mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 03:02:14 +01:00 
			
		
		
		
	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 <me@silverwind.io>
This commit is contained in:
		
							parent
							
								
									9332ff291b
								
							
						
					
					
						commit
						6033c47f90
					
				| @ -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", | ||||
|  | ||||
							
								
								
									
										8
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @ -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': | ||||
|  | ||||
| @ -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 <img src="/attachments/foo"> b', 'foo')).toBe('a  b'); | ||||
|   expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> 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(); | ||||
| }); | ||||
|  | ||||
| @ -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<File> = []; | ||||
|   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) => { | ||||
|  | ||||
| @ -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<T extends HTMLElement>(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
 | ||||
|  | ||||
| @ -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']) { | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user