From 68fa996072de972c395b312affd13e212b59f034 Mon Sep 17 00:00:00 2001 From: Harry Vince Date: Thu, 9 Jan 2025 23:53:31 +0000 Subject: [PATCH 1/6] fix: editor markdown not incrementing in a numbered list --- web_src/js/features/comp/EditorMarkdown.test.ts | 6 +++--- web_src/js/features/comp/EditorMarkdown.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts index 7b4b44e83cb56..af1fa48cd4709 100644 --- a/web_src/js/features/comp/EditorMarkdown.test.ts +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -32,10 +32,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. \n2. b\n3. 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..f9aa5dab1f201 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -96,8 +96,12 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) { } else { // start a new line with the same indention and prefix 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)}`; + const digitMatch = /^\d+/.exec(prefix); + if (digitMatch) { + const number = parseInt(digitMatch[0]); + const incremented = number + 1; + newPrefix = `${incremented}. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; + } newPrefix = newPrefix.replace('[x]', '[ ]'); const newLine = `\n${indention}${newPrefix}`; textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd); From a17c3e84481f21a0c0abf410080a090d49fa08e3 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 10 Jan 2025 12:53:49 +0800 Subject: [PATCH 2/6] make code easier to test --- .../js/features/comp/EditorMarkdown.test.ts | 7 ++- web_src/js/features/comp/EditorMarkdown.ts | 54 +++++++++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts index af1fa48cd4709..455cf4e2443a2 100644 --- a/web_src/js/features/comp/EditorMarkdown.test.ts +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -1,4 +1,4 @@ -import {initTextareaMarkdown} from './EditorMarkdown.ts'; +import {initTextareaMarkdown, markdownHandleListNumbers} from './EditorMarkdown.ts'; test('EditorMarkdown', () => { const textarea = document.createElement('textarea'); @@ -33,9 +33,12 @@ test('EditorMarkdown', () => { testInput('- x', '- x\n- '); testInput('1. foo', '1. foo\n2. '); - testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n2. b\n3. c', pos: 8}); + 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\n2. [ ] '); + + // TODO: test separately + markdownHandleListNumbers({value: '1.', selStart: 1, selEnd: 2}); }); diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index f9aa5dab1f201..d21bc65056137 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,18 +62,21 @@ 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 MarkdownHandleListNumbersResult = { + handled: boolean; + valueSelection?: TextareaValueSelection; +} - const value = textarea.value; +export function markdownHandleListNumbers(tvs: TextareaValueSelection): MarkdownHandleListNumbersResult { + const ret: MarkdownHandleListNumbersResult = {handled: false}; + if (tvs.selEnd !== tvs.selStart) return ret; // do not process when there is a selection + const value = tvs.value; // 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); + const lineStart = value.lastIndexOf('\n', tvs.selStart - 1) + 1; + let lineEnd = value.indexOf('\n', tvs.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 @@ -82,17 +91,20 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) { let prefix = ''; if (prefixMatch) { prefix = prefixMatch[0]; - if (lineStart + prefix.length > selStart) prefix = ''; // do not add new line if cursor is at prefix + if (lineStart + prefix.length > tvs.selStart) 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 + if (!indention && !prefix) return ret; // if no indention and no prefix, do nothing, let the browser handle it - e.preventDefault(); + ret.handled = true; if (!line) { // 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); + ret.valueSelection = { + value: value.slice(0, lineStart) + value.slice(lineEnd), + selStart: tvs.selStart - prefix.length, + selEnd: tvs.selStart - prefix.length, + }; } else { // start a new line with the same indention and prefix let newPrefix = prefix; @@ -104,9 +116,21 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) { } 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); + ret.valueSelection = { + value: value.slice(0, tvs.selStart) + newLine + value.slice(tvs.selEnd), + selStart: tvs.selStart + newLine.length, + selEnd: tvs.selStart + newLine.length, + }; } + return ret; +} + +function handleNewline(textarea: HTMLTextAreaElement, e: Event) { + const ret = markdownHandleListNumbers({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); } From c6da950ca7986a5419bee6c01728398beab0a84a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 10 Jan 2025 14:09:27 +0800 Subject: [PATCH 3/6] improve --- .../js/features/comp/EditorMarkdown.test.ts | 68 ++++++++++++++++- web_src/js/features/comp/EditorMarkdown.ts | 73 ++++++++++++++----- 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts index 455cf4e2443a2..b3ac1314bdb71 100644 --- a/web_src/js/features/comp/EditorMarkdown.test.ts +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -1,4 +1,67 @@ -import {initTextareaMarkdown, markdownHandleListNumbers} from './EditorMarkdown.ts'; +import {initTextareaMarkdown, markdownHandleListNumbers, 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('markdownHandleListNumbers', () => { + const testInput = (input: string, expected: string) => { + const inputPos = input.indexOf('|'); + input = input.replace('|', ''); + const ret = markdownHandleListNumbers({value: input, selStart: inputPos, selEnd: inputPos}); + + const expectedPos = expected.indexOf('|'); + expected = expected.replace('|', ''); + expect(ret).toEqual({handled: true, valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos}}); + }; + + testInput(` +1. a +2. | +`, ` +1. a +| +`); + + testInput(` +1. a +1. b| +`, ` +1. a +2. b +3. | +`); + + testInput(` +1. a +2. b| +3. c +`, ` +1. a +2. b +3. | +4. c +`); +}); test('EditorMarkdown', () => { const textarea = document.createElement('textarea'); @@ -38,7 +101,4 @@ test('EditorMarkdown', () => { testInput('- [ ] foo', '- [ ] foo\n- [ ] '); testInput('* [x] foo', '* [x] foo\n* [ ] '); testInput('1. [x] foo', '1. [x] foo\n2. [ ] '); - - // TODO: test separately - markdownHandleListNumbers({value: '1.', selStart: 1, selEnd: 2}); }); diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index d21bc65056137..1c7aeff3e87c6 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -67,6 +67,45 @@ type MarkdownHandleListNumbersResult = { 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 re = new RegExp(`^${indention}([0-9]+)\\.`); + let firstLineIdx = 0; + for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) { + if (!re.test(linesBuf.lines[firstLineIdx])) break; + } + firstLineIdx++; + for (let i = firstLineIdx; i < linesBuf.lines.length; i++) { + if (!re.test(linesBuf.lines[i])) break; + linesBuf.lines[i] = `${indention}${i - firstLineIdx + 1}.${linesBuf.lines[i].replace(re, '')}`; + } + linesBuf.lengthBeforePosLine = 0; + for (let i = 0; i < linesBuf.posLineIndex; i++) { + linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1; + } + linesBuf.posLineIndex = linesBuf.lines[linesBuf.posLineIndex].length; +} + export function markdownHandleListNumbers(tvs: TextareaValueSelection): MarkdownHandleListNumbersResult { const ret: MarkdownHandleListNumbersResult = {handled: false}; if (tvs.selEnd !== tvs.selStart) return ret; // do not process when there is a selection @@ -97,32 +136,26 @@ export function markdownHandleListNumbers(tvs: TextareaValueSelection): Markdown line = line.slice(prefix.length); if (!indention && !prefix) return ret; // if no indention and no prefix, do nothing, let the browser handle it - ret.handled = true; + const linesBuf = textareaSplitLines(value, tvs.selStart); if (!line) { // clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list - ret.valueSelection = { - value: value.slice(0, lineStart) + value.slice(lineEnd), - selStart: tvs.selStart - prefix.length, - selEnd: tvs.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; - const digitMatch = /^\d+/.exec(prefix); - if (digitMatch) { - const number = parseInt(digitMatch[0]); - const incremented = number + 1; - newPrefix = `${incremented}. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; - } + if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; newPrefix = newPrefix.replace('[x]', '[ ]'); - const newLine = `\n${indention}${newPrefix}`; - ret.valueSelection = { - value: value.slice(0, tvs.selStart) + newLine + value.slice(tvs.selEnd), - selStart: tvs.selStart + newLine.length, - selEnd: tvs.selStart + newLine.length, - }; + + const newLine = `${indention}${newPrefix}`; + linesBuf.lengthBeforePosLine += linesBuf.lines[linesBuf.posLineIndex].length; + linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine); + linesBuf.posLineIndex++; + linesBuf.inlinePos = newLine.length; } - return ret; + 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) { From b5197e8f88d20904f5d6b43b71f3adde8b3aee45 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 10 Jan 2025 14:45:28 +0800 Subject: [PATCH 4/6] fix more edge cases --- .../js/features/comp/EditorMarkdown.test.ts | 79 +++++++++++++++++-- web_src/js/features/comp/EditorMarkdown.ts | 42 +++++----- 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts index b3ac1314bdb71..ed3427e9243f3 100644 --- a/web_src/js/features/comp/EditorMarkdown.test.ts +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -24,14 +24,20 @@ test('textareaSplitLines', () => { }); test('markdownHandleListNumbers', () => { - const testInput = (input: string, expected: string) => { + const testInput = (input: string, expected?: string) => { const inputPos = input.indexOf('|'); input = input.replace('|', ''); const ret = markdownHandleListNumbers({value: input, selStart: inputPos, selEnd: inputPos}); - - const expectedPos = expected.indexOf('|'); - expected = expected.replace('|', ''); - expect(ret).toEqual({handled: true, valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos}}); + 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(` @@ -42,6 +48,10 @@ test('markdownHandleListNumbers', () => { | `); + testInput(` +|1. a +`, null); // let browser handle it + testInput(` 1. a 1. b| @@ -49,6 +59,36 @@ test('markdownHandleListNumbers', () => { 1. a 2. b 3. | +`); + + 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(` @@ -60,6 +100,35 @@ test('markdownHandleListNumbers', () => { 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. | +`); + + // 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 `); }); diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index 1c7aeff3e87c6..40f9d4e8ed5d0 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -89,15 +89,23 @@ export function textareaSplitLines(value: string, pos: number): TextLinesBuffer } function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) { - const re = new RegExp(`^${indention}([0-9]+)\\.`); - let firstLineIdx = 0; + const reDeeperIndention = new RegExp(`^${indention}\\s+`); + const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`); + let firstLineIdx: number; for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) { - if (!re.test(linesBuf.lines[firstLineIdx])) break; + 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++) { - if (!re.test(linesBuf.lines[i])) break; - linesBuf.lines[i] = `${indention}${i - firstLineIdx + 1}.${linesBuf.lines[i].replace(re, '')}`; + const line = linesBuf.lines[i]; + const sameLevel = reSameLevel.test(line); + if (!sameLevel && !reDeeperIndention.test(line)) break; + if (sameLevel) { + linesBuf.lines[i] = `${indention}${num}.${line.replace(reSameLevel, '')}`; + num++; + } } linesBuf.lengthBeforePosLine = 0; for (let i = 0; i < linesBuf.posLineIndex; i++) { @@ -107,22 +115,17 @@ function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: strin } export function markdownHandleListNumbers(tvs: TextareaValueSelection): MarkdownHandleListNumbersResult { - const ret: MarkdownHandleListNumbersResult = {handled: false}; - if (tvs.selEnd !== tvs.selStart) return ret; // do not process when there is a selection - - const value = tvs.value; - // 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', tvs.selStart - 1) + 1; - let lineEnd = value.indexOf('\n', tvs.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 unhandled: MarkdownHandleListNumbersResult = {handled: false}; + if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection + + const linesBuf = textareaSplitLines(tvs.value, tvs.selStart); + let 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); + 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 @@ -130,13 +133,12 @@ export function markdownHandleListNumbers(tvs: TextareaValueSelection): Markdown let prefix = ''; if (prefixMatch) { prefix = prefixMatch[0]; - if (lineStart + prefix.length > tvs.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 ret; // if no indention and no prefix, do nothing, let the browser handle it + if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it - const linesBuf = textareaSplitLines(value, tvs.selStart); if (!line) { // 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] = ''; From 4628e8d956bd56527c41b514d7b1e67b4ab43d3d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 10 Jan 2025 14:57:26 +0800 Subject: [PATCH 5/6] fix more edge cases --- .../js/features/comp/EditorMarkdown.test.ts | 17 ++++++--- web_src/js/features/comp/EditorMarkdown.ts | 37 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts index ed3427e9243f3..063f314f93266 100644 --- a/web_src/js/features/comp/EditorMarkdown.test.ts +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -1,4 +1,4 @@ -import {initTextareaMarkdown, markdownHandleListNumbers, textareaSplitLines} from './EditorMarkdown.ts'; +import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts'; test('textareaSplitLines', () => { let ret = textareaSplitLines('a\nbc\nd', 0); @@ -23,11 +23,11 @@ test('textareaSplitLines', () => { expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1}); }); -test('markdownHandleListNumbers', () => { +test('markdownHandleIndention', () => { const testInput = (input: string, expected?: string) => { const inputPos = input.indexOf('|'); input = input.replace('|', ''); - const ret = markdownHandleListNumbers({value: input, selStart: inputPos, selEnd: inputPos}); + const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos}); if (expected === null) { expect(ret).toEqual({handled: false}); } else { @@ -40,6 +40,13 @@ test('markdownHandleListNumbers', () => { } }; + testInput(` + a|b +`, ` + a + |b +`); + testInput(` 1. a 2. | @@ -54,11 +61,11 @@ test('markdownHandleListNumbers', () => { testInput(` 1. a -1. b| +1. b|c `, ` 1. a 2. b -3. | +3. |c `); testInput(` diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index 40f9d4e8ed5d0..ec73d3ad9109a 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -62,7 +62,7 @@ function handleIndentSelection(textarea: HTMLTextAreaElement, e) { triggerEditorContentChanged(textarea); } -type MarkdownHandleListNumbersResult = { +type MarkdownHandleIndentionResult = { handled: boolean; valueSelection?: TextareaValueSelection; } @@ -107,39 +107,44 @@ function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: strin num++; } } + recalculateLengthBeforeLine(linesBuf); + linesBuf.posLineIndex = linesBuf.lines[linesBuf.posLineIndex].length; +} + +function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) { linesBuf.lengthBeforePosLine = 0; for (let i = 0; i < linesBuf.posLineIndex; i++) { linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1; } - linesBuf.posLineIndex = linesBuf.lines[linesBuf.posLineIndex].length; } -export function markdownHandleListNumbers(tvs: TextareaValueSelection): MarkdownHandleListNumbersResult { - const unhandled: MarkdownHandleListNumbersResult = {handled: false}; +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); - let line = linesBuf.lines[linesBuf.posLineIndex] ?? ''; + 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 (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix } - line = line.slice(prefix.length); + lineContent = lineContent.slice(prefix.length); if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it - 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 linesBuf.lines[linesBuf.posLineIndex] = ''; linesBuf.inlinePos = 0; @@ -149,19 +154,23 @@ export function markdownHandleListNumbers(tvs: TextareaValueSelection): Markdown if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; newPrefix = newPrefix.replace('[x]', '[ ]'); - const newLine = `${indention}${newPrefix}`; - linesBuf.lengthBeforePosLine += linesBuf.lines[linesBuf.posLineIndex].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 = newLine.length; + 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 = markdownHandleListNumbers({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd}); + const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd}); if (!ret.handled) return; e.preventDefault(); textarea.value = ret.valueSelection.value; From a5261422a6c66b7ffcfc98bad7a9eeab414ab36b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 10 Jan 2025 15:09:30 +0800 Subject: [PATCH 6/6] fix more edge cases --- .../js/features/comp/EditorMarkdown.test.ts | 23 +++++++++++++++++++ web_src/js/features/comp/EditorMarkdown.ts | 16 +++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts index 063f314f93266..9f34d77348228 100644 --- a/web_src/js/features/comp/EditorMarkdown.test.ts +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -124,6 +124,29 @@ test('markdownHandleIndention', () => { 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 diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index ec73d3ad9109a..d3ed492396fef 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -99,16 +99,22 @@ function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: strin firstLineIdx++; let num = 1; for (let i = firstLineIdx; i < linesBuf.lines.length; i++) { - const line = linesBuf.lines[i]; - const sameLevel = reSameLevel.test(line); - if (!sameLevel && !reDeeperIndention.test(line)) break; + const oldLine = linesBuf.lines[i]; + const sameLevel = reSameLevel.test(oldLine); + if (!sameLevel && !reDeeperIndention.test(oldLine)) break; if (sameLevel) { - linesBuf.lines[i] = `${indention}${num}.${line.replace(reSameLevel, '')}`; + 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); - linesBuf.posLineIndex = linesBuf.lines[linesBuf.posLineIndex].length; } function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {