feat(app): wrap the selection in a link when pasting a URL (#1747)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-19 12:55:15 +02:00
1 parent 7b5c82c6cf
commit a6f83c4fb1
3 files changed
+141

No files matched your search

@@ -1,5 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildMaskedLink,
canWrapSelectionAsLink,
parsePastedUrl,
} from '@app/features/lexical/composer/ComposerLinkPaste';
import {
$createComposerSegmentNodes,
$projectComposer,
@@ -460,6 +465,35 @@ export function registerComposerClipboardCommands(
state: ComposerClipboardCommandState,
): () => void {
return mergeRegister(
editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!state.isEditable() || state.getPlainText()) {
return false;
}
const clipboardEvent = getClipboardEvent(event);
if (clipboardEvent == null || clipboardEvent.clipboardData == null) {
return false;
}
const url = parsePastedUrl(clipboardEvent.clipboardData.getData('text/plain'));
if (url == null) {
return false;
}
const selection = $getSelection();
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
return false;
}
const selectedText = selection.getTextContent();
if (!canWrapSelectionAsLink(selectedText)) {
return false;
}
selection.insertText(buildMaskedLink(selectedText, url));
$addUpdateTag(PASTE_TAG);
clipboardEvent.preventDefault();
return true;
},
COMMAND_PRIORITY_NORMAL,
),
editor.registerCommand(
COPY_COMMAND,
(event) => {
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildMaskedLink,
canWrapSelectionAsLink,
parsePastedUrl,
} from '@app/features/lexical/composer/ComposerLinkPaste';
import {describe, expect, it} from 'vitest';
describe('parsePastedUrl', () => {
it('accepts http and https urls', () => {
expect(parsePastedUrl('https://fluxer.app/download')).toBe('https://fluxer.app/download');
expect(parsePastedUrl(' http://example.test/a?b=c#d ')).toBe('http://example.test/a?b=c#d');
});
it('rejects non-web protocols', () => {
for (const value of ['javascript:alert(1)', 'data:text/html,<b>x</b>', 'file:///etc/passwd', 'mailto:[email protected]']) {
expect(parsePastedUrl(value)).toBeNull();
}
});
it('rejects prose that merely contains a url', () => {
expect(parsePastedUrl('see https://fluxer.app for more')).toBeNull();
});
it('rejects angle brackets that would break the masked link', () => {
expect(parsePastedUrl('https://fluxer.app/<script>')).toBeNull();
});
it('rejects empty and oversized input', () => {
expect(parsePastedUrl('')).toBeNull();
expect(parsePastedUrl(null)).toBeNull();
expect(parsePastedUrl(`https://fluxer.app/${'a'.repeat(4000)}`)).toBeNull();
});
});
describe('canWrapSelectionAsLink', () => {
it('accepts ordinary selected words', () => {
expect(canWrapSelectionAsLink('the download page')).toBe(true);
});
it('rejects blank or multiline selections', () => {
expect(canWrapSelectionAsLink(' ')).toBe(false);
expect(canWrapSelectionAsLink('first\nsecond')).toBe(false);
});
it('rejects selections containing markdown link brackets', () => {
expect(canWrapSelectionAsLink('already [linked]')).toBe(false);
});
it('rejects replacing a url with a url', () => {
expect(canWrapSelectionAsLink('https://example.test')).toBe(false);
});
});
describe('buildMaskedLink', () => {
it('uses the escaped destination form so parentheses survive', () => {
expect(buildMaskedLink('wiki', 'https://en.wikipedia.org/wiki/Foo_(bar)')).toBe(
'[wiki](<https://en.wikipedia.org/wiki/Foo_(bar)>)',
);
});
});
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const MAX_PASTED_URL_LENGTH = 2048;
const MAX_WRAPPED_SELECTION_LENGTH = 1024;
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
export function parsePastedUrl(clipboardText: string | null | undefined): string | null {
if (clipboardText == null) {
return null;
}
const candidate = clipboardText.trim();
if (candidate.length === 0 || candidate.length > MAX_PASTED_URL_LENGTH) {
return null;
}
if (/[\s<>]/.test(candidate)) {
return null;
}
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
return null;
}
return candidate;
}
export function canWrapSelectionAsLink(selectedText: string): boolean {
if (selectedText.trim().length === 0 || selectedText.length > MAX_WRAPPED_SELECTION_LENGTH) {
return false;
}
if (/[\r\n]/.test(selectedText)) {
return false;
}
if (/[[\]]/.test(selectedText)) {
return false;
}
return parsePastedUrl(selectedText) === null;
}
export function buildMaskedLink(selectedText: string, url: string): string {
return `[${selectedText}](<${url}>)`;
}