Improve "URL" handling in markdown editor (#7006)
Some checks are pending
/ release (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions

The button to insert an URL now opens a dialog prompting for the two
components, the URL and the description.
Any existing text selection is taken into account to pre-fill the
description field.

Closes: #6731

![image](/attachments/ca1f7767-5fd6-4c38-8c7a-83d893523de2)

Co-authored-by: Otto Richter <otto@codeberg.org>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7006
Reviewed-by: Otto <otto@codeberg.org>
Co-authored-by: Lucas Schwiderski <lucas@lschwiderski.de>
Co-committed-by: Lucas Schwiderski <lucas@lschwiderski.de>
This commit is contained in:
Lucas Schwiderski 2025-02-25 20:40:16 +00:00 committed by Otto
parent 3372db660c
commit 6dad457552
4 changed files with 114 additions and 1 deletions

View file

@ -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

View file

@ -29,7 +29,7 @@ Template Attributes:
<div class="markdown-toolbar-group">
<md-quote class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.quote.tooltip"}}">{{svg "octicon-quote"}}</md-quote>
<md-code class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.code.tooltip"}}">{{svg "octicon-code"}}</md-code>
<md-link class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.link.tooltip"}}">{{svg "octicon-link"}}</md-link>
<button class="markdown-toolbar-button show-modal button" data-md-button data-md-action="new-link" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.link.tooltip"}}">{{svg "octicon-link"}}</button>
</div>
<div class="markdown-toolbar-group">
<md-unordered-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.unordered.tooltip"}}">{{svg "octicon-list-unordered"}}</md-unordered-list>
@ -88,4 +88,30 @@ Template Attributes:
<button class="ui primary button" data-selector-name="ok-button">{{ctx.Locale.Tr "ok"}}</button>
</div>
</div>
<div class="ui small modal" data-modal-name="new-markdown-link">
<div class="header">{{ctx.Locale.Tr "editor.link_modal.header"}}</div>
<fieldset class="content">
<div class="ui form" data-selector-name="form">
<label>
{{ctx.Locale.Tr "editor.link_modal.url"}}
<input name="link-url" required dir="auto" autocomplete="off">
</label>
<label>
{{ctx.Locale.Tr "editor.link_modal.description"}}
<input name="link-description" required dir="auto" autocomplete="off">
</label>
<div class="help">
{{ctx.Locale.Tr "editor.link_modal.paste_reminder"}}
</div>
<div class="text right actions">
<button class="ui cancel button" data-selector-name="cancel-button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" data-selector-name="ok-button">{{ctx.Locale.Tr "ok"}}</button>
</div>
</div>
</fieldset>
</div>
</div>

View file

@ -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);

View file

@ -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',