mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(editor): preserve copied blocks and literal Markdown attributes
This commit is contained in:
@@ -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('')
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user