mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	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. 
This commit is contained in:
		| @@ -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); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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 = ``; | ||||
| @@ -131,18 +118,37 @@ const uploadClipboardImage = async (editor, dropzone, e) => { | ||||
|     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); | ||||
| 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 initTextareaImagePaste(textarea, dropzone) { | ||||
|   if (!dropzone) return; | ||||
|   textarea.addEventListener('paste', async (e) => { | ||||
|     return uploadClipboardImage(new TextareaEditor(textarea), dropzone, 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); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
| @@ -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})); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 silverwind
					silverwind