fix(editor): preserve copied blocks and literal Markdown attributes

This commit is contained in:
Neil
2026-09-18 20:36:39 -07:00
parent 8b33fe853c
commit f154eb43ca
12 changed files with 227 additions and 45 deletions
@@ -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()
})
@@ -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(/<!--[\s\S]*?-->/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))
)
}
@@ -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()
}
})
})
}
@@ -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()
})
})
@@ -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 }) : ''
}
@@ -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)
})
@@ -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
}
}
@@ -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',
'<span>x</span> \\$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()
})
}
})
})
@@ -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, '\\$&')
}
})
}
@@ -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
}
}
})
@@ -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
@@ -53,6 +53,30 @@ test('copying a rich selection preserves Markdown formatting', async ({ orcaPage
expect(copied.html).toContain('<strong>bold</strong>')
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)
}