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('')
+ 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)
}