diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index ce0bc4d40e9..f7429601afa 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -31,11 +31,17 @@ function blockTagText(text, textArea, blockTag, selected) { } } -function moveCursor(textArea, tag, wrapped, removedLastNewLine) { +function moveCursor({ textArea, tag, wrapped, removedLastNewLine, select }) { var pos; if (!textArea.setSelectionRange) { return; } + if (select && select.length > 0) { + // calculate the part of the text to be selected + const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); + const endPosition = startPosition + select.length; + return textArea.setSelectionRange(startPosition, endPosition); + } if (textArea.selectionStart === textArea.selectionEnd) { if (wrapped) { pos = textArea.selectionStart - tag.length; @@ -51,7 +57,7 @@ function moveCursor(textArea, tag, wrapped, removedLastNewLine) { } } -export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap) { +export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) { var textToInsert, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; removedLastNewLine = false; removedFirstNewLine = false; @@ -82,11 +88,16 @@ export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + const textPlaceholder = '{text}'; + if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { if (blockTag != null && blockTag !== '') { textToInsert = blockTagText(text, textArea, blockTag, selected); } else { textToInsert = selectedSplit.map(function(val) { + if (tag.indexOf(textPlaceholder) > -1) { + return tag.replace(textPlaceholder, val); + } if (val.indexOf(tag) === 0) { return "" + (val.replace(tag, '')); } else { @@ -94,6 +105,8 @@ export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap } }).join('\n'); } + } else if (tag.indexOf(textPlaceholder) > -1) { + textToInsert = tag.replace(textPlaceholder, selected); } else { textToInsert = "" + startChar + tag + selected + (wrap ? tag : ' '); } @@ -107,17 +120,17 @@ export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap } insertText(textArea, textToInsert); - return moveCursor(textArea, tag, wrap, removedLastNewLine); + return moveCursor({ textArea, tag: tag.replace(textPlaceholder, selected), wrap, removedLastNewLine, select }); } -function updateText(textArea, tag, blockTag, wrap) { +function updateText({ textArea, tag, blockTag, wrap, select }) { var $textArea, selected, text; $textArea = $(textArea); textArea = $textArea.get(0); text = $textArea.val(); selected = selectedText(text, textArea); $textArea.focus(); - return insertMarkdownText(textArea, text, tag, blockTag, selected, wrap); + return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }); } function replaceRange(s, start, end, substitute) { @@ -127,7 +140,12 @@ function replaceRange(s, start, end, substitute) { export function addMarkdownListeners(form) { return $('.js-md', form).off('click').on('click', function() { const $this = $(this); - return updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend')); + return updateText({ + textArea: $this.closest('.md-area').find('textarea'), + tag: $this.data('mdTag'), + blockTag: $this.data('mdBlock'), + wrap: !$this.data('mdPrepend'), + select: $this.data('mdSelect') }); }); } diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 8c22f3f6536..afc4196c729 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -105,6 +105,12 @@ button-title="Insert code" icon="code" /> + "**" }, title: "Add bold text" }) - = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: "Add italic text" }) - = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: "Insert a quote" }) - = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: "Insert code" }) - = 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: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: "Add a task list" }) - %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: "Go full screen", data: { container: "body" } } + = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") }) + = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") }) + = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") }) + = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") }) + = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") }) + = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") }) + = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") }) + = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") }) + %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } } = sprite_icon("screen-full") .md-write-holder diff --git a/changelogs/unreleased/44627-add-link-md-editor.yml b/changelogs/unreleased/44627-add-link-md-editor.yml new file mode 100644 index 00000000000..65551ce9c14 --- /dev/null +++ b/changelogs/unreleased/44627-add-link-md-editor.yml @@ -0,0 +1,5 @@ +--- +title: Add link button to markdown editor toolbar +merge_request: 18579 +author: Jan Beckmann +type: added diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 01448223a7d..cc11577b624 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3651,6 +3651,33 @@ msgstr "" msgid "Markdown enabled" msgstr "" +msgid "MarkdownToolbar|Add a bullet list" +msgstr "" + +msgid "MarkdownToolbar|Add a link" +msgstr "" + +msgid "MarkdownToolbar|Add a numbered list" +msgstr "" + +msgid "MarkdownToolbar|Add a task list" +msgstr "" + +msgid "MarkdownToolbar|Add bold text" +msgstr "" + +msgid "MarkdownToolbar|Add italic text" +msgstr "" + +msgid "MarkdownToolbar|Go full screen" +msgstr "" + +msgid "MarkdownToolbar|Insert a quote" +msgstr "" + +msgid "MarkdownToolbar|Insert code" +msgstr "" + msgid "Max access level" msgstr "" diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js index ca0e7c395a0..043dd018e0c 100644 --- a/spec/javascripts/lib/utils/text_markdown_spec.js +++ b/spec/javascripts/lib/utils/text_markdown_spec.js @@ -21,7 +21,7 @@ describe('init markdown', () => { textArea.selectionStart = 0; textArea.selectionEnd = 0; - insertMarkdownText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText({ textArea, text: textArea.value, tag: '*', blockTag: null, selected: '', wrap: false }); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -32,7 +32,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - insertMarkdownText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText({ textArea, text: textArea.value, tag: '*', blockTag: null, selected: '', wrap: false }); expect(textArea.value).toEqual(`${initialValue}\n* `); }); @@ -43,7 +43,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - insertMarkdownText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText({ textArea, text: textArea.value, tag: '*', blockTag: null, selected: '', wrap: false }); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -54,9 +54,70 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - insertMarkdownText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText({ textArea, text: textArea.value, tag: '*', blockTag: null, selected: '', wrap: false }); expect(textArea.value).toEqual(`${initialValue}* `); }); }); + + describe('with selection', () => { + const text = 'initial selected value'; + const selected = 'selected'; + beforeEach(() => { + textArea.value = text; + const selectedIndex = text.indexOf(selected); + textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length); + }); + + it('applies the tag to the selected value', () => { + insertMarkdownText({ textArea, text: textArea.value, tag: '*', blockTag: null, selected, wrap: true }); + + expect(textArea.value).toEqual(text.replace(selected, `*${selected}*`)); + }); + + it('replaces the placeholder in the tag', () => { + insertMarkdownText({ textArea, text: textArea.value, tag: '[{text}](url)', blockTag: null, selected, wrap: false }); + + expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`)); + }); + + describe('and text to be selected', () => { + const tag = '[{text}](url)'; + const select = 'url'; + + it('selects the text', () => { + insertMarkdownText({ textArea, + text: textArea.value, + tag, + blockTag: null, + selected, + wrap: false, + select }); + + const expectedText = text.replace(selected, `[${selected}](url)`); + expect(textArea.value).toEqual(expectedText); + expect(textArea.selectionStart).toEqual(expectedText.indexOf(select)); + expect(textArea.selectionEnd).toEqual(expectedText.indexOf(select) + select.length); + }); + + it('selects the right text when multiple tags are present', () => { + const initialValue = `${tag} ${tag} ${selected}`; + textArea.value = initialValue; + const selectedIndex = initialValue.indexOf(selected); + textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length); + insertMarkdownText({ textArea, + text: textArea.value, + tag, + blockTag: null, + selected, + wrap: false, + select }); + + const expectedText = initialValue.replace(selected, `[${selected}](url)`); + expect(textArea.value).toEqual(expectedText); + expect(textArea.selectionStart).toEqual(expectedText.lastIndexOf(select)); + expect(textArea.selectionEnd).toEqual(expectedText.lastIndexOf(select) + select.length); + }); + }); + }); }); diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js index 69034975422..0dea9278cc2 100644 --- a/spec/javascripts/vue_shared/components/markdown/field_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -153,7 +153,7 @@ describe('Markdown field component', () => { const textarea = vm.$el.querySelector('textarea'); textarea.setSelectionRange(0, 0); - vm.$el.querySelectorAll('.js-md')[4].click(); + vm.$el.querySelectorAll('.js-md')[5].click(); Vue.nextTick(() => { expect( @@ -168,7 +168,7 @@ describe('Markdown field component', () => { const textarea = vm.$el.querySelector('textarea'); textarea.setSelectionRange(0, 50); - vm.$el.querySelectorAll('.js-md')[4].click(); + vm.$el.querySelectorAll('.js-md')[5].click(); Vue.nextTick(() => { expect( diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js index 488575df401..bc934afe7a4 100644 --- a/spec/javascripts/vue_shared/components/markdown/header_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js @@ -18,7 +18,7 @@ describe('Markdown field header component', () => { }); it('renders markdown buttons', () => { - expect(vm.$el.querySelectorAll('.js-md').length).toBe(7); + expect(vm.$el.querySelectorAll('.js-md').length).toBe(8); }); it('renders `write` link as active when previewMarkdown is false', () => {