fix(editor): preserve zero-start ordered lists

This commit is contained in:
Neil
2026-09-18 02:04:20 -07:00
parent ba7e4dc85b
commit ce8f93cb56
3 changed files with 83 additions and 3 deletions
@@ -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
@@ -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')
})
})
@@ -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)
}
})