Skip to content

Commit ed698d1

Browse files
authored
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.
1 parent d83a071 commit ed698d1

File tree

3 files changed

+45
-12
lines changed

3 files changed

+45
-12
lines changed

web_src/js/features/comp/ComboMarkdownEditor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {POST} from '../../modules/fetch.ts';
1717
import {
1818
EventEditorContentChanged,
1919
initTextareaMarkdown,
20-
textareaInsertText,
20+
replaceTextareaSelection,
2121
triggerEditorContentChanged,
2222
} from './EditorMarkdown.ts';
2323
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
@@ -273,7 +273,7 @@ export class ComboMarkdownEditor {
273273
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]')!.value);
274274
rows = Math.max(1, Math.min(100, rows));
275275
cols = Math.max(1, Math.min(100, cols));
276-
textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
276+
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
277277
addTablePanelTippy.hide();
278278
});
279279
}

web_src/js/features/comp/EditorMarkdown.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,23 @@ export function triggerEditorContentChanged(target: HTMLElement) {
44
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
55
}
66

7-
export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) {
8-
const startPos = textarea.selectionStart;
9-
const endPos = textarea.selectionEnd;
10-
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
11-
textarea.selectionStart = startPos;
12-
textarea.selectionEnd = startPos + value.length;
7+
/** replace selected text or insert text by creating a new edit history entry,
8+
* e.g. CTRL-Z works after this */
9+
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
10+
const before = textarea.value.slice(0, textarea.selectionStart);
11+
const after = textarea.value.slice(textarea.selectionEnd);
12+
1313
textarea.focus();
14-
triggerEditorContentChanged(textarea);
14+
let success = false;
15+
try {
16+
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
17+
} catch {}
18+
19+
// fall back to regular replacement
20+
if (!success) {
21+
textarea.value = `${before}${text}${after}`;
22+
triggerEditorContentChanged(textarea);
23+
}
1524
}
1625

1726
type TextareaValueSelection = {
@@ -176,7 +185,7 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa
176185
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
177186
}
178187

179-
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
188+
function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
180189
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
181190
if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled)
182191
e.preventDefault();
@@ -185,6 +194,28 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
185194
triggerEditorContentChanged(textarea);
186195
}
187196

197+
// Keys that act as dead keys will not work because the spec dictates that such keys are
198+
// emitted as `Dead` in e.key instead of the actual key.
199+
const pairs = new Map<string, string>([
200+
["'", "'"],
201+
['"', '"'],
202+
['`', '`'],
203+
['(', ')'],
204+
['[', ']'],
205+
['{', '}'],
206+
['<', '>'],
207+
]);
208+
209+
function handlePairCharacter(textarea: HTMLTextAreaElement, e: KeyboardEvent): void {
210+
const selStart = textarea.selectionStart;
211+
const selEnd = textarea.selectionEnd;
212+
if (selEnd === selStart) return; // do not process when no selection
213+
e.preventDefault();
214+
const inner = textarea.value.substring(selStart, selEnd);
215+
replaceTextareaSelection(textarea, `${e.key}${inner}${pairs.get(e.key)}`);
216+
textarea.setSelectionRange(selStart + 1, selEnd + 1);
217+
}
218+
188219
function isTextExpanderShown(textarea: HTMLElement): boolean {
189220
return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
190221
}
@@ -198,6 +229,8 @@ export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
198229
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
199230
// use Enter to insert a new line with the same indention and prefix
200231
handleNewline(textarea, e);
232+
} else if (pairs.has(e.key)) {
233+
handlePairCharacter(textarea, e);
201234
}
202235
});
203236
}

web_src/js/features/comp/EditorUpload.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {imageInfo} from '../../utils/image.ts';
2-
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
2+
import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts';
33
import {
44
DropzoneCustomEventRemovedFile,
55
DropzoneCustomEventUploadDone,
@@ -43,7 +43,7 @@ class TextareaEditor {
4343
}
4444

4545
insertPlaceholder(value: string) {
46-
textareaInsertText(this.editor, value);
46+
replaceTextareaSelection(this.editor, value);
4747
}
4848

4949
replacePlaceholder(oldVal: string, newVal: string) {

0 commit comments

Comments
 (0)