Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support pasting URLs over markdown text #29566

Merged
merged 8 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions web_src/js/features/comp/ComboMarkdownEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -108,7 +119,7 @@ class ComboMarkdownEditor {
});

if (this.dropzone) {
initTextareaImagePaste(this.textarea, this.dropzone);
initTextareaPaste(this.textarea, this.dropzone);
}
}

Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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}));
}
Expand Down Expand Up @@ -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 ...)`;
Expand All @@ -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);
}
});
}
36 changes: 36 additions & 0 deletions web_src/js/utils/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
}
}
12 changes: 12 additions & 0 deletions web_src/js/utils/url.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 8 additions & 1 deletion web_src/js/utils/url.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading