mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Fix editor markdown not incrementing in a numbered list (#33187)
Amended the logic for newPrefix in the MarkdownEditor to resolve incorrect number ordering. Fixes #33184 Attached screenshot of fixed input similar to issue <img width="175" alt="Screenshot 2025-01-09 at 23 59 24" src="https://github.com/user-attachments/assets/dfa23cf1-f3db-4b5e-99d2-a71bbcb289a8" /> --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -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', () => { | test('EditorMarkdown', () => { | ||||||
|   const textarea = document.createElement('textarea'); |   const textarea = document.createElement('textarea'); | ||||||
| @@ -32,10 +194,10 @@ test('EditorMarkdown', () => { | |||||||
|   testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0}); |   testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0}); | ||||||
|  |  | ||||||
|   testInput('- x', '- x\n- '); |   testInput('- x', '- x\n- '); | ||||||
|   testInput('1. foo', '1. foo\n1. '); |   testInput('1. foo', '1. foo\n2. '); | ||||||
|   testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n1. \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('- [ ]', '- [ ]\n- '); | ||||||
|   testInput('- [ ] foo', '- [ ] foo\n- [ ] '); |   testInput('- [ ] foo', '- [ ] foo\n- [ ] '); | ||||||
|   testInput('* [x] foo', '* [x] foo\n* [ ] '); |   testInput('* [x] foo', '* [x] foo\n* [ ] '); | ||||||
|   testInput('1. [x] foo', '1. [x] foo\n1. [ ] '); |   testInput('1. [x] foo', '1. [x] foo\n2. [ ] '); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -14,7 +14,13 @@ export function textareaInsertText(textarea, value) { | |||||||
|   triggerEditorContentChanged(textarea); |   triggerEditorContentChanged(textarea); | ||||||
| } | } | ||||||
|  |  | ||||||
| function handleIndentSelection(textarea, e) { | type TextareaValueSelection = { | ||||||
|  |   value: string; | ||||||
|  |   selStart: number; | ||||||
|  |   selEnd: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function handleIndentSelection(textarea: HTMLTextAreaElement, e) { | ||||||
|   const selStart = textarea.selectionStart; |   const selStart = textarea.selectionStart; | ||||||
|   const selEnd = textarea.selectionEnd; |   const selEnd = textarea.selectionEnd; | ||||||
|   if (selEnd === selStart) return; // do not process when no selection |   if (selEnd === selStart) return; // do not process when no selection | ||||||
| @@ -56,53 +62,125 @@ function handleIndentSelection(textarea, e) { | |||||||
|   triggerEditorContentChanged(textarea); |   triggerEditorContentChanged(textarea); | ||||||
| } | } | ||||||
|  |  | ||||||
| function handleNewline(textarea: HTMLTextAreaElement, e: Event) { | type MarkdownHandleIndentionResult = { | ||||||
|   const selStart = textarea.selectionStart; |   handled: boolean; | ||||||
|   const selEnd = textarea.selectionEnd; |   valueSelection?: TextareaValueSelection; | ||||||
|   if (selEnd !== selStart) return; // do not process when there is a selection | } | ||||||
|  |  | ||||||
|   const value = textarea.value; | type TextLinesBuffer = { | ||||||
|  |   lines: string[]; | ||||||
|  |   lengthBeforePosLine: number; | ||||||
|  |   posLineIndex: number; | ||||||
|  |   inlinePos: number | ||||||
|  | } | ||||||
|  |  | ||||||
|   // find the current line | export function textareaSplitLines(value: string, pos: number): TextLinesBuffer { | ||||||
|   // * if selStart is 0, lastIndexOf(..., -1) is the same as lastIndexOf(..., 0) |   const lines = value.split('\n'); | ||||||
|   // * if lastIndexOf reruns -1, lineStart is 0 and it is still correct. |   let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0; | ||||||
|   const lineStart = value.lastIndexOf('\n', selStart - 1) + 1; |   for (; posLineIndex < lines.length; posLineIndex++) { | ||||||
|   let lineEnd = value.indexOf('\n', selStart); |     const lineLength = lines[posLineIndex].length + 1; | ||||||
|   lineEnd = lineEnd < 0 ? value.length : lineEnd; |     if (lengthBeforePosLine + lineLength > pos) { | ||||||
|   let line = value.slice(lineStart, lineEnd); |       inlinePos = pos - lengthBeforePosLine; | ||||||
|   if (!line) return; // if the line is empty, do nothing, let the browser handle it |       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 |   // parse the indention | ||||||
|   const indention = /^\s*/.exec(line)[0]; |   let lineContent = line; | ||||||
|   line = line.slice(indention.length); |   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 |   // 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 |   // 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 = ''; |   let prefix = ''; | ||||||
|   if (prefixMatch) { |   if (prefixMatch) { | ||||||
|     prefix = prefixMatch[0]; |     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); |   lineContent = lineContent.slice(prefix.length); | ||||||
|   if (!indention && !prefix) return; // 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 | ||||||
|  |  | ||||||
|   e.preventDefault(); |   if (!lineContent) { | ||||||
|   if (!line) { |  | ||||||
|     // clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list |     // 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); |     linesBuf.lines[linesBuf.posLineIndex] = ''; | ||||||
|     textarea.setSelectionRange(selStart - prefix.length, selStart - prefix.length); |     linesBuf.inlinePos = 0; | ||||||
|   } else { |   } else { | ||||||
|     // start a new line with the same indention and prefix |     // start a new line with the same indention | ||||||
|     let newPrefix = 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)}`; |     if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; | ||||||
|     newPrefix = newPrefix.replace('[x]', '[ ]'); |     newPrefix = newPrefix.replace('[x]', '[ ]'); | ||||||
|     const newLine = `\n${indention}${newPrefix}`; |  | ||||||
|     textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd); |     const inlinePos = linesBuf.inlinePos; | ||||||
|     textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length); |     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); |   triggerEditorContentChanged(textarea); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Harry Vince
					Harry Vince