mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
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.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -249,7 +249,6 @@ export function createRichMarkdownExtensions({
|
||||
throwOnError: false
|
||||
}
|
||||
}),
|
||||
RichMarkdownEscapedCharacter,
|
||||
createRichMarkdownLiteral(codec.transport),
|
||||
...(htmlSuperscriptLinks
|
||||
? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user