Skip to content

Commit 2644272

Browse files
committed
Support pasting URLs over markdown text
1 parent 9616dbe commit 2644272

File tree

5 files changed

+91
-27
lines changed

5 files changed

+91
-27
lines changed

web_src/js/features/comp/ComboMarkdownEditor.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import '@github/text-expander-element';
33
import $ from 'jquery';
44
import {attachTribute} from '../tribute.js';
55
import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
6-
import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
6+
import {initEasyMDEImagePaste, initTextareaImagePaste} from './Paste.js';
77
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
88
import {renderPreviewPanelContent} from '../repo-editor.js';
99
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';

web_src/js/features/comp/ImagePaste.js renamed to web_src/js/features/comp/Paste.js

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {htmlEscape} from 'escape-goat';
22
import {POST} from '../../modules/fetch.js';
33
import {imageInfo} from '../../utils/image.js';
4+
import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js';
5+
import {isUrl} from '../../utils/url.js';
46

57
async function uploadFile(file, uploadUrl) {
68
const formData = new FormData();
@@ -10,17 +12,6 @@ async function uploadFile(file, uploadUrl) {
1012
return await res.json();
1113
}
1214

13-
function clipboardPastedImages(e) {
14-
if (!e.clipboardData) return [];
15-
16-
const files = [];
17-
for (const item of e.clipboardData.items || []) {
18-
if (!item.type || !item.type.startsWith('image/')) continue;
19-
files.push(item.getAsFile());
20-
}
21-
return files;
22-
}
23-
2415
function triggerEditorContentChanged(target) {
2516
target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
2617
}
@@ -91,20 +82,16 @@ class CodeMirrorEditor {
9182
}
9283
}
9384

94-
const uploadClipboardImage = async (editor, dropzone, e) => {
85+
async function handleClipboardImages(editor, dropzone, images, e) {
9586
const uploadUrl = dropzone.getAttribute('data-upload-url');
9687
const filesContainer = dropzone.querySelector('.files');
9788

98-
if (!uploadUrl || !filesContainer) return;
89+
if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
9990

100-
const pastedImages = clipboardPastedImages(e);
101-
if (!pastedImages || pastedImages.length === 0) {
102-
return;
103-
}
10491
e.preventDefault();
10592
e.stopPropagation();
10693

107-
for (const img of pastedImages) {
94+
for (const img of images) {
10895
const name = img.name.slice(0, img.name.lastIndexOf('.'));
10996

11097
const placeholder = `![${name}](uploading ...)`;
@@ -131,18 +118,37 @@ const uploadClipboardImage = async (editor, dropzone, e) => {
131118
input.value = uuid;
132119
filesContainer.append(input);
133120
}
134-
};
121+
}
122+
123+
function handleClipboardText(textarea, text, e) {
124+
// when pasting links over selected text, turn it into [text](link)
125+
const {value, selectionStart, selectionEnd} = textarea;
126+
const selectedText = value.substring(selectionStart, selectionEnd);
127+
const trimmedText = text.trim();
128+
if (selectedText && isUrl(trimmedText)) {
129+
e.stopPropagation();
130+
e.preventDefault();
131+
replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
132+
}
133+
}
135134

136135
export function initEasyMDEImagePaste(easyMDE, dropzone) {
137-
if (!dropzone) return;
138-
easyMDE.codemirror.on('paste', async (_, e) => {
139-
return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e);
136+
easyMDE.codemirror.on('paste', (_, e) => {
137+
const {images} = getPastedContent(e);
138+
if (images.length) {
139+
handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
140+
}
140141
});
141142
}
142143

143144
export function initTextareaImagePaste(textarea, dropzone) {
144-
if (!dropzone) return;
145-
textarea.addEventListener('paste', async (e) => {
146-
return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e);
145+
textarea.addEventListener('paste', (e) => {
146+
const {images, text} = getPastedContent(e);
147+
if (text) {
148+
handleClipboardText(textarea, text, e);
149+
}
150+
if (images.length) {
151+
handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e);
152+
}
147153
});
148154
}

web_src/js/utils/dom.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,42 @@ export function isElemVisible(element) {
243243

244244
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
245245
}
246+
247+
// extract text and images from "paste" event
248+
export function getPastedContent(e) {
249+
if (!e.clipboardData) return {text: '', images: []};
250+
251+
const images = [];
252+
for (const item of e.clipboardData.items || []) {
253+
if (item.type?.startsWith('image/')) {
254+
images.push(item.getAsFile());
255+
}
256+
}
257+
258+
const text = e.clipboardData.getData('text');
259+
return {text, images};
260+
}
261+
262+
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
263+
export function replaceTextareaSelection(textarea, text) {
264+
const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
265+
const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
266+
let success = true;
267+
268+
textarea.contentEditable = 'true';
269+
try {
270+
success = document.execCommand('insertText', false, text);
271+
} catch {
272+
success = false;
273+
}
274+
textarea.contentEditable = 'false';
275+
276+
if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) {
277+
success = false;
278+
}
279+
280+
if (!success) {
281+
textarea.value = `${before}${text}${after}`;
282+
textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}));
283+
}
284+
}

web_src/js/utils/url.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
11
export function pathEscapeSegments(s) {
22
return s.split('/').map(encodeURIComponent).join('/');
33
}
4+
5+
function stripSlash(url) {
6+
return url.endsWith('/') ? url.slice(0, -1) : url;
7+
}
8+
9+
export function isUrl(url) {
10+
try {
11+
return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
12+
} catch {
13+
return false;
14+
}
15+
}

web_src/js/utils/url.test.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import {pathEscapeSegments} from './url.js';
1+
import {pathEscapeSegments, isUrl} from './url.js';
22

33
test('pathEscapeSegments', () => {
44
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
55
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
66
});
7+
8+
test('isUrl', () => {
9+
expect(isUrl('https://example.com')).toEqual(true);
10+
expect(isUrl('https://example.com/')).toEqual(true);
11+
expect(isUrl('https://example.com/index.html')).toEqual(true);
12+
expect(isUrl('/index.html')).toEqual(false);
13+
});

0 commit comments

Comments
 (0)