From f154eb43ca8238de24798b13e03185b341ac3139 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 20:36:39 -0700 Subject: [PATCH] fix(editor): preserve copied blocks and literal Markdown attributes --- .../editor/markdown-rich-mode.test.ts | 11 +++ .../components/editor/markdown-rich-mode.ts | 6 +- .../rich-markdown-attribute-encoding.test.ts | 43 ++++++++++++ ...h-markdown-clipboard-serialization.test.ts | 69 ++++++++++++++++--- .../rich-markdown-clipboard-serialization.ts | 2 +- .../rich-markdown-code-span-padding.test.ts | 19 ++++- .../editor/rich-markdown-code-span-padding.ts | 25 +++++-- .../editor/rich-markdown-inline-math.test.ts | 31 +++++++++ .../rich-markdown-literal-serialization.ts | 25 ++----- .../editor/rich-markdown-prose-entities.ts | 12 +++- .../components/editor/tiptap-marked-facade.ts | 5 +- tests/e2e/markdown-copy-formatting.spec.ts | 24 +++++++ 12 files changed, 227 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-attribute-encoding.test.ts diff --git a/src/renderer/src/components/editor/markdown-rich-mode.test.ts b/src/renderer/src/components/editor/markdown-rich-mode.test.ts index 1c05d3ef1b9..5e581b23ae9 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.test.ts @@ -244,6 +244,17 @@ describe('getMarkdownRichModeUnsupportedMessage', () => { }) describe('reference-style link definitions', () => { + it('blocks a definition whose label contains an escaped closing bracket', () => { + expect( + getMarkdownRichModeUnsupportedMessage('[foo\\]]: https://example.com\n') + ).not.toBeNull() + }) + + it('blocks an escaped-bracket definition in an oversized document', () => { + const content = `${'a'.repeat(50_001)}\n\n[foo\\]]: https://example.com\n` + expect(getMarkdownRichModeUnsupportedMessage(content)).not.toBeNull() + }) + it('blocks a real link reference definition', () => { expect(getMarkdownRichModeUnsupportedMessage('[id]: https://example.com\n')).not.toBeNull() }) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 73a565db071..82a86171a3b 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -80,7 +80,7 @@ const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ // container nesting produces (e.g. `1) [x]:`); `[label]: ` also opens // ordinary prose, so `hasLinkReferenceDefinition` confirms a real // definition per CommonMark. - pattern: /^[ \t>*+\-\d.)]*\[[^\]]+\]:/m + pattern: /^[ \t>*+\-\d.)]*\[(?:\\.|[^\]\\\n])+\]:/m }, { reason: 'footnotes', @@ -196,7 +196,7 @@ function hasLinkReferenceDefinition(content: string): boolean { // Definitions inside transported HTML comments are comment text, not // Markdown definitions. Remove complete comments before the bounded probe. const commentStripped = content.replace(//g, '') - if (!/^[ \t>*+\-\d.)]*\[[^\]]+\]:/m.test(commentStripped)) { + if (!/^[ \t>*+\-\d.)]*\[(?:\\.|[^\]\\\n])+\]:/m.test(commentStripped)) { return false } if (commentStripped.length > 50_000) { @@ -205,7 +205,7 @@ function hasLinkReferenceDefinition(content: string): boolean { .split(/\r?\n[ \t]*\r?\n/) .some( (block) => - /^[ \t>*+\-\d.)]*\[[^\]]+\]:/m.test(block) && + /^[ \t>*+\-\d.)]*\[(?:\\.|[^\]\\\n])+\]:/m.test(block) && containsDefinitionNode(linkReferenceDefinitionProcessor.parse(block)) ) } diff --git a/src/renderer/src/components/editor/rich-markdown-attribute-encoding.test.ts b/src/renderer/src/components/editor/rich-markdown-attribute-encoding.test.ts new file mode 100644 index 00000000000..9df34cabaf1 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-attribute-encoding.test.ts @@ -0,0 +1,43 @@ +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' + +function open(source: string): Editor { + const codec = createRichMarkdownEditorCodec() + return new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec), + contentType: 'markdown' + }) +} + +for (const text of ['two "" quotes', 'slash\\"quote', 'two\\\\', '[brackets]\\', 'δΈ­ζ–‡ πŸ˜€']) { + describe(`attribute ${JSON.stringify(text)}`, () => { + it.each(['title', 'alt'])('preserves image %s through editing and reopening', (attribute) => { + const editor = open('![image](image.png)') + try { + const image = editor.state.doc.nodeAt(1) + expect(image?.type.name).toBe('image') + editor.view.dispatch( + editor.state.tr.setNodeMarkup(1, undefined, { ...image?.attrs, [attribute]: text }) + ) + let saved = editor.getMarkdown() + for (let revision = 0; revision < 3; revision += 1) { + const reopened = open(saved) + try { + reopened.state.doc.check() + expect(reopened.state.doc.nodeAt(1)?.attrs[attribute]).toBe(text) + saved = reopened.getMarkdown() + } finally { + reopened.destroy() + } + } + } finally { + editor.destroy() + } + }) + }) +} diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.test.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.test.ts index e71cea2aaa9..0b8f54644bb 100644 --- a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.test.ts @@ -1,33 +1,80 @@ import { Editor } from '@tiptap/core' -import { describe, expect, it } from 'vitest' +import { Slice } from '@tiptap/pm/model' +import { describe, expect, it, vi } from 'vitest' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { createRichMarkdownExtensions } from './rich-markdown-extensions' import { serializeRichMarkdownSliceAsMarkdown } from './rich-markdown-clipboard-serialization' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' describe('rich Markdown clipboard serialization', () => { - it('serializes a selected slice as Markdown source', () => { + it.each([ + 'First paragraph.\n\nSecond paragraph.', + '# Heading\n\nA **bold** paragraph with a [link](https://example.com).', + '# Heading\n\n- First item\n- Second item\n\nLast paragraph.' + ])('preserves block boundaries when copying %s', (source) => { const codec = createRichMarkdownEditorCodec() const editor = new Editor({ element: null, extensions: createRichMarkdownExtensions({ codec }), - content: encodeRawMarkdownHtmlForRichEditor( - '# Heading\n\nA **bold** paragraph with a [link](https://example.com).\n', - codec - ), + content: encodeRawMarkdownHtmlForRichEditor(source, codec), contentType: 'markdown' }) try { const slice = editor.state.doc.slice(0, editor.state.doc.content.size) + const original = editor.state.doc + const manager = editor.markdown + if (!manager) { + throw new Error('Markdown manager is required') + } const markdown = serializeRichMarkdownSliceAsMarkdown(slice, (content) => - editor.markdown!.serialize(content) + manager.serialize(content) ) - expect(markdown).toContain('# Heading') - expect(markdown).toContain('**bold**') - expect(markdown).toContain('[link](https://example.com)') - expect(markdown).not.toContain('\n\n\n') + expect(markdown).toBe(source) + editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(markdown, codec), { + contentType: 'markdown' + }) + expect(editor.state.doc.eq(original)).toBe(true) } finally { editor.destroy() } }) + + it('copies only the selected inline text and retains its mark', () => { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor('A **bold** paragraph.', codec), + contentType: 'markdown' + }) + try { + const slice = editor.state.doc.slice(3, 7) + const manager = editor.markdown + if (!manager) { + throw new Error('Markdown manager is required') + } + const markdown = serializeRichMarkdownSliceAsMarkdown(slice, (content) => + manager.serialize(content) + ) + expect(markdown).toBe('**bold**') + editor.commands.setContent(markdown, { contentType: 'markdown' }) + expect(editor.getJSON()).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] }] + } + ] + }) + } finally { + editor.destroy() + } + }) + + it('returns empty text without serializing an empty selection', () => { + const serialize = vi.fn(() => 'unexpected content') + expect(serializeRichMarkdownSliceAsMarkdown(Slice.empty, serialize)).toBe('') + expect(serialize).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts index ed4360b2cb1..b8ba718a902 100644 --- a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts @@ -24,5 +24,5 @@ export function serializeRichMarkdownSliceAsMarkdown( serialize: MarkdownSliceSerializer ): string { const content = slice.content.toJSON() - return Array.isArray(content) ? serialize(content) : '' + return Array.isArray(content) ? serialize({ type: 'doc', content }) : '' } diff --git a/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts b/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts index 4fe833311d3..05d5dd69348 100644 --- a/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts @@ -36,7 +36,21 @@ describe('maskCodeSpanPadding', () => { { type: 'text', text: ' code ', marks: [{ type: 'code' }] } ]) expect(masked.nodes[0]?.text).not.toContain(' ') - expect(restoreCodeSpanPadding(masked.nodes[0]?.text ?? '', masked.placeholder)).toBe(' code ') + expect( + restoreCodeSpanPadding(masked.nodes[0]?.text ?? '', masked.placeholder, masked.replacements) + ).toBe(' code ') + }) + + it('restores the exact whitespace bytes at code-span boundaries', () => { + const leading = '\t\u00a0 ' + const trailing = ' \u00a0\t' + const masked = maskCodeSpanPadding([ + { type: 'text', text: `${leading}code${trailing}`, marks: [{ type: 'code' }] } + ]) + + expect( + restoreCodeSpanPadding(masked.nodes[0]?.text ?? '', masked.placeholder, masked.replacements) + ).toBe(`${leading}code${trailing}`) }) }) @@ -49,7 +63,8 @@ describe('code span padding round trip', () => { ['`a\uE000b` and ` padded`'], ['**bold** and `code` and *it*'], ['`a` and `b`'], - ['[`label`](https://example.com)'] + ['[`label`](https://example.com)'], + ['Read `\tcode\u00a0` and write up.'] ])('preserves %j', (source) => { expect(roundTrip(source)).toBe(source) }) diff --git a/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts b/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts index 4ad7c0e10bd..507622a8f0c 100644 --- a/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts +++ b/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts @@ -40,8 +40,15 @@ function paddingPlaceholder(nodes: MarkdownNodeLike[]): string { export function maskCodeSpanPadding(nodes: MarkdownNodeLike[]): { nodes: MarkdownNodeLike[] placeholder: string + replacements: readonly (readonly [string, string])[] } { const placeholder = paddingPlaceholder(nodes) + const replacements: [string, string][] = [] + const mask = (padding: string): string => { + const token = `${placeholder}${replacements.length}${placeholder}` + replacements.push([token, padding]) + return token + } const masked = nodes.map((node) => { if (node?.type !== 'text' || !hasCodeMark(node)) { return node @@ -55,14 +62,22 @@ export function maskCodeSpanPadding(nodes: MarkdownNodeLike[]): { const body = text.slice(leading.length, trailing ? text.length - trailing.length : text.length) return { ...node, - text: placeholder.repeat(leading.length) + body + placeholder.repeat(trailing.length) + text: (leading ? mask(leading) : '') + body + (trailing ? mask(trailing) : '') } }) - return { nodes: masked, placeholder } + return { nodes: masked, placeholder, replacements } } -export function restoreCodeSpanPadding(markdown: string, placeholder: string): string { - return markdown.split(placeholder).join(' ') +export function restoreCodeSpanPadding( + markdown: string, + placeholder: string, + replacements: readonly (readonly [string, string])[] +): string { + const padding = new Map(replacements) + return markdown.replace( + new RegExp(`${placeholder}\\d+${placeholder}`, 'g'), + (token) => padding.get(token) ?? token + ) } /** @@ -92,7 +107,7 @@ export const RichMarkdownCodeSpanPadding = Extension.create({ const masked = maskCodeSpanPadding(nodes ?? []) const rendered = walk.call(this, masked.nodes, ...rest) return typeof rendered === 'string' - ? restoreCodeSpanPadding(rendered, masked.placeholder) + ? restoreCodeSpanPadding(rendered, masked.placeholder, masked.replacements) : rendered } } diff --git a/src/renderer/src/components/editor/rich-markdown-inline-math.test.ts b/src/renderer/src/components/editor/rich-markdown-inline-math.test.ts index cf28bebf79a..90ff5bf3c25 100644 --- a/src/renderer/src/components/editor/rich-markdown-inline-math.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-inline-math.test.ts @@ -62,3 +62,34 @@ describe('inline math delimiters', () => { expect(countInlineMath('A line with US$ 5,000 here\nand another with R$ 40,000 there.')).toBe(0) }) }) + +it.each([ + '$x$ then \\$HOME', + '\\$HOME then $x$', + '$x$ and \\$a\\$b', + 'x \\$HOME and \\$PATH' +])('preserves literal dollars beside math or HTML: %s', (source) => { + withEditor(source, (editor) => { + const expectedText = editor.state.doc.textContent + const expectedMath: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'inlineMath') { + expectedMath.push(node.attrs.latex) + } + }) + let saved = editor.getMarkdown() + for (let revision = 0; revision < 3; revision += 1) { + saved = withEditor(saved, (reopened) => { + const math: string[] = [] + reopened.state.doc.descendants((node) => { + if (node.type.name === 'inlineMath') { + math.push(node.attrs.latex) + } + }) + expect(reopened.state.doc.textContent).toBe(expectedText) + expect(math).toEqual(expectedMath) + return reopened.getMarkdown() + }) + } + }) +}) 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 0cbdde67c75..9f4466ee7f1 100644 --- a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts +++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts @@ -39,19 +39,6 @@ function escapeBare(markdown: string, chars: string): string { return parts.join('') } -function hasBare(markdown: string, chars: string): boolean { - let oddBackslash = false - for (let i = 0; i < markdown.length; i += 1) { - const character = markdown[i] ?? '' - const bare = !oddBackslash - oddBackslash = character === '\\' ? !oddBackslash : false - if (chars.includes(character) && bare) { - return true - } - } - return false -} - function destNeedsEscape(dest: string): boolean { let depth = 0 let oddBackslash = false @@ -150,8 +137,8 @@ function attrNeedsRepair( const dest = kind === 'image' ? attrs.src : attrs.href return ( (typeof dest === 'string' && destNeedsEscape(dest)) || - (typeof attrs.title === 'string' && hasBare(attrs.title, '"')) || - (kind === 'image' && typeof attrs.alt === 'string' && hasBare(attrs.alt, '[]\\')) + (typeof attrs.title === 'string' && /["\\]/.test(attrs.title)) || + (kind === 'image' && typeof attrs.alt === 'string' && /[\\[\]]/.test(attrs.alt)) ) } @@ -172,11 +159,11 @@ function escapeLinkAndImageAttributes(node: JSONContent): void { if (typeof dest === 'string' && destNeedsEscape(dest)) { attrs[destKey] = escapeBare(dest, '()') } - if (typeof attrs.title === 'string' && hasBare(attrs.title, '"')) { - attrs.title = escapeBare(attrs.title, '"') + if (typeof attrs.title === 'string' && /["\\]/.test(attrs.title)) { + attrs.title = attrs.title.replace(/["\\]/g, '\\$&') } - if (kind === 'image' && typeof attrs.alt === 'string' && hasBare(attrs.alt, '[]\\')) { - attrs.alt = escapeBare(attrs.alt, '[]\\') + if (kind === 'image' && typeof attrs.alt === 'string' && /[\\[\]]/.test(attrs.alt)) { + attrs.alt = attrs.alt.replace(/[\\[\]]/g, '\\$&') } }) } diff --git a/src/renderer/src/components/editor/rich-markdown-prose-entities.ts b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts index 38440f3e353..9b7c524ce7e 100644 --- a/src/renderer/src/components/editor/rich-markdown-prose-entities.ts +++ b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts @@ -1,9 +1,9 @@ -import { Extension } from '@tiptap/core' +import { Extension, type JSONContent } from '@tiptap/core' const TAG_OPENING = /^<(?:[a-zA-Z][a-zA-Z0-9-]*|\/[a-zA-Z][a-zA-Z0-9-]*|!|\?)/ type MarkdownTextEncoder = { - encodeTextForMarkdown?: (text: string, node: unknown, parentNode?: unknown) => string + encodeTextForMarkdown?: (text: string, node: JSONContent, parentNode?: JSONContent) => string } function isMarkdownTextEncoder(value: unknown): value is MarkdownTextEncoder { @@ -54,7 +54,13 @@ export const RichMarkdownProseEntities = Extension.create({ } managerValue.encodeTextForMarkdown = (text, node, parentNode) => { const encoded = base.call(managerValue, text, node, parentNode) - return encoded === text ? text : encodeProseTextForMarkdown(text) + const prose = encoded === text ? text : encodeProseTextForMarkdown(text) + const insideCode = + parentNode?.type === 'codeBlock' || node.marks?.some((mark) => mark.type === 'code') + const hasInlineSyntax = parentNode?.content?.some( + (child) => child.type === 'inlineMath' || child.type === 'rawMarkdownHtmlInline' + ) + return !insideCode && hasInlineSyntax ? prose.replace(/\$/g, '\\$&') : prose } } }) diff --git a/src/renderer/src/components/editor/tiptap-marked-facade.ts b/src/renderer/src/components/editor/tiptap-marked-facade.ts index 534c2fffb58..bb8d9b02270 100644 --- a/src/renderer/src/components/editor/tiptap-marked-facade.ts +++ b/src/renderer/src/components/editor/tiptap-marked-facade.ts @@ -19,7 +19,10 @@ export function createTiptapMarkedFacade(): typeof marked { tokenizer: { link(src) { const token = Tokenizer.prototype.link.call(this, src) - const label = token?.type === 'link' ? this.rules.inline.link.exec(src)?.[1] : undefined + const label = token ? this.rules.inline.link.exec(src)?.[1] : undefined + if (token?.type === 'image' && label !== undefined) { + token.text = label.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/g, '$1') + } if (token?.type === 'link' && label && /\\(?:\[|\])/.test(label)) { // Preserve label escapes before nested inline parsing can reinterpret them as links. const wasInLink = this.lexer.state.inLink diff --git a/tests/e2e/markdown-copy-formatting.spec.ts b/tests/e2e/markdown-copy-formatting.spec.ts index 5ef99daf431..8ea3a3e638e 100644 --- a/tests/e2e/markdown-copy-formatting.spec.ts +++ b/tests/e2e/markdown-copy-formatting.spec.ts @@ -53,6 +53,30 @@ test('copying a rich selection preserves Markdown formatting', async ({ orcaPage expect(copied.html).toContain('bold') await expect(editor.locator('p')).toHaveText('A bold paragraph with a link.') await testInfo.attach('copied-markdown', { body: copied.text, contentType: 'text/markdown' }) + + await editor.evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + element.focus() + document.dispatchEvent(new Event('selectionchange')) + }) + await expect + .poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())) + .toContain('Copy formatting') + const copiedBlocks = await editor.evaluate((element) => { + const clipboardData = new DataTransfer() + element.dispatchEvent( + new ClipboardEvent('copy', { clipboardData, bubbles: true, cancelable: true }) + ) + return clipboardData.getData('text/plain') + }) + expect(copiedBlocks).toBe( + '# Copy formatting\n\nA **bold** paragraph with a [link](https://example.com).' + ) + await testInfo.attach('copied-blocks', { body: copiedBlocks, contentType: 'text/markdown' }) } finally { await cleanupMarkdownFixture(filePath) }