mirror of
https://github.com/go-gitea/gitea.git
synced 2025-12-13 18:32:54 +00:00
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.
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user