Add matching pair insertion to markdown textarea (#36121)

1. Our textarea already has some editor-like feature like tab
indentation, so I thought why not also add insertion of matching closing
quotes/brackets over selected text. This does that.
2. `textareaInsertText` is replaced with `replaceTextareaSelection`
which does the same but create a new edit history entry in the textarea
so CTRL-Z works. The button that inserts tables into the textarea can
now also be reverted via CTRL-Z, which was not possible before.
pull/36124/head^2
silverwind 2025-12-10 08:30:50 +07:00 committed by GitHub
parent d83a071db9
commit ed698d1a61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 45 additions and 12 deletions

@ -17,7 +17,7 @@ import {POST} from '../../modules/fetch.ts';
import { import {
EventEditorContentChanged, EventEditorContentChanged,
initTextareaMarkdown, initTextareaMarkdown,
textareaInsertText, replaceTextareaSelection,
triggerEditorContentChanged, triggerEditorContentChanged,
} from './EditorMarkdown.ts'; } from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts'; import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
@ -273,7 +273,7 @@ export class ComboMarkdownEditor {
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]')!.value); let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]')!.value);
rows = Math.max(1, Math.min(100, rows)); rows = Math.max(1, Math.min(100, rows));
cols = Math.max(1, Math.min(100, cols)); cols = Math.max(1, Math.min(100, cols));
textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`); replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
addTablePanelTippy.hide(); addTablePanelTippy.hide();
}); });
} }

@ -4,14 +4,23 @@ export function triggerEditorContentChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true})); target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
} }
export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) { /** replace selected text or insert text by creating a new edit history entry,
const startPos = textarea.selectionStart; * e.g. CTRL-Z works after this */
const endPos = textarea.selectionEnd; export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos); const before = textarea.value.slice(0, textarea.selectionStart);
textarea.selectionStart = startPos; const after = textarea.value.slice(textarea.selectionEnd);
textarea.selectionEnd = startPos + value.length;
textarea.focus(); textarea.focus();
triggerEditorContentChanged(textarea); let success = false;
try {
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
} catch {}
// fall back to regular replacement
if (!success) {
textarea.value = `${before}${text}${after}`;
triggerEditorContentChanged(textarea);
}
} }
type TextareaValueSelection = { type TextareaValueSelection = {
@ -176,7 +185,7 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}}; return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
} }
function handleNewline(textarea: HTMLTextAreaElement, e: Event) { function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd}); const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled) if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled)
e.preventDefault(); e.preventDefault();
@ -185,6 +194,28 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
triggerEditorContentChanged(textarea); triggerEditorContentChanged(textarea);
} }
// Keys that act as dead keys will not work because the spec dictates that such keys are
// emitted as `Dead` in e.key instead of the actual key.
const pairs = new Map<string, string>([
["'", "'"],
['"', '"'],
['`', '`'],
['(', ')'],
['[', ']'],
['{', '}'],
['<', '>'],
]);
function handlePairCharacter(textarea: HTMLTextAreaElement, e: KeyboardEvent): void {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
e.preventDefault();
const inner = textarea.value.substring(selStart, selEnd);
replaceTextareaSelection(textarea, `${e.key}${inner}${pairs.get(e.key)}`);
textarea.setSelectionRange(selStart + 1, selEnd + 1);
}
function isTextExpanderShown(textarea: HTMLElement): boolean { function isTextExpanderShown(textarea: HTMLElement): boolean {
return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions')); return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
} }
@ -198,6 +229,8 @@ export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { } else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Enter to insert a new line with the same indention and prefix // use Enter to insert a new line with the same indention and prefix
handleNewline(textarea, e); handleNewline(textarea, e);
} else if (pairs.has(e.key)) {
handlePairCharacter(textarea, e);
} }
}); });
} }

@ -1,5 +1,5 @@
import {imageInfo} from '../../utils/image.ts'; import {imageInfo} from '../../utils/image.ts';
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts'; import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts';
import { import {
DropzoneCustomEventRemovedFile, DropzoneCustomEventRemovedFile,
DropzoneCustomEventUploadDone, DropzoneCustomEventUploadDone,
@ -43,7 +43,7 @@ class TextareaEditor {
} }
insertPlaceholder(value: string) { insertPlaceholder(value: string) {
textareaInsertText(this.editor, value); replaceTextareaSelection(this.editor, value);
} }
replacePlaceholder(oldVal: string, newVal: string) { replacePlaceholder(oldVal: string, newVal: string) {