diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bd7b91cd5a..29c696f29f 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -232,6 +232,11 @@ table_modal.placeholder.content = Content table_modal.label.rows = Rows table_modal.label.columns = Columns +link_modal.header = Add a link +link_modal.url = Url +link_modal.description = Description +link_modal.paste_reminder = Hint: With a URL in your clipboard, you can paste directly into the editor to create a link. + [filter] string.asc = A - Z string.desc = Z - A diff --git a/templates/shared/combomarkdowneditor.tmpl b/templates/shared/combomarkdowneditor.tmpl index 0620cca65f..1ea22660f7 100644 --- a/templates/shared/combomarkdowneditor.tmpl +++ b/templates/shared/combomarkdowneditor.tmpl @@ -29,7 +29,7 @@ Template Attributes:
{{svg "octicon-quote"}} {{svg "octicon-code"}} - {{svg "octicon-link"}} +
{{svg "octicon-list-unordered"}} @@ -88,4 +88,30 @@ Template Attributes:
+ + diff --git a/tests/e2e/markdown-editor.test.e2e.ts b/tests/e2e/markdown-editor.test.e2e.ts index 35e9de2ea6..39ced25bc3 100644 --- a/tests/e2e/markdown-editor.test.e2e.ts +++ b/tests/e2e/markdown-editor.test.e2e.ts @@ -5,6 +5,7 @@ // @watch end import {expect} from '@playwright/test'; +import {accessibilityCheck} from './shared/accessibility.ts'; import {save_visual, test} from './utils_e2e.ts'; test.use({user: 'user2'}); @@ -224,6 +225,33 @@ test('markdown insert table', async ({page}) => { await save_visual(page); }); +test('markdown insert link', async ({page}) => { + const response = await page.goto('/user2/repo1/issues/new'); + expect(response?.status()).toBe(200); + + const newLinkButton = page.locator('button[data-md-action="new-link"]'); + await newLinkButton.click(); + + const newLinkModal = page.locator('div[data-markdown-link-modal-id="0"]'); + await expect(newLinkModal).toBeVisible(); + await accessibilityCheck({page}, ['[data-modal-name="new-markdown-link"]'], [], []); + await save_visual(page); + + const url = 'https://example.com'; + const description = 'Where does this lead?'; + + await newLinkModal.locator('input[name="link-url"]').fill(url); + await newLinkModal.locator('input[name="link-description"]').fill(description); + + await newLinkModal.locator('button[data-selector-name="ok-button"]').click(); + + await expect(newLinkModal).toBeHidden(); + + const textarea = page.locator('textarea[name=content]'); + await expect(textarea).toHaveValue(`[${description}](${url})`); + await save_visual(page); +}); + test('text expander has higher prio then prefix continuation', async ({page}) => { const response = await page.goto('/user2/repo1/issues/new'); expect(response?.status()).toBe(200); diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 89a252f6f3..efd068f354 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -49,6 +49,7 @@ class ComboMarkdownEditor { this.setupDropzone(); this.setupTextarea(); this.setupTableInserter(); + this.setupLinkInserter(); await this.switchToUserPreference(); @@ -93,6 +94,7 @@ class ComboMarkdownEditor { this.indentSelection(true); }); this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-table"]')?.setAttribute('data-modal', `div[data-markdown-table-modal-id="${elementIdCounter}"]`); + this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-link"]')?.setAttribute('data-modal', `div[data-markdown-link-modal-id="${elementIdCounter}"]`); this.textarea.addEventListener('keydown', (e) => { if (e.shiftKey) { @@ -228,6 +230,58 @@ class ComboMarkdownEditor { button.addEventListener('click', this.addNewTable); } + addNewLink(event) { + const elementId = event.target.getAttribute('data-element-id'); + const newLinkModal = document.querySelector(`div[data-markdown-link-modal-id="${elementId}"]`); + const form = newLinkModal.querySelector('div[data-selector-name="form"]'); + + // Validate input fields + for (const currentInput of form.querySelectorAll('input')) { + if (!currentInput.checkValidity()) { + currentInput.reportValidity(); + return; + } + } + + const url = form.querySelector('input[name="link-url"]').value; + const description = form.querySelector('input[name="link-description"]').value; + + const code = `[${description}](${url})`; + + replaceTextareaSelection(document.getElementById(`_combo_markdown_editor_${elementId}`), code); + + // Close the modal then clear its fields in case the user wants to add another one. + newLinkModal.querySelector('button[data-selector-name="cancel-button"]').click(); + form.querySelector('input[name="link-url"]').value = ''; + form.querySelector('input[name="link-description"]').value = ''; + } + + setupLinkInserter() { + const newLinkModal = this.container.querySelector('div[data-modal-name="new-markdown-link"]'); + newLinkModal.setAttribute('data-markdown-link-modal-id', elementIdCounter); + const textarea = document.getElementById(`_combo_markdown_editor_${elementIdCounter}`); + + $(newLinkModal).modal({ + // Pre-fill the description field from the selection to create behavior similar + // to pasting an URL over selected text. + onShow: () => { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + if (start !== end) { + const selection = textarea.value.slice(start ?? undefined, end ?? undefined); + newLinkModal.querySelector('input[name="link-description"]').value = selection; + } else { + newLinkModal.querySelector('input[name="link-description"]').value = ''; + } + }, + }); + + const button = newLinkModal.querySelector('button[data-selector-name="ok-button"]'); + button.setAttribute('data-element-id', elementIdCounter); + button.addEventListener('click', this.addNewLink); + } + prepareEasyMDEToolbarActions() { this.easyMDEToolbarDefault = [ 'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3',