Skip to content

Commit

Permalink
Fix editor markdown not incrementing in a numbered list (#33187) (#33193
Browse files Browse the repository at this point in the history
)

Backport #33187 (no conflict)

Co-authored-by: Harry Vince <[email protected]>
  • Loading branch information
wxiaoguang and harryvince authored Jan 10, 2025
1 parent d703488 commit 1454e1b
Show file tree
Hide file tree
Showing 2 changed files with 273 additions and 33 deletions.
170 changes: 166 additions & 4 deletions web_src/js/features/comp/EditorMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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. [ ] ');
});
136 changes: 107 additions & 29 deletions web_src/js/features/comp/EditorMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down

0 comments on commit 1454e1b

Please sign in to comment.