code.gitea.io/gitea@v1.22.3/web_src/js/features/comp/Paste.js (about) 1 import {htmlEscape} from 'escape-goat'; 2 import {POST} from '../../modules/fetch.js'; 3 import {imageInfo} from '../../utils/image.js'; 4 import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js'; 5 import {isUrl} from '../../utils/url.js'; 6 7 async function uploadFile(file, uploadUrl) { 8 const formData = new FormData(); 9 formData.append('file', file, file.name); 10 11 const res = await POST(uploadUrl, {data: formData}); 12 return await res.json(); 13 } 14 15 function triggerEditorContentChanged(target) { 16 target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); 17 } 18 19 class TextareaEditor { 20 constructor(editor) { 21 this.editor = editor; 22 } 23 24 insertPlaceholder(value) { 25 const editor = this.editor; 26 const startPos = editor.selectionStart; 27 const endPos = editor.selectionEnd; 28 editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); 29 editor.selectionStart = startPos; 30 editor.selectionEnd = startPos + value.length; 31 editor.focus(); 32 triggerEditorContentChanged(editor); 33 } 34 35 replacePlaceholder(oldVal, newVal) { 36 const editor = this.editor; 37 const startPos = editor.selectionStart; 38 const endPos = editor.selectionEnd; 39 if (editor.value.substring(startPos, endPos) === oldVal) { 40 editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos); 41 editor.selectionEnd = startPos + newVal.length; 42 } else { 43 editor.value = editor.value.replace(oldVal, newVal); 44 editor.selectionEnd -= oldVal.length; 45 editor.selectionEnd += newVal.length; 46 } 47 editor.selectionStart = editor.selectionEnd; 48 editor.focus(); 49 triggerEditorContentChanged(editor); 50 } 51 } 52 53 class CodeMirrorEditor { 54 constructor(editor) { 55 this.editor = editor; 56 } 57 58 insertPlaceholder(value) { 59 const editor = this.editor; 60 const startPoint = editor.getCursor('start'); 61 const endPoint = editor.getCursor('end'); 62 editor.replaceSelection(value); 63 endPoint.ch = startPoint.ch + value.length; 64 editor.setSelection(startPoint, endPoint); 65 editor.focus(); 66 triggerEditorContentChanged(editor.getTextArea()); 67 } 68 69 replacePlaceholder(oldVal, newVal) { 70 const editor = this.editor; 71 const endPoint = editor.getCursor('end'); 72 if (editor.getSelection() === oldVal) { 73 editor.replaceSelection(newVal); 74 } else { 75 editor.setValue(editor.getValue().replace(oldVal, newVal)); 76 } 77 endPoint.ch -= oldVal.length; 78 endPoint.ch += newVal.length; 79 editor.setSelection(endPoint, endPoint); 80 editor.focus(); 81 triggerEditorContentChanged(editor.getTextArea()); 82 } 83 } 84 85 async function handleClipboardImages(editor, dropzone, images, e) { 86 const uploadUrl = dropzone.getAttribute('data-upload-url'); 87 const filesContainer = dropzone.querySelector('.files'); 88 89 if (!dropzone || !uploadUrl || !filesContainer || !images.length) return; 90 91 e.preventDefault(); 92 e.stopPropagation(); 93 94 for (const img of images) { 95 const name = img.name.slice(0, img.name.lastIndexOf('.')); 96 97 const placeholder = ``; 98 editor.insertPlaceholder(placeholder); 99 100 const {uuid} = await uploadFile(img, uploadUrl); 101 const {width, dppx} = await imageInfo(img); 102 103 let text; 104 if (width > 0 && dppx > 1) { 105 // Scale down images from HiDPI monitors. This uses the <img> tag because it's the only 106 // method to change image size in Markdown that is supported by all implementations. 107 // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" 108 const url = `attachments/${uuid}`; 109 text = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(name)}" src="${htmlEscape(url)}">`; 110 } else { 111 // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" 112 // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" 113 const url = `/attachments/${uuid}`; 114 text = ``; 115 } 116 editor.replacePlaceholder(placeholder, text); 117 118 const input = document.createElement('input'); 119 input.setAttribute('name', 'files'); 120 input.setAttribute('type', 'hidden'); 121 input.setAttribute('id', uuid); 122 input.value = uuid; 123 filesContainer.append(input); 124 } 125 } 126 127 function handleClipboardText(textarea, text, e) { 128 // when pasting links over selected text, turn it into [text](link), except when shift key is held 129 const {value, selectionStart, selectionEnd, _shiftDown} = textarea; 130 if (_shiftDown) return; 131 const selectedText = value.substring(selectionStart, selectionEnd); 132 const trimmedText = text.trim(); 133 if (selectedText && isUrl(trimmedText)) { 134 e.stopPropagation(); 135 e.preventDefault(); 136 replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); 137 } 138 } 139 140 export function initEasyMDEPaste(easyMDE, dropzone) { 141 easyMDE.codemirror.on('paste', (_, e) => { 142 const {images} = getPastedContent(e); 143 if (images.length) { 144 handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); 145 } 146 }); 147 } 148 149 export function initTextareaPaste(textarea, dropzone) { 150 textarea.addEventListener('paste', (e) => { 151 const {images, text} = getPastedContent(e); 152 if (images.length) { 153 handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); 154 } else if (text) { 155 handleClipboardText(textarea, text, e); 156 } 157 }); 158 }