Skip to content

Commit d7f5a22

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

File tree

5 files changed

+103
-27
lines changed

5 files changed

+103
-27
lines changed

web_src/js/features/comp/ComboMarkdownEditor.js

Lines changed: 12 additions & 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';
@@ -84,6 +84,17 @@ class ComboMarkdownEditor {
8484
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
8585
}
8686

87+
this.textarea.addEventListener('keydown', (e) => {
88+
if (e.shiftKey) {
89+
e.target._giteaShiftDown = true;
90+
}
91+
});
92+
this.textarea.addEventListener('keyup', (e) => {
93+
if (!e.shiftKey) {
94+
e.target._giteaShiftDown = false;
95+
}
96+
});
97+
8798
const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
8899
const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
89100
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');

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

Lines changed: 32 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,38 @@ 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), except when shift key is held
125+
const {value, selectionStart, selectionEnd, _giteaShiftDown} = textarea;
126+
if (_giteaShiftDown) return;
127+
const selectedText = value.substring(selectionStart, selectionEnd);
128+
const trimmedText = text.trim();
129+
if (selectedText && isUrl(trimmedText)) {
130+
e.stopPropagation();
131+
e.preventDefault();
132+
replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
133+
}
134+
}
135135

136136
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);
137+
easyMDE.codemirror.on('paste', (_, e) => {
138+
const {images} = getPastedContent(e);
139+
if (images.length) {
140+
handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
141+
}
140142
});
141143
}
142144

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

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)