fix(editor): preserve ordered list continuation columns

This commit is contained in:
Neil
2026-09-18 01:42:46 -07:00
parent 150f834fe6
commit 45fcfef994
3 changed files with 102 additions and 0 deletions
@@ -38,6 +38,7 @@ import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-supe
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { RichMarkdownOrderedList } from './rich-markdown-ordered-list'
import { RichMarkdownCodeSpanPadding } from './rich-markdown-code-span-padding'
import { RichMarkdownListItem } from './rich-markdown-list-item'
import { RichMarkdownParagraph } from './rich-markdown-paragraph'
import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
@@ -74,6 +75,7 @@ export function createRichMarkdownExtensions({
link: false,
code: false,
codeBlock: false,
listItem: false,
orderedList: false,
paragraph: false
}),
@@ -215,6 +217,7 @@ export function createRichMarkdownExtensions({
inline: true
}),
RichMarkdownOrderedList,
RichMarkdownListItem,
RichMarkdownTaskList,
TaskItem.configure({
nested: true
@@ -0,0 +1,45 @@
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 continuation serialization', () => {
it.each([
['9. item\n continuation', '9. item\n continuation'],
['10. item\n continuation', '10. item\n continuation'],
['10. item\n continuation', '10. item\n continuation'],
['100. item\n continuation', '100. item\n continuation']
])('uses the marker width for %j', (source, expected) => {
expect(roundTrip(source)).toBe(expected)
})
it('keeps multi-digit continuation text intact across repeated saves', () => {
const source = '10. An item mentioning the 12-step\n write-up are in the folder.'
let current = source
for (let cycle = 0; cycle < 3; cycle += 1) {
current = roundTrip(current)
}
expect(current).toBe('10. An item mentioning the 12-step\n write-up are in the folder.')
})
it('does not change bullet continuation formatting', () => {
const source = '- item\n continuation'
expect(roundTrip(source)).toBe('- item\ncontinuation')
})
})
@@ -0,0 +1,54 @@
import type { JSONContent, RenderContext } from '@tiptap/core'
import { ListItem } from '@tiptap/extension-list'
const BASE_INDENT = 2
const baseRenderMarkdown = ListItem.config.renderMarkdown
function listStart(context: RenderContext): number {
const start = context.meta?.parentAttrs?.start
return typeof start === 'number' ? start : 1
}
function markerWidth(context: RenderContext): number {
if (context.parentType !== 'orderedList') {
return 2
}
return `${listStart(context) + (context.index ?? 0)}. `.length
}
function paragraphLineCount(node: JSONContent): number {
const first = Array.isArray(node.content) ? node.content[0] : undefined
if (first?.type !== 'paragraph') {
return 1
}
const text = (first.content ?? [])
.map((child) => (child.type === 'hardBreak' ? '\n' : (child.text ?? '')))
.join('')
return text.split('\n').length
}
function indentParagraphContinuations(markdown: string, column: number): string {
return markdown.replace(/\n(?!\n)[ \t]*/g, `\n${' '.repeat(column)}`)
}
export const RichMarkdownListItem = ListItem.extend({
renderMarkdown: (node, helpers, context) => {
if (typeof baseRenderMarkdown !== 'function') {
return ''
}
if (context.parentType !== 'orderedList') {
return baseRenderMarkdown(node, helpers, context)
}
const width = markerWidth(context)
const lines = baseRenderMarkdown(node, helpers, context).split('\n')
const paragraphLines = paragraphLineCount(node)
const paragraph = indentParagraphContinuations(lines.slice(0, paragraphLines).join('\n'), width)
const blocks = lines
.slice(paragraphLines)
.map((line) =>
line === '' ? line : `${' '.repeat(Math.max(0, width - BASE_INDENT))}${line}`
)
return [paragraph, ...blocks].join('\n')
}
})