From ce8f93cb567b0469d5d423b2b478f5ecb3226c8f Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 02:04:20 -0700 Subject: [PATCH] fix(editor): preserve zero-start ordered lists --- .../editor/rich-markdown-list-item.ts | 15 +++++++- .../editor/rich-markdown-ordered-list.test.ts | 35 ++++++++++++++++++ .../editor/rich-markdown-ordered-list.ts | 36 +++++++++++++++++-- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-ordered-list.test.ts diff --git a/src/renderer/src/components/editor/rich-markdown-list-item.ts b/src/renderer/src/components/editor/rich-markdown-list-item.ts index 9852bb5a822..7af460469c1 100644 --- a/src/renderer/src/components/editor/rich-markdown-list-item.ts +++ b/src/renderer/src/components/editor/rich-markdown-list-item.ts @@ -17,6 +17,17 @@ function markerWidth(context: RenderContext): number { return `${listStart(context) + (context.index ?? 0)}. `.length } +function renderZeroStartMarker(rendered: string, context: RenderContext): string { + if (context.parentType !== 'orderedList' || listStart(context) !== 0) { + return rendered + } + const index = context.index ?? 0 + const baseMarker = `${index + 1}. ` + return rendered.startsWith(baseMarker) + ? `${index}. ${rendered.slice(baseMarker.length)}` + : rendered +} + function paragraphLineCount(node: JSONContent): number { const first = Array.isArray(node.content) ? node.content[0] : undefined if (first?.type !== 'paragraph') { @@ -41,7 +52,9 @@ export const RichMarkdownListItem = ListItem.extend({ return baseRenderMarkdown(node, helpers, context) } const width = markerWidth(context) - const lines = baseRenderMarkdown(node, helpers, context).split('\n') + const lines = renderZeroStartMarker(baseRenderMarkdown(node, helpers, context), context).split( + '\n' + ) const paragraphLines = paragraphLineCount(node) const paragraph = indentParagraphContinuations(lines.slice(0, paragraphLines).join('\n'), width) const blocks = lines diff --git a/src/renderer/src/components/editor/rich-markdown-ordered-list.test.ts b/src/renderer/src/components/editor/rich-markdown-ordered-list.test.ts new file mode 100644 index 00000000000..1c4585beb11 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-ordered-list.test.ts @@ -0,0 +1,35 @@ +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +function roundTrip(source: string): string { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec), + contentType: 'markdown' + }) + try { + return editor.getMarkdown().trimEnd() + } finally { + editor.destroy() + } +} +describe('ordered list start values', () => { + it('preserves a zero-start ordered list', () => { + const source = '0. zero\n1. one' + expect(roundTrip(source)).toBe(source) + }) + it('keeps a zero-start list stable across repeated saves', () => { + let current = '0. zero\n1. one' + for (let cycle = 0; cycle < 3; cycle += 1) { + current = roundTrip(current) + } + expect(current).toBe('0. zero\n1. one') + }) + it('does not change ordinary ordered lists', () => { + expect(roundTrip('1. one\n2. two')).toBe('1. one\n2. two') + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-ordered-list.ts b/src/renderer/src/components/editor/rich-markdown-ordered-list.ts index 4ccf53eae77..7347b3b373e 100644 --- a/src/renderer/src/components/editor/rich-markdown-ordered-list.ts +++ b/src/renderer/src/components/editor/rich-markdown-ordered-list.ts @@ -1,9 +1,30 @@ -import type { MarkdownTokenizer } from '@tiptap/core' +import type { MarkdownParseResult, MarkdownToken, MarkdownTokenizer } from '@tiptap/core' import { OrderedList, ORDERED_LIST_MARKER_PATTERN } from '@tiptap/extension-list' const orderedListStart = new RegExp(`^\\s*(?:${ORDERED_LIST_MARKER_PATTERN})[.)]\\s`) +const zeroOrderedListStart = /^\s*0[.)]\s/ const baseTokenizer = OrderedList.config.markdownTokenizer as MarkdownTokenizer +const baseParseMarkdown = OrderedList.config.parseMarkdown + +function withZeroStart(parsed: MarkdownParseResult, token: MarkdownToken): MarkdownParseResult { + if ( + token.start !== 0 || + !parsed || + Array.isArray(parsed) || + !('type' in parsed) || + parsed.type !== 'orderedList' + ) { + return parsed + } + return { + ...parsed, + attrs: { + ...parsed.attrs, + start: 0 + } + } +} export const RichMarkdownOrderedList = OrderedList.extend({ markdownTokenizer: { @@ -13,7 +34,18 @@ export const RichMarkdownOrderedList = OrderedList.extend({ if (!orderedListStart.test(src)) { return undefined } - return baseTokenizer.tokenize(src, tokens, lexer) + const token = baseTokenizer.tokenize(src, tokens, lexer) + if (zeroOrderedListStart.test(src) && token && typeof token === 'object') { + token.start = 0 + } + return token } + }, + parseMarkdown: (token, helpers) => { + const parsed = baseParseMarkdown?.(token, helpers) + if (!parsed) { + return [] + } + return withZeroStart(parsed, token) } })