From 2bcfc3000d3759e3eae7aafdd08dba548fb4f68b Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 13:53:20 -0700 Subject: [PATCH] fix(editor): honor edited markdown destinations --- .../editor/markdown-round-trip.test.ts | 63 +++++++++++++++++++ .../editor/rich-markdown-extensions.ts | 22 +++++-- .../rich-markdown-literal-serialization.ts | 3 +- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 7bb005d93d1..00ad5003c03 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -240,6 +240,60 @@ function markdownAfterTextReplace(content: string, search: string, replacement: } } +function markdownAfterDestinationEdit( + content: string, + kind: 'link' | 'image', + destination: string +): string { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(content, codec), + contentType: 'markdown' + }) + try { + if (kind === 'link') { + let from: number | undefined + let to: number | undefined + editor.state.doc.descendants((node, pos) => { + if ( + from === undefined && + node.isText && + node.marks.some((mark) => mark.type.name === 'link') + ) { + from = pos + to = pos + node.nodeSize + } + }) + if (from === undefined || to === undefined) { + throw new Error(`Missing ${kind}`) + } + editor.chain().setTextSelection({ from, to }).setLink({ href: destination }).run() + } else { + let position: number | undefined + editor.state.doc.descendants((node, pos) => { + if (position === undefined && node.type.name === 'image') { + position = pos + } + }) + if (position === undefined) { + throw new Error(`Missing ${kind}`) + } + const node = editor.state.doc.nodeAt(position) + if (!node) { + throw new Error(`Missing ${kind}`) + } + editor.view.dispatch( + editor.state.tr.setNodeMarkup(position, undefined, { ...node.attrs, src: destination }) + ) + } + return editor.getMarkdown().trimEnd() + } finally { + editor.destroy() + } +} + function markdownAfterTypingBesideImage(content: string, typed: string): string { const codec = createRichMarkdownEditorCodec() const editor = new Editor({ @@ -729,6 +783,15 @@ describe('rich markdown round trip', () => { expect(roundTripMarkdown('![a \\] b](x.png)\n')).toBe('![a \\] b](x.png)') }) + it('uses the edited link or image destination instead of stale source bytes', () => { + expect(markdownAfterDestinationEdit('[a](old\\)path)\n', 'link', 'new)path')).toBe( + '[a](new\\)path)' + ) + expect(markdownAfterDestinationEdit('![a](old\\(path.png)\n', 'image', 'new(path.png')).toBe( + '![a](new\\(path.png)' + ) + }) + it('does not split a paragraph at a mid-line $$', () => { const content = 'costs $$ big money $$ here, honestly' expect(roundTripMarkdown(`${content}\n`)).toBe(content) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index e2e2c353633..3fffb8245b6 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -53,17 +53,22 @@ const RichMarkdownLink = Link.extend({ addAttributes() { return { ...this.parent?.(), - rawHref: { default: null, rendered: false } + rawHref: { default: null, rendered: false }, + originalHref: { default: null, rendered: false } } }, parseMarkdown: (token, helpers) => helpers.applyMark('link', helpers.parseInline(token.tokens || []), { href: token.href, title: token.title || null, - rawHref: extractRawDestination(token.raw) + rawHref: extractRawDestination(token.raw), + originalHref: token.href }), renderMarkdown: (node, helpers) => { - const href = node.attrs?.rawHref ?? node.attrs?.href ?? '' + const href = + node.attrs?.rawHref && node.attrs?.href === node.attrs?.originalHref + ? node.attrs.rawHref + : (node.attrs?.href ?? '') const title = node.attrs?.title ?? '' const text = helpers.renderChildren(node) return title ? `[${text}](${href} "${title}")` : `[${text}](${href})` @@ -179,7 +184,8 @@ export function createRichMarkdownExtensions({ addAttributes() { return { ...this.parent?.(), - rawSrc: { default: null, rendered: false } + rawSrc: { default: null, rendered: false }, + originalSrc: { default: null, rendered: false } } }, parseMarkdown: (token, helpers) => @@ -187,10 +193,14 @@ export function createRichMarkdownExtensions({ src: token.href, alt: token.text || '', title: token.title, - rawSrc: extractRawDestination(token.raw) + rawSrc: extractRawDestination(token.raw), + originalSrc: token.href }), renderMarkdown: (node) => { - const src = node.attrs?.rawSrc ?? node.attrs?.src ?? '' + const src = + node.attrs?.rawSrc && node.attrs?.src === node.attrs?.originalSrc + ? node.attrs.rawSrc + : (node.attrs?.src ?? '') const alt = node.attrs?.alt ?? '' const title = node.attrs?.title ?? '' return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})` diff --git a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts index 5748be5629b..989fadc699c 100644 --- a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts +++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts @@ -171,7 +171,8 @@ function escapeLinkAndImageAttributes(node: JSONContent): void { const destKey = kind === 'image' ? 'src' : 'href' const dest = attrs[destKey] const rawDest = kind === 'link' ? attrs.rawHref : attrs.rawSrc - if (typeof rawDest === 'string') { + const originalDest = kind === 'link' ? attrs.originalHref : attrs.originalSrc + if (typeof rawDest === 'string' && dest === originalDest) { attrs[destKey] = rawDest } if (typeof dest === 'string' && destNeedsEscape(dest)) {