From 1454e1b6eb38bf263334cada4267e54f2beee39b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 10 Jan 2025 16:00:22 +0800 Subject: [PATCH] Fix editor markdown not incrementing in a numbered list (#33187) (#33193) Backport #33187 (no conflict) Co-authored-by: Harry Vince <47283812+harryvince@users.noreply.github.com> --- .../js/features/comp/EditorMarkdown.test.ts | 170 +++++++++++++++++- web_src/js/features/comp/EditorMarkdown.ts | 136 +++++++++++--- 2 files changed, 273 insertions(+), 33 deletions(-) diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts index 7b4b44e83cb56..9f34d77348228 100644 --- a/web_src/js/features/comp/EditorMarkdown.test.ts +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -1,4 +1,166 @@ -import {initTextareaMarkdown} from './EditorMarkdown.ts'; +import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts'; + +test('textareaSplitLines', () => { + let ret = textareaSplitLines('a\nbc\nd', 0); + expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0}); + + ret = textareaSplitLines('a\nbc\nd', 1); + expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1}); + + ret = textareaSplitLines('a\nbc\nd', 2); + expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0}); + + ret = textareaSplitLines('a\nbc\nd', 3); + expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1}); + + ret = textareaSplitLines('a\nbc\nd', 4); + expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2}); + + ret = textareaSplitLines('a\nbc\nd', 5); + expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0}); + + ret = textareaSplitLines('a\nbc\nd', 6); + expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1}); +}); + +test('markdownHandleIndention', () => { + const testInput = (input: string, expected?: string) => { + const inputPos = input.indexOf('|'); + input = input.replace('|', ''); + const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos}); + if (expected === null) { + expect(ret).toEqual({handled: false}); + } else { + const expectedPos = expected.indexOf('|'); + expected = expected.replace('|', ''); + expect(ret).toEqual({ + handled: true, + valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos}, + }); + } + }; + + testInput(` + a|b +`, ` + a + |b +`); + + testInput(` +1. a +2. | +`, ` +1. a +| +`); + + testInput(` +|1. a +`, null); // let browser handle it + + testInput(` +1. a +1. b|c +`, ` +1. a +2. b +3. |c +`); + + testInput(` +2. a +2. b| + +1. x +1. y +`, ` +1. a +2. b +3. | + +1. x +1. y +`); + + testInput(` +2. a +2. b + +1. x| +1. y +`, ` +2. a +2. b + +1. x +2. | +3. y +`); + + testInput(` +1. a +2. b| +3. c +`, ` +1. a +2. b +3. | +4. c +`); + + testInput(` +1. a + 1. b + 2. b + 3. b + 4. b +1. c| +`, ` +1. a + 1. b + 2. b + 3. b + 4. b +2. c +3. | +`); + + testInput(` +1. a +2. a +3. a +4. a +5. a +6. a +7. a +8. a +9. b|c +`, ` +1. a +2. a +3. a +4. a +5. a +6. a +7. a +8. a +9. b +10. |c +`); + + // this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future + testInput(` +1. a + 2. b| +3. c +`, ` +1. a + 1. b + 2. | +3. c +`); +}); test('EditorMarkdown', () => { const textarea = document.createElement('textarea'); @@ -32,10 +194,10 @@ test('EditorMarkdown', () => { testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0}); testInput('- x', '- x\n- '); - testInput('1. foo', '1. foo\n1. '); - testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n1. \n2. b\n3. c', pos: 8}); + testInput('1. foo', '1. foo\n2. '); + testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8}); testInput('- [ ]', '- [ ]\n- '); testInput('- [ ] foo', '- [ ] foo\n- [ ] '); testInput('* [x] foo', '* [x] foo\n* [ ] '); - testInput('1. [x] foo', '1. [x] foo\n1. [ ] '); + testInput('1. [x] foo', '1. [x] foo\n2. [ ] '); }); diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index 5e2ef121f5297..d3ed492396fef 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -14,7 +14,13 @@ export function textareaInsertText(textarea, value) { triggerEditorContentChanged(textarea); } -function handleIndentSelection(textarea, e) { +type TextareaValueSelection = { + value: string; + selStart: number; + selEnd: number; +} + +function handleIndentSelection(textarea: HTMLTextAreaElement, e) { const selStart = textarea.selectionStart; const selEnd = textarea.selectionEnd; if (selEnd === selStart) return; // do not process when no selection @@ -56,53 +62,125 @@ function handleIndentSelection(textarea, e) { triggerEditorContentChanged(textarea); } -function handleNewline(textarea: HTMLTextAreaElement, e: Event) { - const selStart = textarea.selectionStart; - const selEnd = textarea.selectionEnd; - if (selEnd !== selStart) return; // do not process when there is a selection +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; + } +} - const value = textarea.value; +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 - // find the current line - // * if selStart is 0, lastIndexOf(..., -1) is the same as lastIndexOf(..., 0) - // * if lastIndexOf reruns -1, lineStart is 0 and it is still correct. - const lineStart = value.lastIndexOf('\n', selStart - 1) + 1; - let lineEnd = value.indexOf('\n', selStart); - lineEnd = lineEnd < 0 ? value.length : lineEnd; - let line = value.slice(lineStart, lineEnd); - if (!line) return; // if the line is empty, do nothing, let the browser handle it + 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 - const indention = /^\s*/.exec(line)[0]; - line = line.slice(indention.length); + 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(line); + const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent); let prefix = ''; if (prefixMatch) { prefix = prefixMatch[0]; - if (lineStart + prefix.length > selStart) prefix = ''; // do not add new line if cursor is at prefix + if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix } - line = line.slice(prefix.length); - if (!indention && !prefix) return; // if no indention and no prefix, do nothing, let the browser handle it + lineContent = lineContent.slice(prefix.length); + if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it - e.preventDefault(); - if (!line) { + if (!lineContent) { // clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list - textarea.value = value.slice(0, lineStart) + value.slice(lineEnd); - textarea.setSelectionRange(selStart - prefix.length, selStart - prefix.length); + linesBuf.lines[linesBuf.posLineIndex] = ''; + linesBuf.inlinePos = 0; } else { - // start a new line with the same indention and prefix + // start a new line with the same indention let newPrefix = prefix; - // a simple approach, otherwise it needs to parse the lines after the current line if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; newPrefix = newPrefix.replace('[x]', '[ ]'); - const newLine = `\n${indention}${newPrefix}`; - textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd); - textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length); + + 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: Event) { + const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd}); + if (!ret.handled) return; + e.preventDefault(); + textarea.value = ret.valueSelection.value; + textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd); triggerEditorContentChanged(textarea); }