fix(editor): honor edited markdown destinations

This commit is contained in:
Neil
2026-09-18 13:53:20 -07:00
parent e359fbf746
commit 2bcfc3000d
3 changed files with 81 additions and 7 deletions
@@ -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)
@@ -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})`
@@ -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)) {