mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(app): wrap the selection in a link when pasting a URL (#1747)
This commit is contained in:
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}>)`;
|
||||
}
|
||||
Reference in new issue
Block a user