diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 9ceb087005..86b1a037a0 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -17,7 +17,7 @@ import {POST} from '../../modules/fetch.ts'; import { EventEditorContentChanged, initTextareaMarkdown, - textareaInsertText, + replaceTextareaSelection, triggerEditorContentChanged, } from './EditorMarkdown.ts'; import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts'; @@ -273,7 +273,7 @@ export class ComboMarkdownEditor { let cols = parseInt(addTablePanel.querySelector('[name=cols]')!.value); rows = Math.max(1, Math.min(100, rows)); 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(); }); } diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index 2240e2f41b..da7bbcfef7 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -4,14 +4,23 @@ export function triggerEditorContentChanged(target: HTMLElement) { target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true})); } -export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) { - const startPos = textarea.selectionStart; - const endPos = textarea.selectionEnd; - textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos); - textarea.selectionStart = startPos; - textarea.selectionEnd = startPos + value.length; +/** replace selected text or insert text by creating a new edit history entry, + * e.g. CTRL-Z works after this */ +export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) { + const before = textarea.value.slice(0, textarea.selectionStart); + const after = textarea.value.slice(textarea.selectionEnd); + 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 = { @@ -176,7 +185,7 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa 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}); if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled) e.preventDefault(); @@ -185,6 +194,28 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) { 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([ + ["'", "'"], + ['"', '"'], + ['`', '`'], + ['(', ')'], + ['[', ']'], + ['{', '}'], + ['<', '>'], +]); + +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 { 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) { // use Enter to insert a new line with the same indention and prefix handleNewline(textarea, e); + } else if (pairs.has(e.key)) { + handlePairCharacter(textarea, e); } }); } diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index 92593e7092..6aff4242ba 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -1,5 +1,5 @@ import {imageInfo} from '../../utils/image.ts'; -import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts'; +import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts'; import { DropzoneCustomEventRemovedFile, DropzoneCustomEventUploadDone, @@ -43,7 +43,7 @@ class TextareaEditor { } insertPlaceholder(value: string) { - textareaInsertText(this.editor, value); + replaceTextareaSelection(this.editor, value); } replacePlaceholder(oldVal: string, newVal: string) {