mirror of
https://github.com/go-gitea/gitea.git
synced 2025-09-18 15:38:56 +02:00
Enable more markdown paste features in textarea editor (#35494)
Enable the [same paste features](https://github.com/github/paste-markdown#paste-markdown-objects) that GitHub has, notably the ability to paste text containing HTML links and have them automatically turn into Markdown links. As far as I can tell, previous paste features all work as expected. --------- Signed-off-by: silverwind <me@silverwind.io>
This commit is contained in:
parent
9332ff291b
commit
6033c47f90
@ -11,6 +11,7 @@
|
|||||||
"@citation-js/plugin-csl": "0.7.18",
|
"@citation-js/plugin-csl": "0.7.18",
|
||||||
"@citation-js/plugin-software-formats": "0.6.1",
|
"@citation-js/plugin-software-formats": "0.6.1",
|
||||||
"@github/markdown-toolbar-element": "2.2.3",
|
"@github/markdown-toolbar-element": "2.2.3",
|
||||||
|
"@github/paste-markdown": "1.5.3",
|
||||||
"@github/relative-time-element": "4.4.8",
|
"@github/relative-time-element": "4.4.8",
|
||||||
"@github/text-expander-element": "2.9.2",
|
"@github/text-expander-element": "2.9.2",
|
||||||
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
||||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -41,6 +41,9 @@ importers:
|
|||||||
'@github/markdown-toolbar-element':
|
'@github/markdown-toolbar-element':
|
||||||
specifier: 2.2.3
|
specifier: 2.2.3
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
|
'@github/paste-markdown':
|
||||||
|
specifier: 1.5.3
|
||||||
|
version: 1.5.3
|
||||||
'@github/relative-time-element':
|
'@github/relative-time-element':
|
||||||
specifier: 4.4.8
|
specifier: 4.4.8
|
||||||
version: 4.4.8
|
version: 4.4.8
|
||||||
@ -718,6 +721,9 @@ packages:
|
|||||||
'@github/markdown-toolbar-element@2.2.3':
|
'@github/markdown-toolbar-element@2.2.3':
|
||||||
resolution: {integrity: sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==}
|
resolution: {integrity: sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==}
|
||||||
|
|
||||||
|
'@github/paste-markdown@1.5.3':
|
||||||
|
resolution: {integrity: sha512-PzZ1b3PaqBzYqbT4fwKEhiORf38h2OcGp2+JdXNNM7inZ7egaSmfmhyNkQILpqWfS0AYtRS3CDq6z03eZ8yOMQ==}
|
||||||
|
|
||||||
'@github/relative-time-element@4.4.8':
|
'@github/relative-time-element@4.4.8':
|
||||||
resolution: {integrity: sha512-FSLYm6F3TSQnqHE1EMQUVVgi2XjbCvsESwwXfugHFpBnhyF1uhJOtu0Psp/BB/qqazfdkk7f5fVcu7WuXl3t8Q==}
|
resolution: {integrity: sha512-FSLYm6F3TSQnqHE1EMQUVVgi2XjbCvsESwwXfugHFpBnhyF1uhJOtu0Psp/BB/qqazfdkk7f5fVcu7WuXl3t8Q==}
|
||||||
|
|
||||||
@ -5024,6 +5030,8 @@ snapshots:
|
|||||||
|
|
||||||
'@github/markdown-toolbar-element@2.2.3': {}
|
'@github/markdown-toolbar-element@2.2.3': {}
|
||||||
|
|
||||||
|
'@github/paste-markdown@1.5.3': {}
|
||||||
|
|
||||||
'@github/relative-time-element@4.4.8': {}
|
'@github/relative-time-element@4.4.8': {}
|
||||||
|
|
||||||
'@github/text-expander-element@2.9.2':
|
'@github/text-expander-element@2.9.2':
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
|
import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
|
||||||
|
|
||||||
test('removeAttachmentLinksFromMarkdown', () => {
|
test('removeAttachmentLinksFromMarkdown', () => {
|
||||||
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
|
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
|
||||||
@ -12,13 +12,3 @@ test('removeAttachmentLinksFromMarkdown', () => {
|
|||||||
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
|
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
|
||||||
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
|
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preparePasteAsMarkdownLink', () => {
|
|
||||||
expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull();
|
|
||||||
expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull();
|
|
||||||
expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull();
|
|
||||||
expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)');
|
|
||||||
expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)');
|
|
||||||
expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull();
|
|
||||||
expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull();
|
|
||||||
});
|
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import {imageInfo} from '../../utils/image.ts';
|
import {imageInfo} from '../../utils/image.ts';
|
||||||
import {replaceTextareaSelection} from '../../utils/dom.ts';
|
|
||||||
import {isUrl} from '../../utils/url.ts';
|
|
||||||
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
|
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
|
||||||
import {
|
import {
|
||||||
DropzoneCustomEventRemovedFile,
|
DropzoneCustomEventRemovedFile,
|
||||||
DropzoneCustomEventUploadDone,
|
DropzoneCustomEventUploadDone,
|
||||||
generateMarkdownLinkForAttachment,
|
generateMarkdownLinkForAttachment,
|
||||||
} from '../dropzone.ts';
|
} from '../dropzone.ts';
|
||||||
|
import {subscribe} from '@github/paste-markdown';
|
||||||
import type CodeMirror from 'codemirror';
|
import type CodeMirror from 'codemirror';
|
||||||
import type EasyMDE from 'easymde';
|
import type EasyMDE from 'easymde';
|
||||||
import type {DropzoneFile} from 'dropzone';
|
import type {DropzoneFile} from 'dropzone';
|
||||||
@ -118,46 +117,20 @@ export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null {
|
function getPastedImages(e: ClipboardEvent) {
|
||||||
const {value, selectionStart, selectionEnd} = textarea;
|
const images: Array<File> = [];
|
||||||
const selectedText = value.substring(selectionStart, selectionEnd);
|
|
||||||
const trimmedText = pastedText.trim();
|
|
||||||
const beforeSelection = value.substring(0, selectionStart);
|
|
||||||
const afterSelection = value.substring(selectionEnd);
|
|
||||||
const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')');
|
|
||||||
const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink;
|
|
||||||
return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) {
|
|
||||||
// pasting with "shift" means "paste as original content" in most applications
|
|
||||||
if (isShiftDown) return; // let the browser handle it
|
|
||||||
|
|
||||||
// when pasting links over selected text, turn it into [text](link)
|
|
||||||
const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText);
|
|
||||||
if (pastedAsMarkdown) {
|
|
||||||
e.preventDefault();
|
|
||||||
replaceTextareaSelection(textarea, pastedAsMarkdown);
|
|
||||||
}
|
|
||||||
// else, let the browser handle it
|
|
||||||
}
|
|
||||||
|
|
||||||
// extract text and images from "paste" event
|
|
||||||
function getPastedContent(e: ClipboardEvent) {
|
|
||||||
const images = [];
|
|
||||||
for (const item of e.clipboardData?.items ?? []) {
|
for (const item of e.clipboardData?.items ?? []) {
|
||||||
if (item.type?.startsWith('image/')) {
|
if (item.type?.startsWith('image/')) {
|
||||||
images.push(item.getAsFile());
|
images.push(item.getAsFile());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const text = e.clipboardData?.getData?.('text') ?? '';
|
return images;
|
||||||
return {text, images};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
|
export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
|
||||||
const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
|
const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
|
||||||
easyMDE.codemirror.on('paste', (_, e) => {
|
easyMDE.codemirror.on('paste', (_, e) => {
|
||||||
const {images} = getPastedContent(e);
|
const images = getPastedImages(e);
|
||||||
if (!images.length) return;
|
if (!images.length) return;
|
||||||
handleUploadFiles(editor, dropzoneEl, images, e);
|
handleUploadFiles(editor, dropzoneEl, images, e);
|
||||||
});
|
});
|
||||||
@ -173,19 +146,11 @@ export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
|
export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
|
||||||
let isShiftDown = false;
|
subscribe(textarea); // enable paste features
|
||||||
textarea.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
||||||
if (e.shiftKey) isShiftDown = true;
|
|
||||||
});
|
|
||||||
textarea.addEventListener('keyup', (e: KeyboardEvent) => {
|
|
||||||
if (!e.shiftKey) isShiftDown = false;
|
|
||||||
});
|
|
||||||
textarea.addEventListener('paste', (e: ClipboardEvent) => {
|
textarea.addEventListener('paste', (e: ClipboardEvent) => {
|
||||||
const {images, text} = getPastedContent(e);
|
const images = getPastedImages(e);
|
||||||
if (images.length && dropzoneEl) {
|
if (images.length && dropzoneEl) {
|
||||||
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
|
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
|
||||||
} else if (text) {
|
|
||||||
handleClipboardText(textarea, e, text, isShiftDown);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
textarea.addEventListener('drop', (e: DragEvent) => {
|
textarea.addEventListener('drop', (e: DragEvent) => {
|
||||||
|
@ -286,28 +286,6 @@ export function isElemVisible(el: HTMLElement): boolean {
|
|||||||
return !el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none';
|
return !el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this */
|
|
||||||
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
|
|
||||||
const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
|
|
||||||
const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
|
|
||||||
let success = false;
|
|
||||||
|
|
||||||
textarea.contentEditable = 'true';
|
|
||||||
try {
|
|
||||||
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
|
|
||||||
} catch {} // ignore the error if execCommand is not supported or failed
|
|
||||||
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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
|
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
|
||||||
htmlString = htmlString.trim();
|
htmlString = htmlString.trim();
|
||||||
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
|
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
|
||||||
|
@ -1,17 +1,10 @@
|
|||||||
import {pathEscapeSegments, isUrl, toOriginUrl} from './url.ts';
|
import {pathEscapeSegments, toOriginUrl} from './url.ts';
|
||||||
|
|
||||||
test('pathEscapeSegments', () => {
|
test('pathEscapeSegments', () => {
|
||||||
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
||||||
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
|
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toOriginUrl', () => {
|
test('toOriginUrl', () => {
|
||||||
const oldLocation = String(window.location);
|
const oldLocation = String(window.location);
|
||||||
for (const origin of ['https://example.com', 'https://example.com:3000']) {
|
for (const origin of ['https://example.com', 'https://example.com:3000']) {
|
||||||
|
@ -2,18 +2,6 @@ export function pathEscapeSegments(s: string): string {
|
|||||||
return s.split('/').map(encodeURIComponent).join('/');
|
return s.split('/').map(encodeURIComponent).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripSlash(url: string): string {
|
|
||||||
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isUrl(url: string): boolean {
|
|
||||||
try {
|
|
||||||
return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert an absolute or relative URL to an absolute URL with the current origin. It only
|
/** Convert an absolute or relative URL to an absolute URL with the current origin. It only
|
||||||
* processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */
|
* processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */
|
||||||
export function toOriginUrl(urlStr: string) {
|
export function toOriginUrl(urlStr: string) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user