aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsilverwind <me@silverwind.io>2024-03-08 16:15:58 +0100
committerGitHub <noreply@github.com>2024-03-08 15:15:58 +0000
commita3cfe6f39ba33cea305de592a006727857014c53 (patch)
tree8109fd00f922609edd8e3707c0d27d3d74e44ed2
parentb253463e959c44cbd212fe1d662f2520ebfe38e6 (diff)
downloadgitea-a3cfe6f39ba33cea305de592a006727857014c53.tar.gz
gitea-a3cfe6f39ba33cea305de592a006727857014c53.zip
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)
-rw-r--r--web_src/js/features/comp/ComboMarkdownEditor.js17
-rw-r--r--web_src/js/features/comp/Paste.js (renamed from web_src/js/features/comp/ImagePaste.js)60
-rw-r--r--web_src/js/utils/dom.js36
-rw-r--r--web_src/js/utils/url.js12
-rw-r--r--web_src/js/utils/url.test.js9
5 files changed, 103 insertions, 31 deletions
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/Paste.js
index b727880bc8..b26296d1fc 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);
}
-};
+}
-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);
+ }
});
}
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);
+});