From 2644272f8ec37e7c35a463e4d3985543be6c90c3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 3 Mar 2024 21:29:19 +0100 Subject: [PATCH] Support pasting URLs over markdown text --- .../js/features/comp/ComboMarkdownEditor.js | 2 +- .../features/comp/{ImagePaste.js => Paste.js} | 56 ++++++++++--------- web_src/js/utils/dom.js | 39 +++++++++++++ web_src/js/utils/url.js | 12 ++++ web_src/js/utils/url.test.js | 9 ++- 5 files changed, 91 insertions(+), 27 deletions(-) rename web_src/js/features/comp/{ImagePaste.js => Paste.js} (75%) diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 4c973358e34b3..06c9dcb0c2175 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -3,7 +3,7 @@ import '@github/text-expander-element'; import $ from 'jquery'; import {attachTribute} from '../tribute.js'; import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js'; -import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js'; +import {initEasyMDEImagePaste, initTextareaImagePaste} from './Paste.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; diff --git a/web_src/js/features/comp/ImagePaste.js b/web_src/js/features/comp/Paste.js similarity index 75% rename from web_src/js/features/comp/ImagePaste.js rename to web_src/js/features/comp/Paste.js index b727880bc851c..a73948b71c9b0 100644 --- a/web_src/js/features/comp/ImagePaste.js +++ b/web_src/js/features/comp/Paste.js @@ -1,6 +1,8 @@ import {htmlEscape} from 'escape-goat'; import {POST} from '../../modules/fetch.js'; import {imageInfo} from '../../utils/image.js'; +import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js'; +import {isUrl} from '../../utils/url.js'; async function uploadFile(file, uploadUrl) { const formData = new FormData(); @@ -10,17 +12,6 @@ async function uploadFile(file, uploadUrl) { return await res.json(); } -function clipboardPastedImages(e) { - if (!e.clipboardData) return []; - - const files = []; - for (const item of e.clipboardData.items || []) { - if (!item.type || !item.type.startsWith('image/')) continue; - files.push(item.getAsFile()); - } - return files; -} - function triggerEditorContentChanged(target) { target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); } @@ -91,20 +82,16 @@ class CodeMirrorEditor { } } -const uploadClipboardImage = async (editor, dropzone, e) => { +async function handleClipboardImages(editor, dropzone, images, e) { const uploadUrl = dropzone.getAttribute('data-upload-url'); const filesContainer = dropzone.querySelector('.files'); - if (!uploadUrl || !filesContainer) return; + if (!dropzone || !uploadUrl || !filesContainer || !images.length) return; - const pastedImages = clipboardPastedImages(e); - if (!pastedImages || pastedImages.length === 0) { - return; - } e.preventDefault(); e.stopPropagation(); - for (const img of pastedImages) { + for (const img of images) { const name = img.name.slice(0, img.name.lastIndexOf('.')); const placeholder = `![${name}](uploading ...)`; @@ -131,18 +118,37 @@ const uploadClipboardImage = async (editor, dropzone, e) => { input.value = uuid; filesContainer.append(input); } -}; +} + +function handleClipboardText(textarea, text, e) { + // when pasting links over selected text, turn it into [text](link) + const {value, selectionStart, selectionEnd} = textarea; + const selectedText = value.substring(selectionStart, selectionEnd); + const trimmedText = text.trim(); + if (selectedText && isUrl(trimmedText)) { + e.stopPropagation(); + e.preventDefault(); + replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); + } +} export function initEasyMDEImagePaste(easyMDE, dropzone) { - if (!dropzone) return; - easyMDE.codemirror.on('paste', async (_, e) => { - return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e); + easyMDE.codemirror.on('paste', (_, e) => { + const {images} = getPastedContent(e); + if (images.length) { + handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); + } }); } export function initTextareaImagePaste(textarea, dropzone) { - if (!dropzone) return; - textarea.addEventListener('paste', async (e) => { - return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e); + textarea.addEventListener('paste', (e) => { + const {images, text} = getPastedContent(e); + if (text) { + handleClipboardText(textarea, text, e); + } + if (images.length) { + handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); + } }); } diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 91535dc187595..b242463cab6fa 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -243,3 +243,42 @@ export function isElemVisible(element) { return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); } + +// extract text and images from "paste" event +export function getPastedContent(e) { + if (!e.clipboardData) return {text: '', images: []}; + + const images = []; + 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}; +} + +// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this +export function replaceTextareaSelection(textarea, text) { + const before = textarea.value.slice(0, textarea.selectionStart ?? undefined); + const after = textarea.value.slice(textarea.selectionEnd ?? undefined); + let success = true; + + textarea.contentEditable = 'true'; + try { + success = document.execCommand('insertText', false, text); + } catch { + success = false; + } + 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})); + } +} diff --git a/web_src/js/utils/url.js b/web_src/js/utils/url.js index a40737ca6f5a2..470ece31b0ed8 100644 --- a/web_src/js/utils/url.js +++ b/web_src/js/utils/url.js @@ -1,3 +1,15 @@ export function pathEscapeSegments(s) { return s.split('/').map(encodeURIComponent).join('/'); } + +function stripSlash(url) { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +export function isUrl(url) { + try { + return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim(); + } catch { + return false; + } +} diff --git a/web_src/js/utils/url.test.js b/web_src/js/utils/url.test.js index 3dbedec94f1e0..08c6373ffb002 100644 --- a/web_src/js/utils/url.test.js +++ b/web_src/js/utils/url.test.js @@ -1,6 +1,13 @@ -import {pathEscapeSegments} from './url.js'; +import {pathEscapeSegments, isUrl} from './url.js'; 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); +});