diff --git a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts index 786274af072..2576bb31b21 100644 --- a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts +++ b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts @@ -1,9 +1,10 @@ -import { RichMarkdownExtension } from './rich-markdown-extension' +import { createRichMarkdownExtension } from './rich-markdown-extension' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' export function createIsolatedMarkdownExtensionForTests() { - return RichMarkdownExtension.configure({ - marked: createRichMarkdownEditorCodec().marked, + const codec = createRichMarkdownEditorCodec() + return createRichMarkdownExtension(codec).configure({ + marked: codec.marked, markedOptions: { gfm: true } }) } diff --git a/src/renderer/src/components/editor/rich-markdown-extension.test.ts b/src/renderer/src/components/editor/rich-markdown-extension.test.ts index e1ebc9b4c99..d7a4a3646fb 100644 --- a/src/renderer/src/components/editor/rich-markdown-extension.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-extension.test.ts @@ -24,32 +24,38 @@ describe('Markdown source compatibility without a DOM', () => { } ) - it.each(['**literal**', '[literal](./example.md)'])( - 'keeps typed formatting syntax %j literal after saving and reopening', - (text) => { - const editor = new Editor({ + it.each([ + '**literal**', + '[literal](./example.md)', + '![image](./image.png)', + '[literal][ref]', + '`[literal]`', + '[literal] _emphasis_', + String.raw`\[literal\]`, + ' [literal]' + ])('keeps typed formatting syntax %j literal after saving and reopening', (text) => { + const editor = new Editor({ + element: null, + extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()], + content: '', + contentType: 'markdown' + }) + try { + editor.commands.insertContentAt(1, { type: 'text', text }) + const reopened = new Editor({ element: null, extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()], - content: '', + content: editor.getMarkdown(), contentType: 'markdown' }) try { - editor.commands.insertContentAt(1, { type: 'text', text }) - const reopened = new Editor({ - element: null, - extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()], - content: editor.getMarkdown(), - contentType: 'markdown' - }) - try { - expect(reopened.getText()).toBe(text) - expect(reopened.getJSON()).toEqual(editor.getJSON()) - } finally { - reopened.destroy() - } + expect(reopened.getText()).toBe(text) + expect(reopened.getJSON()).toEqual(editor.getJSON()) } finally { - editor.destroy() + reopened.destroy() } + } finally { + editor.destroy() } - ) + }) }) diff --git a/src/renderer/src/components/editor/rich-markdown-extension.ts b/src/renderer/src/components/editor/rich-markdown-extension.ts index 2eb8940f560..c36c692b4b9 100644 --- a/src/renderer/src/components/editor/rich-markdown-extension.ts +++ b/src/renderer/src/components/editor/rich-markdown-extension.ts @@ -1,4 +1,6 @@ import { Markdown } from '@tiptap/markdown' +import { preserveLiteralMarkdownSource } from './rich-markdown-literal-serialization' +import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' export const RichMarkdownExtension = Markdown.extend({ onBeforeCreate(event) { @@ -12,3 +14,15 @@ export const RichMarkdownExtension = Markdown.extend({ } } }) + +export function createRichMarkdownExtension( + codec: RichMarkdownEditorCodec, + htmlSuperscriptLinks = false +) { + return RichMarkdownExtension.extend({ + onBeforeCreate(event) { + this.parent?.(event) + preserveLiteralMarkdownSource(this.editor, codec, htmlSuperscriptLinks) + } + }) +} diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 2dca4f05b57..9a4f292f042 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -10,7 +10,7 @@ import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' -import { RichMarkdownExtension } from './rich-markdown-extension' +import { createRichMarkdownExtension } from './rich-markdown-extension' import { createLowlight, common } from 'lowlight' import { acquireLocalImageSrcLease, @@ -243,7 +243,7 @@ export function createRichMarkdownExtensions({ createRawMarkdownHtmlBlock(codec.transport), createMarkdownDocLink(codec.transport), DragSelectionGuard, - RichMarkdownExtension.configure({ + createRichMarkdownExtension(codec, htmlSuperscriptLinks).configure({ marked: codec.marked, markedOptions: { gfm: true diff --git a/src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts b/src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts new file mode 100644 index 00000000000..0589a6abe27 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts @@ -0,0 +1,101 @@ +import { Editor, type JSONContent } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { describe, expect, it, vi } from 'vitest' +import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests' + +function createEditor(content: JSONContent | string) { + return new Editor({ + element: null, + extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()], + content, + ...(typeof content === 'string' ? { contentType: 'markdown' as const } : {}) + }) +} + +function paragraph(text: string): JSONContent { + return { type: 'paragraph', content: [{ type: 'text', text }] } +} + +function expectReopens(editor: Editor) { + const reopened = createEditor(editor.getMarkdown()) + try { + expect(reopened.getJSON()).toEqual(editor.getJSON()) + } finally { + reopened.destroy() + } +} + +describe('literal Markdown serialization', () => { + it.each(['[ref]: ./target.md', '[ref]: <./target.md> "Title"'])( + 'preserves literal references across blocks with definition %j', + (definition) => { + const editor = createEditor({ + type: 'doc', + content: [paragraph('[literal][ref] and [ref]'), paragraph(definition)] + }) + try { + expectReopens(editor) + } finally { + editor.destroy() + } + } + ) + + it('reuses unchanged blocks but revalidates edited syntax', () => { + const editor = createEditor({ + type: 'doc', + content: [paragraph('[literal]'), paragraph('[[]]')] + }) + const parse = vi.spyOn(editor.markdown!, 'parse') + try { + expect(editor.getMarkdown()).toBe('[literal]\n\n[[]]') + expect(parse).toHaveBeenCalledTimes(2) + editor.getMarkdown() + expect(parse).toHaveBeenCalledTimes(2) + editor.commands.insertContentAt(10, { type: 'text', text: '(./target.md)' }) + const saved = editor.getMarkdown() + expect(parse).toHaveBeenCalledTimes(3) + expect(saved).toContain('\\[literal\\]') + expectReopens(editor) + } finally { + parse.mockRestore() + editor.destroy() + } + }) + + it('keeps upstream escaping when the parser cannot validate a block', () => { + const editor = createEditor('[[]]') + const upstream = editor.markdown!.serialize(editor.getJSON()) + const parse = vi.spyOn(editor.markdown!, 'parse').mockImplementation(() => { + throw new Error('custom parser failure') + }) + try { + expect(editor.getMarkdown()).toBe(upstream) + } finally { + parse.mockRestore() + editor.destroy() + } + }) + + it('preserves headings, marked text, and nested blocks', () => { + const editor = createEditor({ + type: 'doc', + content: [ + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '[[]]' }] }, + { + type: 'paragraph', + content: [{ type: 'text', text: '[literal]', marks: [{ type: 'bold' }] }] + }, + { + type: 'bulletList', + content: [{ type: 'listItem', content: [paragraph('[literal](./a)')] }] + } + ] + }) + try { + expectReopens(editor) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts new file mode 100644 index 00000000000..d294bec3e06 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts @@ -0,0 +1,72 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' + +function withoutOptionalEscapes(markdown: string): string { + return markdown.replace(/\\([\\_[\]])/g, (escaped, character) => + character === '\\' ? escaped : character + ) +} + +export function preserveLiteralMarkdownSource( + editor: Editor, + codec: RichMarkdownEditorCodec, + htmlSuperscriptLinks: boolean +): void { + const manager = editor.markdown! + const render = manager.renderNodeToMarkdown.bind(manager) + const serialize = editor.getMarkdown.bind(editor) + const cache = new WeakMap() + let blocks: Map | undefined + + manager.renderNodeToMarkdown = (node, ...args) => { + const markdown = render(node, ...args) + const block = blocks?.get(node) + if (!block || !/\\[[\]]/.test(markdown)) { + return markdown + } + const cached = cache.get(block) + if (cached?.markdown === markdown) { + return cached.result + } + const candidate = withoutOptionalEscapes(markdown) + let result = markdown + try { + const parsed = manager.parse( + encodeRawMarkdownHtmlForRichEditor(candidate, codec, { htmlSuperscriptLinks }) + ) + // Every mark, attribute and text position must survive reopening this block. + if (parsed.content?.length === 1 && editor.schema.nodeFromJSON(parsed.content[0]).eq(block)) { + result = candidate + } + } catch { + // Keep the upstream escaped output when a custom parser cannot prove equivalence. + } + cache.set(block, { markdown, result }) + return result + } + + editor.getMarkdown = () => { + const markdown = serialize() + if (!/\\[[\]]/.test(markdown)) { + return markdown + } + // Reference definitions can change inline meaning across block boundaries. + if (/^ {0,3}\[[^\n]*\]:/m.test(withoutOptionalEscapes(markdown))) { + return markdown + } + const json = editor.getJSON() + blocks = new Map() + json.content?.forEach((node, index) => { + if (node.type === 'paragraph' || node.type === 'heading') { + blocks!.set(node, editor.state.doc.child(index)) + } + }) + try { + return manager.serialize(json) + } finally { + blocks = undefined + } + } +}