From 6033c47f90971da930f06594c040d29828ea64c3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 17 Sep 2025 01:55:57 +0200 Subject: [PATCH] Enable more markdown paste features in textarea editor (#35494) Enable the [same paste features](https://github.com/github/paste-markdown#paste-markdown-objects) that GitHub has, notably the ability to paste text containing HTML links and have them automatically turn into Markdown links. As far as I can tell, previous paste features all work as expected. --------- Signed-off-by: silverwind --- package.json | 1 + pnpm-lock.yaml | 8 +++ web_src/js/features/comp/EditorUpload.test.ts | 12 +---- web_src/js/features/comp/EditorUpload.ts | 49 +++---------------- web_src/js/utils/dom.ts | 22 --------- web_src/js/utils/url.test.ts | 9 +--- web_src/js/utils/url.ts | 12 ----- 7 files changed, 18 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index d3eb83fd0f..832a629e71 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@citation-js/plugin-csl": "0.7.18", "@citation-js/plugin-software-formats": "0.6.1", "@github/markdown-toolbar-element": "2.2.3", + "@github/paste-markdown": "1.5.3", "@github/relative-time-element": "4.4.8", "@github/text-expander-element": "2.9.2", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ff860f2c7..3ba3d91d55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@github/markdown-toolbar-element': specifier: 2.2.3 version: 2.2.3 + '@github/paste-markdown': + specifier: 1.5.3 + version: 1.5.3 '@github/relative-time-element': specifier: 4.4.8 version: 4.4.8 @@ -718,6 +721,9 @@ packages: '@github/markdown-toolbar-element@2.2.3': resolution: {integrity: sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==} + '@github/paste-markdown@1.5.3': + resolution: {integrity: sha512-PzZ1b3PaqBzYqbT4fwKEhiORf38h2OcGp2+JdXNNM7inZ7egaSmfmhyNkQILpqWfS0AYtRS3CDq6z03eZ8yOMQ==} + '@github/relative-time-element@4.4.8': resolution: {integrity: sha512-FSLYm6F3TSQnqHE1EMQUVVgi2XjbCvsESwwXfugHFpBnhyF1uhJOtu0Psp/BB/qqazfdkk7f5fVcu7WuXl3t8Q==} @@ -5024,6 +5030,8 @@ snapshots: '@github/markdown-toolbar-element@2.2.3': {} + '@github/paste-markdown@1.5.3': {} + '@github/relative-time-element@4.4.8': {} '@github/text-expander-element@2.9.2': diff --git a/web_src/js/features/comp/EditorUpload.test.ts b/web_src/js/features/comp/EditorUpload.test.ts index e6e5f4de13..55f3f74389 100644 --- a/web_src/js/features/comp/EditorUpload.test.ts +++ b/web_src/js/features/comp/EditorUpload.test.ts @@ -1,4 +1,4 @@ -import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts'; +import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts'; test('removeAttachmentLinksFromMarkdown', () => { expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b'); @@ -12,13 +12,3 @@ test('removeAttachmentLinksFromMarkdown', () => { expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); }); - -test('preparePasteAsMarkdownLink', () => { - expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull(); - expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull(); - expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull(); - expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)'); - expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)'); - expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull(); - expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull(); -}); diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index bf78f58daf..3e4a84568c 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -1,12 +1,11 @@ import {imageInfo} from '../../utils/image.ts'; -import {replaceTextareaSelection} from '../../utils/dom.ts'; -import {isUrl} from '../../utils/url.ts'; import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts'; import { DropzoneCustomEventRemovedFile, DropzoneCustomEventUploadDone, generateMarkdownLinkForAttachment, } from '../dropzone.ts'; +import {subscribe} from '@github/paste-markdown'; import type CodeMirror from 'codemirror'; import type EasyMDE from 'easymde'; import type {DropzoneFile} from 'dropzone'; @@ -118,46 +117,20 @@ export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string return text; } -export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null { - const {value, selectionStart, selectionEnd} = textarea; - const selectedText = value.substring(selectionStart, selectionEnd); - const trimmedText = pastedText.trim(); - const beforeSelection = value.substring(0, selectionStart); - const afterSelection = value.substring(selectionEnd); - const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')'); - const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink; - return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null; -} - -function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) { - // pasting with "shift" means "paste as original content" in most applications - if (isShiftDown) return; // let the browser handle it - - // when pasting links over selected text, turn it into [text](link) - const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText); - if (pastedAsMarkdown) { - e.preventDefault(); - replaceTextareaSelection(textarea, pastedAsMarkdown); - } - // else, let the browser handle it -} - -// extract text and images from "paste" event -function getPastedContent(e: ClipboardEvent) { - const images = []; +function getPastedImages(e: ClipboardEvent) { + const images: Array = []; for (const item of e.clipboardData?.items ?? []) { if (item.type?.startsWith('image/')) { images.push(item.getAsFile()); } } - const text = e.clipboardData?.getData?.('text') ?? ''; - return {text, images}; + return images; } export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) { const editor = new CodeMirrorEditor(easyMDE.codemirror as any); easyMDE.codemirror.on('paste', (_, e) => { - const {images} = getPastedContent(e); + const images = getPastedImages(e); if (!images.length) return; handleUploadFiles(editor, dropzoneEl, images, e); }); @@ -173,19 +146,11 @@ export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) { } export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) { - let isShiftDown = false; - textarea.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.shiftKey) isShiftDown = true; - }); - textarea.addEventListener('keyup', (e: KeyboardEvent) => { - if (!e.shiftKey) isShiftDown = false; - }); + subscribe(textarea); // enable paste features textarea.addEventListener('paste', (e: ClipboardEvent) => { - const {images, text} = getPastedContent(e); + const images = getPastedImages(e); if (images.length && dropzoneEl) { handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e); - } else if (text) { - handleClipboardText(textarea, e, text, isShiftDown); } }); textarea.addEventListener('drop', (e: DragEvent) => { diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 1da6b71de6..3a1b74c1db 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -286,28 +286,6 @@ export function isElemVisible(el: HTMLElement): boolean { return !el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none'; } -/** replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this */ -export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) { - const before = textarea.value.slice(0, textarea.selectionStart ?? undefined); - const after = textarea.value.slice(textarea.selectionEnd ?? undefined); - let success = false; - - textarea.contentEditable = 'true'; - try { - success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated - } catch {} // ignore the error if execCommand is not supported or failed - textarea.contentEditable = 'false'; - - if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) { - success = false; - } - - if (!success) { - textarea.value = `${before}${text}${after}`; - textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true})); - } -} - export function createElementFromHTML(htmlString: string): T { htmlString = htmlString.trim(); // There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js diff --git a/web_src/js/utils/url.test.ts b/web_src/js/utils/url.test.ts index bb331a6b49..c39dd15732 100644 --- a/web_src/js/utils/url.test.ts +++ b/web_src/js/utils/url.test.ts @@ -1,17 +1,10 @@ -import {pathEscapeSegments, isUrl, toOriginUrl} from './url.ts'; +import {pathEscapeSegments, toOriginUrl} from './url.ts'; test('pathEscapeSegments', () => { expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c'); expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c'); }); -test('isUrl', () => { - expect(isUrl('https://example.com')).toEqual(true); - expect(isUrl('https://example.com/')).toEqual(true); - expect(isUrl('https://example.com/index.html')).toEqual(true); - expect(isUrl('/index.html')).toEqual(false); -}); - test('toOriginUrl', () => { const oldLocation = String(window.location); for (const origin of ['https://example.com', 'https://example.com:3000']) { diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts index 9991da7472..6bcb4c1609 100644 --- a/web_src/js/utils/url.ts +++ b/web_src/js/utils/url.ts @@ -2,18 +2,6 @@ export function pathEscapeSegments(s: string): string { return s.split('/').map(encodeURIComponent).join('/'); } -function stripSlash(url: string): string { - return url.endsWith('/') ? url.slice(0, -1) : url; -} - -export function isUrl(url: string): boolean { - try { - return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim(); - } catch { - return false; - } -} - /** Convert an absolute or relative URL to an absolute URL with the current origin. It only * processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */ export function toOriginUrl(urlStr: string) {