diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index cd4cb96ec9e..d4b551d8355 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -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 diff --git a/src/renderer/src/components/editor/rich-markdown-list-item.test.ts b/src/renderer/src/components/editor/rich-markdown-list-item.test.ts new file mode 100644 index 00000000000..59b36794449 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-list-item.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-list-item.ts b/src/renderer/src/components/editor/rich-markdown-list-item.ts new file mode 100644 index 00000000000..9852bb5a822 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-list-item.ts @@ -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') + } +})