mirror of https://github.com/go-gitea/gitea.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
236 lines
8.8 KiB
236 lines
8.8 KiB
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) { |
|
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 (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); |
|
} |
|
}); |
|
}
|
|
|