From 72bd58b49ab19aa43c6802189bb0be985da4e32d Mon Sep 17 00:00:00 2001
From: Brett Walker <bwalker@gitlab.com>
Date: Fri, 23 Sep 2022 16:21:26 +0000
Subject: [PATCH] Use toolbar buttons for indent/outdent

to better support cross-platform

Changelog: fixed
---
 .../behaviors/shortcuts/keybindings.js        | 14 +++++
 .../javascripts/lib/utils/text_markdown.js    | 60 +++++++++----------
 .../vue_shared/components/markdown/header.vue | 30 ++++++++++
 .../components/markdown/toolbar_button.vue    |  6 ++
 .../shared/blob/_markdown_buttons.html.haml   |  8 +++
 locale/gitlab.pot                             | 18 ++++++
 spec/frontend/lib/utils/text_markdown_spec.js | 38 +++++++-----
 .../components/markdown/header_spec.js        |  2 +
 8 files changed, 128 insertions(+), 48 deletions(-)

diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 23b66405844c0..3239375bf7c82 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -145,6 +145,20 @@ export const LINK_TEXT = {
   customizable: false,
 };
 
+export const INDENT_LINE = {
+  id: 'editing.indentLine',
+  description: __('Indent line'),
+  defaultKeys: ['mod+]'], // eslint-disable-line @gitlab/require-i18n-strings
+  customizable: false,
+};
+
+export const OUTDENT_LINE = {
+  id: 'editing.outdentLine',
+  description: __('Outdent line'),
+  defaultKeys: ['mod+['], // eslint-disable-line @gitlab/require-i18n-strings
+  customizable: false,
+};
+
 export const TOGGLE_MARKDOWN_PREVIEW = {
   id: 'editing.toggleMarkdownPreview',
   description: __('Toggle Markdown preview'),
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 48be8af3ff666..5e2b8b786a45e 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -391,13 +391,15 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
 /**
  * Indents selected lines to the right by 2 spaces
  *
- * @param {Object} textArea - the targeted text area
+ * @param {Object} textArea - jQuery object with the targeted text area
  */
-function indentLines(textArea) {
+function indentLines($textArea) {
+  const textArea = $textArea.get(0);
   const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
   const shiftedLines = [];
   let totalAdded = 0;
 
+  textArea.focus();
   textArea.setSelectionRange(startPos, endPos);
 
   lines.forEach((line) => {
@@ -418,13 +420,15 @@ function indentLines(textArea) {
  *
  * @param {Object} textArea - the targeted text area
  */
-function outdentLines(textArea) {
+function outdentLines($textArea) {
+  const textArea = $textArea.get(0);
   const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
   const shiftedLines = [];
   let totalRemoved = 0;
   let removedFromFirstline = -1;
   let removedFromLine = 0;
 
+  textArea.focus();
   textArea.setSelectionRange(startPos, endPos);
 
   lines.forEach((line) => {
@@ -460,28 +464,10 @@ function outdentLines(textArea) {
   );
 }
 
-function handleIndentOutdent(e, textArea) {
-  if (e.altKey || e.ctrlKey || e.shiftKey) return;
-  if (!e.metaKey) return;
-
-  switch (e.key) {
-    case ']':
-      e.preventDefault();
-      indentLines(textArea);
-      break;
-    case '[':
-      e.preventDefault();
-      outdentLines(textArea);
-      break;
-    default:
-      break;
-  }
-}
-
 /* eslint-disable @gitlab/require-i18n-strings */
 function handleSurroundSelectedText(e, textArea) {
   if (!gon.markdown_surround_selection) return;
-  if (e.metaKey) return;
+  if (e.metaKey || e.ctrlKey) return;
   if (textArea.selectionStart === textArea.selectionEnd) return;
 
   const keys = {
@@ -586,7 +572,6 @@ export function keypressNoteText(e) {
 
   if ($(textArea).atwho?.('isSelecting')) return;
 
-  handleIndentOutdent(e, textArea);
   handleContinueList(e, textArea);
   handleSurroundSelectedText(e, textArea);
 }
@@ -600,15 +585,26 @@ export function compositionEndNoteText() {
 }
 
 export function updateTextForToolbarBtn($toolbarBtn) {
-  return updateText({
-    textArea: $toolbarBtn.closest('.md-area').find('textarea'),
-    tag: $toolbarBtn.data('mdTag'),
-    cursorOffset: $toolbarBtn.data('mdCursorOffset'),
-    blockTag: $toolbarBtn.data('mdBlock'),
-    wrap: !$toolbarBtn.data('mdPrepend'),
-    select: $toolbarBtn.data('mdSelect'),
-    tagContent: $toolbarBtn.attr('data-md-tag-content'),
-  });
+  const $textArea = $toolbarBtn.closest('.md-area').find('textarea');
+
+  switch ($toolbarBtn.data('mdCommand')) {
+    case 'indentLines':
+      indentLines($textArea);
+      break;
+    case 'outdentLines':
+      outdentLines($textArea);
+      break;
+    default:
+      return updateText({
+        textArea: $textArea,
+        tag: $toolbarBtn.data('mdTag'),
+        cursorOffset: $toolbarBtn.data('mdCursorOffset'),
+        blockTag: $toolbarBtn.data('mdBlock'),
+        wrap: !$toolbarBtn.data('mdPrepend'),
+        select: $toolbarBtn.data('mdSelect'),
+        tagContent: $toolbarBtn.attr('data-md-tag-content'),
+      });
+  }
 }
 
 export function addMarkdownListeners(form) {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 458dfe0ed23bc..473d3b4a41a8e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -7,6 +7,8 @@ import {
   ITALIC_TEXT,
   STRIKETHROUGH_TEXT,
   LINK_TEXT,
+  INDENT_LINE,
+  OUTDENT_LINE,
 } from '~/behaviors/shortcuts/keybindings';
 import { getSelectedFragment } from '~/lib/utils/common_utils';
 import { s__, __ } from '~/locale';
@@ -170,6 +172,8 @@ export default {
     italic: keysFor(ITALIC_TEXT),
     strikethrough: keysFor(STRIKETHROUGH_TEXT),
     link: keysFor(LINK_TEXT),
+    indent: keysFor(INDENT_LINE),
+    outdent: keysFor(OUTDENT_LINE),
   },
   i18n: {
     writeTabTitle: __('Write'),
@@ -317,6 +321,32 @@ export default {
             :button-title="__('Add a checklist')"
             icon="list-task"
           />
+          <toolbar-button
+            v-if="!restrictedToolBarItems.includes('indent')"
+            class="gl-display-none"
+            :button-title="
+              /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+              sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), {
+                modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+              })
+            "
+            :shortcuts="$options.shortcuts.indent"
+            command="indentLines"
+            icon="list-indent"
+          />
+          <toolbar-button
+            v-if="!restrictedToolBarItems.includes('outdent')"
+            class="gl-display-none"
+            :button-title="
+              /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+              sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), {
+                modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+              })
+            "
+            :shortcuts="$options.shortcuts.outdent"
+            command="outdentLines"
+            icon="list-outdent"
+          />
           <toolbar-button
             v-if="!restrictedToolBarItems.includes('collapsible-section')"
             :tag="mdCollapsibleSection"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 49217e38a1bb0..5ca21522d33f5 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -47,6 +47,11 @@ export default {
       required: false,
       default: 0,
     },
+    command: {
+      type: String,
+      required: false,
+      default: '',
+    },
 
     /**
      * A string (or an array of strings) of
@@ -81,6 +86,7 @@ export default {
     :data-md-tag-content="tagContent"
     :data-md-prepend="prepend"
     :data-md-shortcuts="shortcutsString"
+    :data-md-command="command"
     :title="buttonTitle"
     :aria-label="buttonTitle"
     :icon="icon"
diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index 4db1d20e81b76..00e8d39b26bc1 100644
--- a/app/views/shared/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -23,6 +23,14 @@
   = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
   = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
   = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") })
+  = markdown_toolbar_button({ icon: "list-indent",
+                              data: { "md-command" => 'indentLines', "md-shortcuts": '["mod+]"]' },
+                              css_class: 'gl-display-none',
+                              title: sprintf(s_("MarkdownEditor|Indent line (%{modifier_key}])") % { modifier_key: modifier_key }) })
+  = markdown_toolbar_button({ icon: "list-outdent",
+                              data: { "md-command" => 'outdentLines', "md-shortcuts": '["mod+["]' },
+                              css_class: 'gl-display-none',
+                              title: sprintf(s_("MarkdownEditor|Outdent line (%{modifier_key}[)") % { modifier_key: modifier_key }) })
   = markdown_toolbar_button({ icon: "details-block",
                               data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" },
                               title: _("Add a collapsible section") })
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 362fb5d5ec523..ae973cecdf81f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -21171,6 +21171,9 @@ msgstr ""
 msgid "Increase"
 msgstr ""
 
+msgid "Indent line"
+msgstr ""
+
 msgid "Index"
 msgstr ""
 
@@ -24407,6 +24410,18 @@ msgstr ""
 msgid "MarkdownEditor|Add strikethrough text (%{modifier_key}⇧X)"
 msgstr ""
 
+msgid "MarkdownEditor|Indent line (%{modifierKey}])"
+msgstr ""
+
+msgid "MarkdownEditor|Indent line (%{modifier_key}])"
+msgstr ""
+
+msgid "MarkdownEditor|Outdent line (%{modifierKey}[)"
+msgstr ""
+
+msgid "MarkdownEditor|Outdent line (%{modifier_key}[)"
+msgstr ""
+
 msgid "MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}"
 msgstr ""
 
@@ -28114,6 +28129,9 @@ msgstr ""
 msgid "OutdatedBrowser|Please install a %{browser_link_start}supported web browser%{browser_link_end} for a better experience."
 msgstr ""
 
+msgid "Outdent line"
+msgstr ""
+
 msgid "Overridden"
 msgstr ""
 
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index 8d179baa5051f..ba3c73d662de1 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -4,15 +4,30 @@ import {
   keypressNoteText,
   compositionStartNoteText,
   compositionEndNoteText,
+  updateTextForToolbarBtn,
 } from '~/lib/utils/text_markdown';
 import '~/lib/utils/jquery_at_who';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
 
 describe('init markdown', () => {
+  let mdArea;
   let textArea;
+  let indentButton;
+  let outdentButton;
 
   beforeAll(() => {
-    textArea = document.createElement('textarea');
-    document.querySelector('body').appendChild(textArea);
+    setHTMLFixture(
+      `<div class='md-area'>
+        <textarea></textarea>
+        <button data-md-command="indentLines" id="indentButton"></button>
+        <button data-md-command="outdentLines" id="outdentButton"></button>
+      </div>`,
+    );
+    mdArea = document.querySelector('.md-area');
+    textArea = mdArea.querySelector('textarea');
+    indentButton = mdArea.querySelector('#indentButton');
+    outdentButton = mdArea.querySelector('#outdentButton');
+
     textArea.focus();
 
     // needed for the underlying insertText to work
@@ -20,7 +35,7 @@ describe('init markdown', () => {
   });
 
   afterAll(() => {
-    textArea.parentNode.removeChild(textArea);
+    resetHTMLFixture();
   });
 
   describe('insertMarkdownText', () => {
@@ -306,15 +321,6 @@ describe('init markdown', () => {
     });
 
     describe('shifting selected lines left or right', () => {
-      const indentEvent = new KeyboardEvent('keydown', { key: ']', metaKey: true });
-      const outdentEvent = new KeyboardEvent('keydown', { key: '[', metaKey: true });
-
-      beforeEach(() => {
-        textArea.addEventListener('keydown', keypressNoteText);
-        textArea.addEventListener('compositionstart', compositionStartNoteText);
-        textArea.addEventListener('compositionend', compositionEndNoteText);
-      });
-
       it.each`
         selectionStart | selectionEnd | expected                | expectedSelectionStart | expectedSelectionEnd
         ${0}           | ${0}         | ${'  012\n456\n89'}     | ${2}                   | ${2}
@@ -338,7 +344,7 @@ describe('init markdown', () => {
           textArea.value = text;
           textArea.setSelectionRange(selectionStart, selectionEnd);
 
-          textArea.dispatchEvent(indentEvent);
+          updateTextForToolbarBtn($(indentButton));
 
           expect(textArea.value).toEqual(expected);
           expect(textArea.selectionStart).toEqual(expectedSelectionStart);
@@ -350,7 +356,7 @@ describe('init markdown', () => {
         textArea.value = '012\n\n89';
         textArea.setSelectionRange(4, 4);
 
-        textArea.dispatchEvent(indentEvent);
+        updateTextForToolbarBtn($(indentButton));
 
         expect(textArea.value).toEqual('012\n  \n89');
         expect(textArea.selectionStart).toEqual(6);
@@ -381,7 +387,7 @@ describe('init markdown', () => {
           textArea.value = text;
           textArea.setSelectionRange(selectionStart, selectionEnd);
 
-          textArea.dispatchEvent(outdentEvent);
+          updateTextForToolbarBtn($(outdentButton));
 
           expect(textArea.value).toEqual(expected);
           expect(textArea.selectionStart).toEqual(expectedSelectionStart);
@@ -393,7 +399,7 @@ describe('init markdown', () => {
         textArea.value = '012\n\n89';
         textArea.setSelectionRange(4, 4);
 
-        textArea.dispatchEvent(outdentEvent);
+        updateTextForToolbarBtn($(outdentButton));
 
         expect(textArea.value).toEqual('012\n\n89');
         expect(textArea.selectionStart).toEqual(4);
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 9831908f806d4..afeaa5ec8507e 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -54,6 +54,8 @@ describe('Markdown field header component', () => {
         'Add a bullet list',
         'Add a numbered list',
         'Add a checklist',
+        'Indent line (⌘])',
+        'Outdent line (⌘[)',
         'Add a collapsible section',
         'Add a table',
         'Go full screen',
-- 
GitLab