From 7bc2bd16ce1ec2d13d2c40141e911f25de527bb3 Mon Sep 17 00:00:00 2001 From: averydev Date: Sat, 12 Sep 2026 13:57:31 -0700 Subject: [PATCH] fix(editor): carry escapes as a mark so emphasis and links stay continuous Second-round review of the atom approach: Tiptap's serializer closes every active mark before a non-text inline node and reopens it after, so an escape atom inside bold split the run (`**cost \$5 total**` came back as `**cost **\$**5 total**`, which no longer parses as bold), a link containing an escape became two links, and find/replace was disabled on any match touching an escape because atoms are read-only. The escape is now a non-inclusive mark on ordinary text. A mark's markdown is one prefix for the whole run, so `getMarkdown` is wrapped to rewrite marked text as `\X` per character before the manager serializes; inside a code mark the character is emitted bare, and `& < >` are left to the serializer's entity encoding. The mark is registered after `Markdown` because that extension's onBeforeCreate installs the getMarkdown being wrapped. Also restores tiptap-marked-facade.ts to the base branch: the previous commit meant to remove the escape-token rewrite there but restored the file from the branch's own HEAD, leaving a dead override in the diff. --- .../editor/markdown-round-trip.test.ts | 26 ++++- .../editor/rich-markdown-escaped-character.ts | 96 +++++++++++-------- .../editor/rich-markdown-extensions.ts | 1 - .../components/editor/tiptap-marked-facade.ts | 17 ---- 4 files changed, 83 insertions(+), 57 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 f769ea2510c..1da8315e1f4 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -635,13 +635,37 @@ describe('rich markdown round trip', () => { '\\*\\*not bold\\*\\* and \\_not em\\_', '\\`not code\\` and \\~\\~not strike\\~\\~', 'C:\\\\Program Files\\\\(x86)', - 'shell \\$HOME\\$ var' + 'shell \\$HOME\\$ var', + // Why: marks must stay continuous around an escape; consecutive escapes need one backslash each. + '**cost \\$5 total** and **\\$5 fee** and **bold \\$**', + '[a \\$ b](http://x) and *a \\_ b* and *\\*literal\\**', + '**\\*\\*not bold\\*\\*** inside bold' ].join('\n\n') const once = roundTripMarkdown(`${content}\n`) expect(once).toBe(content) expect(roundTripMarkdown(`${once}\n`)).toBe(content) }) + it('does not escape inside code marks and drops the backslash for entity-encoded characters', () => { + expect(roundTripMarkdown('a \\& b and \\< c\n')).toBe('a & b and < c') + // Why: a code span never contains an escape token, so an escaped mark can only reach code + // through editing; the serializer must then emit the literal character. + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor('cost \\$5\n', codec), + contentType: 'markdown' + }) + try { + editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size - 1 }) + editor.commands.setCode() + expect(editor.getMarkdown()).toBe('`cost $5`') + } finally { + editor.destroy() + } + }) + it('keeps escaped dollars inside table cells', () => { expect(roundTripMarkdown('| Item | Amount |\n|---|---|\n| Fee | \\$500 |\n')).toContain( '\\$500' diff --git a/src/renderer/src/components/editor/rich-markdown-escaped-character.ts b/src/renderer/src/components/editor/rich-markdown-escaped-character.ts index 17b43cbf8ff..f7c65703bf9 100644 --- a/src/renderer/src/components/editor/rich-markdown-escaped-character.ts +++ b/src/renderer/src/components/editor/rich-markdown-escaped-character.ts @@ -1,33 +1,27 @@ -import { Node } from '@tiptap/core' +import { Mark, type JSONContent } from '@tiptap/core' // marked's GFM inline escape rule: a backslash before ASCII punctuation. +const ESCAPABLE_CHARACTER_PATTERN = /[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]/ const ESCAPED_CHARACTER_PATTERN = /^\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/ -const NODE_NAME = 'richMarkdownEscapedCharacter' +// Why: the serializer entity-encodes these itself; a backslash in front would survive as literal text. +const ENTITY_ENCODED_CHARACTERS = new Set(['&', '<', '>']) +const MARK_NAME = 'richMarkdownEscapedCharacter' const MARKER_ATTRIBUTE = 'data-rich-markdown-escaped-character' /** - * One inline atom per backslash-escaped character. Tiptap's markdown parser has - * no handler for marked's `escape` token (the character was deleted on load), - * and its text serializer never re-escapes, so a text node could not carry the - * backslash back to disk. An atom that owns the token round-trips `\$` as `\$`. + * Text that was backslash-escaped in the source. Tiptap's markdown parser has no + * handler for marked's `escape` token (the character was deleted on load) and its + * text serializer never re-escapes, so the mark carries the escape through the + * document and `getMarkdown` writes it back per character (see below). */ -export const RichMarkdownEscapedCharacter = Node.create({ - name: NODE_NAME, - inline: true, - group: 'inline', - atom: true, - // Why: reads as one character of text; a node selection on click would be noise. - selectable: false, +export const RichMarkdownEscapedCharacter = Mark.create({ + name: MARK_NAME, + inclusive: false, + keepOnSplit: false, - addAttributes() { - return { - character: { default: '', rendered: false } - } - }, - - markdownTokenName: NODE_NAME, + markdownTokenName: MARK_NAME, markdownTokenizer: { - name: NODE_NAME, + name: MARK_NAME, level: 'inline', start: (src: string) => src.indexOf('\\'), tokenize(src: string) { @@ -35,33 +29,59 @@ export const RichMarkdownEscapedCharacter = Node.create({ if (!match) { return undefined } - return { type: NODE_NAME, raw: match[0], character: match[1] } + return { type: MARK_NAME, raw: match[0], character: match[1] } } }, parseMarkdown: (token, helpers) => { const character = (token as { character?: string }).character - if (token.type !== NODE_NAME || !character) { + if (token.type !== MARK_NAME || !character) { return [] } - return helpers.createNode(NODE_NAME, { character }) + return helpers.applyMark(MARK_NAME, [{ type: 'text', text: character }]) }, - renderMarkdown: (node) => `\\${String(node.attrs?.character ?? '')}`, - renderText: ({ node }) => String(node.attrs.character ?? ''), + // Why: a mark's markdown is one prefix for the whole run, so `\*\*` would come out as `\**`; + // this is only the fallback for a serializer that bypasses getMarkdown. + renderMarkdown: (node, helpers) => `\\${helpers.renderChildren(node)}`, parseHTML() { - return [ - { - tag: `span[${MARKER_ATTRIBUTE}]`, - getAttrs: (element: HTMLElement) => { - const character = element.getAttribute(MARKER_ATTRIBUTE) ?? '' - return ESCAPED_CHARACTER_PATTERN.test(`\\${character}`) ? { character } : false - } - } - ] + return [{ tag: `span[${MARKER_ATTRIBUTE}]` }] + }, + renderHTML() { + return ['span', { [MARKER_ATTRIBUTE]: '' }, 0] }, - renderHTML({ node }) { - const character = String(node.attrs.character ?? '') - return ['span', { [MARKER_ATTRIBUTE]: character }, character] + onBeforeCreate() { + // Why: must be registered after `Markdown`, whose onBeforeCreate installs the + // getMarkdown this replaces; the manager itself is what serializes. + const editor = this.editor + editor.getMarkdown = () => { + const manager = editor.markdown + if (!manager) { + throw new Error('RichMarkdownEscapedCharacter requires the Markdown extension') + } + return manager.serialize(escapeMarkedCharacters(editor.getJSON()) as JSONContent) + } } }) + +/** Turns escaped-mark text back into `\X` per character so mark runs around it stay continuous. */ +function escapeMarkedCharacters(node: JSONContent): JSONContent { + if (node.type === 'text' && node.marks?.some((mark) => mark.type === MARK_NAME)) { + const marks = node.marks.filter((mark) => mark.type !== MARK_NAME) + const insideCode = marks.some((mark) => mark.type === 'code') + const text = insideCode + ? (node.text ?? '') + : Array.from(node.text ?? '') + .map((character) => + ESCAPABLE_CHARACTER_PATTERN.test(character) && !ENTITY_ENCODED_CHARACTERS.has(character) + ? `\\${character}` + : character + ) + .join('') + return marks.length > 0 ? { ...node, text, marks } : { type: 'text', text } + } + if (!node.content) { + return node + } + return { ...node, content: node.content.map(escapeMarkedCharacters) } +} diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 77cee516456..2428ac744e3 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -249,7 +249,6 @@ export function createRichMarkdownExtensions({ throwOnError: false } }), - RichMarkdownEscapedCharacter, createRichMarkdownLiteral(codec.transport), ...(htmlSuperscriptLinks ? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)] diff --git a/src/renderer/src/components/editor/tiptap-marked-facade.ts b/src/renderer/src/components/editor/tiptap-marked-facade.ts index a0deac28ec7..80cadb343f9 100644 --- a/src/renderer/src/components/editor/tiptap-marked-facade.ts +++ b/src/renderer/src/components/editor/tiptap-marked-facade.ts @@ -13,17 +13,6 @@ import { marked } from 'marked' -/** Rewrites escape tokens in place (marked fills caller-owned arrays for table cells). */ -function preserveEscapedCharacters(tokens: Token[]): Token[] { - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index] - if (token.type === 'escape') { - tokens[index] = { type: 'text', raw: token.raw, text: token.text, escaped: false } - } - } - return tokens -} - export function createTiptapMarkedFacade(): typeof marked { const registry = new Marked() @@ -37,12 +26,6 @@ export function createTiptapMarkedFacade(): typeof marked { extensions: registry.defaults.extensions }) } - - // Why: Tiptap's markdown parser has no case for marked's `escape` token, so - // `\$`, `\*`, `\_`, `\[` would be deleted from the document on load. - inlineTokens(src: string, tokens: Token[] = []): Token[] { - return preserveEscapedCharacters(super.inlineTokens(src, tokens)) - } } const parser = (tokens: Token[], options?: MarkedOptions) => registry.parser(tokens, options)