mirror of
https://github.com/go-gitea/gitea.git
synced 2026-02-09 11:45:20 +01:00
Fixes #36532 Refined the Enter key trigger logic in the repository filter to prevent actions during IME composition. By checking the e.isComposing property, the filter now correctly distinguishes between "confirming an IME candidate" and "submitting the search." This prevents premature search triggers when users press Enter to select Chinese/Japanese characters. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
239 lines
8.8 KiB
TypeScript
239 lines
8.8 KiB
TypeScript
export const EventEditorContentChanged = 'ce-editor-content-changed';
|
|
|
|
export function triggerEditorContentChanged(target: HTMLElement) {
|
|
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
|
|
}
|
|
|
|
/** 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();
|
|
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 = {
|
|
value: string;
|
|
selStart: number;
|
|
selEnd: number;
|
|
};
|
|
|
|
function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
|
|
const selStart = textarea.selectionStart;
|
|
const selEnd = textarea.selectionEnd;
|
|
if (selEnd === selStart) return; // do not process when no selection
|
|
|
|
e.preventDefault();
|
|
const lines = textarea.value.split('\n');
|
|
const selectedLines = [];
|
|
|
|
let pos = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (pos > selEnd) break;
|
|
if (pos >= selStart) selectedLines.push(i);
|
|
pos += lines[i].length + 1;
|
|
}
|
|
|
|
for (const i of selectedLines) {
|
|
if (e.shiftKey) {
|
|
lines[i] = lines[i].replace(/^(\t| {1,2})/, '');
|
|
} else {
|
|
lines[i] = ` ${lines[i]}`;
|
|
}
|
|
}
|
|
|
|
// re-calculating the selection range
|
|
let newSelStart: number | null = null;
|
|
let newSelEnd: number | null = null;
|
|
pos = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (i === selectedLines[0]) {
|
|
newSelStart = pos;
|
|
}
|
|
if (i === selectedLines[selectedLines.length - 1]) {
|
|
newSelEnd = pos + lines[i].length;
|
|
break;
|
|
}
|
|
pos += lines[i].length + 1;
|
|
}
|
|
textarea.value = lines.join('\n');
|
|
textarea.setSelectionRange(newSelStart, newSelEnd);
|
|
triggerEditorContentChanged(textarea);
|
|
}
|
|
|
|
type MarkdownHandleIndentionResult = {
|
|
handled: boolean;
|
|
valueSelection?: TextareaValueSelection;
|
|
};
|
|
|
|
type TextLinesBuffer = {
|
|
lines: string[];
|
|
lengthBeforePosLine: number;
|
|
posLineIndex: number;
|
|
inlinePos: number
|
|
};
|
|
|
|
export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
|
|
const lines = value.split('\n');
|
|
let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
|
|
for (; posLineIndex < lines.length; posLineIndex++) {
|
|
const lineLength = lines[posLineIndex].length + 1;
|
|
if (lengthBeforePosLine + lineLength > pos) {
|
|
inlinePos = pos - lengthBeforePosLine;
|
|
break;
|
|
}
|
|
lengthBeforePosLine += lineLength;
|
|
}
|
|
return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
|
|
}
|
|
|
|
function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
|
|
const reDeeperIndention = new RegExp(`^${indention}\\s+`);
|
|
const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
|
|
let firstLineIdx: number;
|
|
for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
|
|
const line = linesBuf.lines[firstLineIdx];
|
|
if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
|
|
}
|
|
firstLineIdx++;
|
|
let num = 1;
|
|
for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
|
|
const oldLine = linesBuf.lines[i];
|
|
const sameLevel = reSameLevel.test(oldLine);
|
|
if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
|
|
if (sameLevel) {
|
|
const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
|
|
linesBuf.lines[i] = newLine;
|
|
num++;
|
|
if (linesBuf.posLineIndex === i) {
|
|
// need to correct the cursor inline position if the line length changes
|
|
linesBuf.inlinePos += newLine.length - oldLine.length;
|
|
linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
|
|
linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
|
|
}
|
|
}
|
|
}
|
|
recalculateLengthBeforeLine(linesBuf);
|
|
}
|
|
|
|
function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
|
|
linesBuf.lengthBeforePosLine = 0;
|
|
for (let i = 0; i < linesBuf.posLineIndex; i++) {
|
|
linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
|
|
}
|
|
}
|
|
|
|
export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
|
|
const unhandled: MarkdownHandleIndentionResult = {handled: false};
|
|
if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
|
|
|
|
const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
|
|
const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
|
|
if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
|
|
|
|
// parse the indention
|
|
let lineContent = line;
|
|
const indention = (/^\s*/.exec(lineContent) || [''])[0];
|
|
lineContent = lineContent.slice(indention.length);
|
|
if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
|
|
|
|
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
|
|
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
|
|
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
|
|
let prefix = '';
|
|
if (prefixMatch) {
|
|
prefix = prefixMatch[0];
|
|
if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
|
|
}
|
|
|
|
lineContent = lineContent.slice(prefix.length);
|
|
if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
|
|
|
|
if (!lineContent) {
|
|
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
|
|
linesBuf.lines[linesBuf.posLineIndex] = '';
|
|
linesBuf.inlinePos = 0;
|
|
} else {
|
|
// start a new line with the same indention
|
|
let newPrefix = prefix;
|
|
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
|
|
newPrefix = newPrefix.replace('[x]', '[ ]');
|
|
|
|
const inlinePos = linesBuf.inlinePos;
|
|
linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
|
|
const newLineLeft = `${indention}${newPrefix}`;
|
|
const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
|
|
linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
|
|
linesBuf.posLineIndex++;
|
|
linesBuf.inlinePos = newLineLeft.length;
|
|
recalculateLengthBeforeLine(linesBuf);
|
|
}
|
|
|
|
markdownReformatListNumbers(linesBuf, indention);
|
|
const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
|
|
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
|
|
}
|
|
|
|
function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
|
|
if (e.isComposing) return;
|
|
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();
|
|
textarea.value = ret.valueSelection.value;
|
|
textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
|
|
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 {
|
|
return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
|
|
}
|
|
|
|
export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
|
|
textarea.addEventListener('keydown', (e) => {
|
|
if (e.isComposing) return;
|
|
if (isTextExpanderShown(textarea)) return;
|
|
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
// use Tab/Shift-Tab to indent/unindent the selected lines
|
|
handleIndentSelection(textarea, e);
|
|
} 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);
|
|
}
|
|
});
|
|
}
|