From a3cfe6f39ba33cea305de592a006727857014c53 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 8 Mar 2024 16:15:58 +0100 Subject: Support pasting URLs over markdown text (#29566) Support pasting URLs over selection text in the textarea editor. Does not work in EasyMDE and I don't intend to support it. Image paste works as usual in both Textarea and EasyMDE. The new `replaceTextareaSelection` function changes textarea content via [`insertText`](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#using_inserttext) command, which preserves history, e.g. `CTRL-Z` works and is also demostrated below. We should later refactor the image paste code to use the same function because it currently destroys history. Overriding the formatting via `Shift` key is supported as well, e.g. `Ctrl+Shift+V` will insert the URL as-is, like on GitHub. ![urlpaste](https://github.com/go-gitea/gitea/assets/115237/522b1023-6797-401c-9e4a-498570adfc88) --- web_src/js/features/comp/ComboMarkdownEditor.js | 17 ++- web_src/js/features/comp/ImagePaste.js | 148 ----------------------- web_src/js/features/comp/Paste.js | 154 ++++++++++++++++++++++++ web_src/js/utils/dom.js | 36 ++++++ web_src/js/utils/url.js | 12 ++ web_src/js/utils/url.test.js | 9 +- 6 files changed, 224 insertions(+), 152 deletions(-) delete mode 100644 web_src/js/features/comp/ImagePaste.js create mode 100644 web_src/js/features/comp/Paste.js diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 4c973358e3..1e7b554b98 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 {initEasyMDEPaste, initTextareaPaste} from './Paste.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; @@ -84,6 +84,17 @@ class ComboMarkdownEditor { if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); } + this.textarea.addEventListener('keydown', (e) => { + if (e.shiftKey) { + e.target._shiftDown = true; + } + }); + this.textarea.addEventListener('keyup', (e) => { + if (!e.shiftKey) { + e.target._shiftDown = false; + } + }); + const monospaceButton = this.container.querySelector('.markdown-switch-monospace'); const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); @@ -108,7 +119,7 @@ class ComboMarkdownEditor { }); if (this.dropzone) { - initTextareaImagePaste(this.textarea, this.dropzone); + initTextareaPaste(this.textarea, this.dropzone); } } @@ -241,7 +252,7 @@ class ComboMarkdownEditor { }); this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights); await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true}); - initEasyMDEImagePaste(this.easyMDE, this.dropzone); + initEasyMDEPaste(this.easyMDE, this.dropzone); hideElem(this.textareaMarkdownToolbar); } diff --git a/web_src/js/features/comp/ImagePaste.js b/web_src/js/features/comp/ImagePaste.js deleted file mode 100644 index b727880bc8..0000000000 --- a/web_src/js/features/comp/ImagePaste.js +++ /dev/null @@ -1,148 +0,0 @@ -import {htmlEscape} from 'escape-goat'; -import {POST} from '../../modules/fetch.js'; -import {imageInfo} from '../../utils/image.js'; - -async function uploadFile(file, uploadUrl) { - const formData = new FormData(); - formData.append('file', file, file.name); - - const res = await POST(uploadUrl, {data: formData}); - 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})); -} - -class TextareaEditor { - constructor(editor) { - this.editor = editor; - } - - insertPlaceholder(value) { - const editor = this.editor; - const startPos = editor.selectionStart; - const endPos = editor.selectionEnd; - editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); - editor.selectionStart = startPos; - editor.selectionEnd = startPos + value.length; - editor.focus(); - triggerEditorContentChanged(editor); - } - - replacePlaceholder(oldVal, newVal) { - const editor = this.editor; - const startPos = editor.selectionStart; - const endPos = editor.selectionEnd; - if (editor.value.substring(startPos, endPos) === oldVal) { - editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos); - editor.selectionEnd = startPos + newVal.length; - } else { - editor.value = editor.value.replace(oldVal, newVal); - editor.selectionEnd -= oldVal.length; - editor.selectionEnd += newVal.length; - } - editor.selectionStart = editor.selectionEnd; - editor.focus(); - triggerEditorContentChanged(editor); - } -} - -class CodeMirrorEditor { - constructor(editor) { - this.editor = editor; - } - - insertPlaceholder(value) { - const editor = this.editor; - const startPoint = editor.getCursor('start'); - const endPoint = editor.getCursor('end'); - editor.replaceSelection(value); - endPoint.ch = startPoint.ch + value.length; - editor.setSelection(startPoint, endPoint); - editor.focus(); - triggerEditorContentChanged(editor.getTextArea()); - } - - replacePlaceholder(oldVal, newVal) { - const editor = this.editor; - const endPoint = editor.getCursor('end'); - if (editor.getSelection() === oldVal) { - editor.replaceSelection(newVal); - } else { - editor.setValue(editor.getValue().replace(oldVal, newVal)); - } - endPoint.ch -= oldVal.length; - endPoint.ch += newVal.length; - editor.setSelection(endPoint, endPoint); - editor.focus(); - triggerEditorContentChanged(editor.getTextArea()); - } -} - -const uploadClipboardImage = async (editor, dropzone, e) => { - const uploadUrl = dropzone.getAttribute('data-upload-url'); - const filesContainer = dropzone.querySelector('.files'); - - if (!uploadUrl || !filesContainer) return; - - const pastedImages = clipboardPastedImages(e); - if (!pastedImages || pastedImages.length === 0) { - return; - } - e.preventDefault(); - e.stopPropagation(); - - for (const img of pastedImages) { - const name = img.name.slice(0, img.name.lastIndexOf('.')); - - const placeholder = `![${name}](uploading ...)`; - editor.insertPlaceholder(placeholder); - - const {uuid} = await uploadFile(img, uploadUrl); - const {width, dppx} = await imageInfo(img); - - const url = `/attachments/${uuid}`; - let text; - if (width > 0 && dppx > 1) { - // Scale down images from HiDPI monitors. This uses the tag because it's the only - // method to change image size in Markdown that is supported by all implementations. - text = `${htmlEscape(name)}`; - } else { - text = `![${name}](${url})`; - } - editor.replacePlaceholder(placeholder, text); - - const input = document.createElement('input'); - input.setAttribute('name', 'files'); - input.setAttribute('type', 'hidden'); - input.setAttribute('id', uuid); - input.value = uuid; - filesContainer.append(input); - } -}; - -export function initEasyMDEImagePaste(easyMDE, dropzone) { - if (!dropzone) return; - easyMDE.codemirror.on('paste', async (_, e) => { - return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e); - }); -} - -export function initTextareaImagePaste(textarea, dropzone) { - if (!dropzone) return; - textarea.addEventListener('paste', async (e) => { - return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e); - }); -} diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js new file mode 100644 index 0000000000..b26296d1fc --- /dev/null +++ b/web_src/js/features/comp/Paste.js @@ -0,0 +1,154 @@ +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(); + formData.append('file', file, file.name); + + const res = await POST(uploadUrl, {data: formData}); + return await res.json(); +} + +function triggerEditorContentChanged(target) { + target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); +} + +class TextareaEditor { + constructor(editor) { + this.editor = editor; + } + + insertPlaceholder(value) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); + editor.selectionStart = startPos; + editor.selectionEnd = startPos + value.length; + editor.focus(); + triggerEditorContentChanged(editor); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + if (editor.value.substring(startPos, endPos) === oldVal) { + editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos); + editor.selectionEnd = startPos + newVal.length; + } else { + editor.value = editor.value.replace(oldVal, newVal); + editor.selectionEnd -= oldVal.length; + editor.selectionEnd += newVal.length; + } + editor.selectionStart = editor.selectionEnd; + editor.focus(); + triggerEditorContentChanged(editor); + } +} + +class CodeMirrorEditor { + constructor(editor) { + this.editor = editor; + } + + insertPlaceholder(value) { + const editor = this.editor; + const startPoint = editor.getCursor('start'); + const endPoint = editor.getCursor('end'); + editor.replaceSelection(value); + endPoint.ch = startPoint.ch + value.length; + editor.setSelection(startPoint, endPoint); + editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const endPoint = editor.getCursor('end'); + if (editor.getSelection() === oldVal) { + editor.replaceSelection(newVal); + } else { + editor.setValue(editor.getValue().replace(oldVal, newVal)); + } + endPoint.ch -= oldVal.length; + endPoint.ch += newVal.length; + editor.setSelection(endPoint, endPoint); + editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); + } +} + +async function handleClipboardImages(editor, dropzone, images, e) { + const uploadUrl = dropzone.getAttribute('data-upload-url'); + const filesContainer = dropzone.querySelector('.files'); + + if (!dropzone || !uploadUrl || !filesContainer || !images.length) return; + + e.preventDefault(); + e.stopPropagation(); + + for (const img of images) { + const name = img.name.slice(0, img.name.lastIndexOf('.')); + + const placeholder = `![${name}](uploading ...)`; + editor.insertPlaceholder(placeholder); + + const {uuid} = await uploadFile(img, uploadUrl); + const {width, dppx} = await imageInfo(img); + + const url = `/attachments/${uuid}`; + let text; + if (width > 0 && dppx > 1) { + // Scale down images from HiDPI monitors. This uses the tag because it's the only + // method to change image size in Markdown that is supported by all implementations. + text = `${htmlEscape(name)}`; + } else { + text = `![${name}](${url})`; + } + editor.replacePlaceholder(placeholder, text); + + const input = document.createElement('input'); + input.setAttribute('name', 'files'); + input.setAttribute('type', 'hidden'); + input.setAttribute('id', uuid); + input.value = uuid; + filesContainer.append(input); + } +} + +function handleClipboardText(textarea, text, e) { + // when pasting links over selected text, turn it into [text](link), except when shift key is held + const {value, selectionStart, selectionEnd, _shiftDown} = textarea; + if (_shiftDown) return; + const selectedText = value.substring(selectionStart, selectionEnd); + const trimmedText = text.trim(); + if (selectedText && isUrl(trimmedText)) { + e.stopPropagation(); + e.preventDefault(); + replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); + } +} + +export function initEasyMDEPaste(easyMDE, dropzone) { + easyMDE.codemirror.on('paste', (_, e) => { + const {images} = getPastedContent(e); + if (images.length) { + handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); + } + }); +} + +export function initTextareaPaste(textarea, dropzone) { + textarea.addEventListener('paste', (e) => { + const {images, text} = getPastedContent(e); + if (images.length) { + handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); + } else if (text) { + handleClipboardText(textarea, text, e); + } + }); +} diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 91535dc187..aa7c2604aa 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -243,3 +243,39 @@ export function isElemVisible(element) { return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); } + +// extract text and images from "paste" event +export function getPastedContent(e) { + 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 a40737ca6f..470ece31b0 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 3dbedec94f..08c6373ffb 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); +}); -- cgit v1.2.3