diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 1ba2131..8c3e742 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -51,6 +51,7 @@ import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts'; import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs'; import { convertListNode, type MixedListName } from '$lib/editor/mixedLists'; + import { serializeInlineMarkdown } from '$lib/editor/markdown'; import { relativePath } from '$lib/utils/paths'; import GraphView from './GraphView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; @@ -3666,68 +3667,7 @@ } function serializeInline(node: any): string { - if (node.childCount === 0) return ''; - const parts: string[] = []; - node.forEach((child: any, _offset: number, index: number) => { - if (child.isText) { - let text = child.text || ''; - // Preserve leading tabs/em-spaces as HTML entities so they survive markdown roundtrip - // (markdown parsers strip tab whitespace, but passes through as HTML) - // Tabs come from initial indent; em-spaces (U+2003) come from prior roundtrips - if (index === 0) { - text = text.replace(/^[\t\u2003]+/, (ws) => ' '.repeat(ws.length)); - } - // Apply marks - for (const mark of child.marks) { - switch (mark.type.name) { - case 'bold': text = `**${text}**`; break; - case 'italic': text = `*${text}*`; break; - case 'strike': text = `~~${text}~~`; break; - case 'code': text = `\`${text}\``; break; - case 'underline': text = `${text}`; break; - case 'subscript': text = `~${text}~`; break; - case 'superscript': text = `^${text}^`; break; - case 'highlight': { - const color = mark.attrs?.color; - if (color) { - text = `${text}`; - } else { - text = `==${text}==`; - } - break; - } - case 'textStyle': { - const c = mark.attrs?.color; - if (c) text = `${text}`; - break; - } - case 'link': text = `[${text}](${mark.attrs.href})`; break; - case 'wikiLink': { - const wlTitle = mark.attrs.title || text; - // If display text differs from the reference, emit [[ref|display]] (Obsidian alias syntax) - text = wlTitle !== text ? `[[${wlTitle}|${text}]]` : `[[${wlTitle}]]`; - break; - } - } - } - parts.push(text); - } else if (child.type.name === 'image') { - const src = stripAssetSrc(child.attrs.src || ''); - if (!src) return; // Skip images with unresolved blob: URLs - const alt = child.attrs.alt || ''; - const size = child.attrs['data-size'] || child.attrs.size || 'full'; - const sizeSuffix = size && size !== 'full' ? `|size=${size}` : ''; - if (parts.length > 0 && parts[parts.length - 1] !== '\n') { - parts.push('\n'); - } - parts.push(``); - } else if (child.type.name === 'mathInline') { - parts.push(`$${child.attrs.tex || ''}$`); - } else if (child.type.name === 'hardBreak') { - parts.push(' \n'); - } - }); - return parts.join(''); + return serializeInlineMarkdown(node, stripAssetSrc); } function autofocus(el: HTMLElement) { diff --git a/src/lib/editor/markdown.ts b/src/lib/editor/markdown.ts new file mode 100644 index 0000000..b3c52f9 --- /dev/null +++ b/src/lib/editor/markdown.ts @@ -0,0 +1,143 @@ +import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model'; + +type AssetSourceNormalizer = (source: string) => string; + +type MarkdownMark = { + key: string; + priority: number; + open: string; + close: string; +}; + +const identityAssetSource: AssetSourceNormalizer = (source) => source; + +function stringAttr(mark: Mark, name: string): string { + const value = mark.attrs?.[name]; + return typeof value === 'string' ? value : ''; +} + +function markdownMark(mark: Mark, text: string): MarkdownMark | null { + switch (mark.type.name) { + case 'bold': + return { key: 'bold', priority: 10, open: '**', close: '**' }; + case 'italic': + return { key: 'italic', priority: 20, open: '*', close: '*' }; + case 'strike': + return { key: 'strike', priority: 30, open: '~~', close: '~~' }; + case 'underline': + return { key: 'underline', priority: 40, open: '', close: '' }; + case 'subscript': + return { key: 'subscript', priority: 50, open: '~', close: '~' }; + case 'superscript': + return { key: 'superscript', priority: 60, open: '^', close: '^' }; + case 'highlight': { + const color = stringAttr(mark, 'color'); + return color + ? { + key: `highlight:${color}`, + priority: 70, + open: ``, + close: '', + } + : { key: 'highlight', priority: 70, open: '==', close: '==' }; + } + case 'textStyle': { + const color = stringAttr(mark, 'color'); + return color + ? { + key: `textStyle:${color}`, + priority: 80, + open: ``, + close: '', + } + : null; + } + case 'code': + return { key: 'code', priority: 90, open: '`', close: '`' }; + case 'link': { + const href = stringAttr(mark, 'href'); + return { + key: `link:${href}`, + priority: 100, + open: '[', + close: `](${href})`, + }; + } + case 'wikiLink': { + const title = stringAttr(mark, 'title') || text; + const aliased = mark.attrs?.aliased === true || title !== text; + return { + key: `wikiLink:${title}:${aliased}`, + priority: 110, + open: aliased ? `[[${title}|` : '[[', + close: ']]', + }; + } + default: + return null; + } +} + +function marksForText(node: ProseMirrorNode): MarkdownMark[] { + return node.marks + .map((mark) => markdownMark(mark, node.text || '')) + .filter((mark): mark is MarkdownMark => mark !== null) + .sort((left, right) => left.priority - right.priority || left.key.localeCompare(right.key)); +} + +function commonMarkCount(active: MarkdownMark[], next: MarkdownMark[]): number { + const length = Math.min(active.length, next.length); + let index = 0; + while (index < length && active[index].key === next[index].key) index += 1; + return index; +} + +export function serializeInlineMarkdown( + node: ProseMirrorNode, + normalizeAssetSource: AssetSourceNormalizer = identityAssetSource, +): string { + let result = ''; + let activeMarks: MarkdownMark[] = []; + + const transitionMarks = (nextMarks: MarkdownMark[]) => { + const shared = commonMarkCount(activeMarks, nextMarks); + for (let index = activeMarks.length - 1; index >= shared; index -= 1) { + result += activeMarks[index].close; + } + for (let index = shared; index < nextMarks.length; index += 1) { + result += nextMarks[index].open; + } + activeMarks = nextMarks; + }; + + node.forEach((child, _offset, index) => { + if (child.isText) { + transitionMarks(marksForText(child)); + let text = child.text || ''; + if (index === 0) { + text = text.replace(/^[\t\u2003]+/, (whitespace) => ' '.repeat(whitespace.length)); + } + result += text; + return; + } + + transitionMarks([]); + + if (child.type.name === 'image') { + const source = normalizeAssetSource(child.attrs.src || ''); + if (!source) return; + const alt = child.attrs.alt || ''; + const size = child.attrs['data-size'] || child.attrs.size || 'full'; + const sizeSuffix = size && size !== 'full' ? `|size=${size}` : ''; + if (result) result += '\n'; + result += ``; + } else if (child.type.name === 'mathInline') { + result += `$${child.attrs.tex || ''}$`; + } else if (child.type.name === 'hardBreak') { + result += ' \n'; + } + }); + + transitionMarks([]); + return result; +} diff --git a/tests/markdown-roundtrip.test.mjs b/tests/markdown-roundtrip.test.mjs new file mode 100644 index 0000000..c52165b --- /dev/null +++ b/tests/markdown-roundtrip.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import MarkdownIt from 'markdown-it'; +import { Schema } from '@tiptap/pm/model'; +import { transformWithEsbuild } from 'vite'; + +const source = await readFile( + new URL('../src/lib/editor/markdown.ts', import.meta.url), + 'utf8' +); +const { code } = await transformWithEsbuild(source, 'markdown.ts', { + loader: 'ts', + format: 'esm', + target: 'esnext' +}); +const { serializeInlineMarkdown } = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` +); + +// Link intentionally precedes formatting marks in the schema. This matches the +// mark order produced by the app and reproduces the original per-text-node bug. +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { content: 'text*', group: 'block' }, + text: { group: 'inline' } + }, + marks: { + link: { attrs: { href: {} } }, + bold: {}, + italic: {}, + wikiLink: { + attrs: { + title: { default: null }, + aliased: { default: false } + } + } + } +}); + +const markdown = new MarkdownIt({ html: true, linkify: false, breaks: false }); +const bold = schema.marks.bold.create(); +const italic = schema.marks.italic.create(); +const link = schema.marks.link.create({ href: 'https://example.com' }); + +function paragraph(segments) { + return schema.node( + 'paragraph', + null, + segments.map(({ text, marks }) => schema.text(text, marks)) + ); +} + +function assertMarkdown(segments, expected, expectedHtml) { + const serialized = serializeInlineMarkdown(paragraph(segments)); + assert.equal(serialized, expected); + assert.equal(markdown.render(serialized), `${expectedHtml}\n`); +} + +test('keeps bold open when a link ends the marked span', () => { + assertMarkdown( + [ + { text: 'Bold ', marks: [bold] }, + { text: 'link', marks: [link, bold] } + ], + '**Bold [link](https://example.com)**', + '
Bold link
' + ); +}); + +test('keeps bold open when a link starts the marked span', () => { + assertMarkdown( + [ + { text: 'link', marks: [link, bold] }, + { text: ' bold', marks: [bold] } + ], + '**[link](https://example.com) bold**', + 'link bold
' + ); +}); + +test('keeps one bold span around a link in the middle', () => { + assertMarkdown( + [ + { text: 'Before ', marks: [bold] }, + { text: 'link', marks: [link, bold] }, + { text: ' after', marks: [bold] } + ], + '**Before [link](https://example.com) after**', + 'Before link after
' + ); +}); + +test('keeps nested bold and italic marks open across a link', () => { + assertMarkdown( + [ + { text: 'Nested ', marks: [bold, italic] }, + { text: 'link', marks: [link, bold, italic] }, + { text: ' marks', marks: [bold, italic] } + ], + '***Nested [link](https://example.com) marks***', + 'Nested link marks
' + ); +}); + +test('preserves wiki-link aliases inside a bold span', () => { + const wikiLink = schema.marks.wikiLink.create({ title: 'Target note', aliased: true }); + const serialized = serializeInlineMarkdown( + paragraph([ + { text: 'See ', marks: [bold] }, + { text: 'this note', marks: [wikiLink, bold] }, + { text: ' now', marks: [bold] } + ]) + ); + + assert.equal(serialized, '**See [[Target note|this note]] now**'); +});