mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(editor): render authored Markdown breaks as rich-editor line breaks (#8995)
* fix(editor): render <br> in markdown table cells as line breaks in rich mode The rich Markdown editor encoded every inline HTML tag — including `<br>` and `<br/>` — into an inert raw-HTML atom that displayed the literal tag text. In a table cell, where a blank-line break is impossible, this meant `<br>` showed up as raw "<br>" instead of breaking the line. Render `<br>`/`<br/>` raw inline nodes as real break elements while keeping `renderMarkdown` emitting the verbatim tag, so the value round-trips exactly on save. A native hardBreak is intentionally not used: it serializes to a space inside table cells and would silently drop the tag. The raw-source node's parse/render DOM logic is extracted into a dedicated module to keep it cohesive and within the file's line budget. Closes #8838 * fix(editor): validate preserved break source Use the editor model's supported DOM parse-rule type instead of a private duplicate. Exercise the rendered-DOM round trip and reject forged preservation attributes before they can become hidden Markdown source. * test(editor): assert forged break marker exists * test(editor): preserve authored rich markdown break spelling --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
co-authored by
Brennan Benson
Neil
Neil
parent
831c394797
commit
39fc4f7688
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
|
||||
function createEditor(markdown: string): Editor {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(markdown, codec),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
}
|
||||
|
||||
function findRawInlineNodes(editor: Editor): ProseMirrorNode[] {
|
||||
const nodes: ProseMirrorNode[] = []
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'rawMarkdownHtmlInline') {
|
||||
nodes.push(node)
|
||||
}
|
||||
})
|
||||
return nodes
|
||||
}
|
||||
|
||||
function renderTag(node: ProseMirrorNode): string {
|
||||
const spec = node.type.spec.toDOM?.(node)
|
||||
return Array.isArray(spec) ? String(spec[0]) : ''
|
||||
}
|
||||
|
||||
const TABLE_WITH_BREAKS =
|
||||
'| Field | Description |\n' +
|
||||
'| --- | --- |\n' +
|
||||
'| verdict | Values:<br/><br/>`normal`<br>`defect` |\n'
|
||||
|
||||
describe('raw markdown <br> line breaks in the rich editor', () => {
|
||||
it('renders <br> and <br/> inside a table cell as real line breaks, not literal text', () => {
|
||||
const editor = createEditor(TABLE_WITH_BREAKS)
|
||||
try {
|
||||
const breakNodes = findRawInlineNodes(editor)
|
||||
expect(breakNodes.length).toBeGreaterThan(0)
|
||||
for (const node of breakNodes) {
|
||||
expect(renderTag(node)).toBe('br')
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps non-break inline HTML as an inert literal span', () => {
|
||||
const editor = createEditor('Before <span>hi</span> after\n')
|
||||
try {
|
||||
const [spanNode] = findRawInlineNodes(editor)
|
||||
expect(spanNode).toBeDefined()
|
||||
expect(renderTag(spanNode)).toBe('span')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips verbatim <br> tags after the rendered DOM is parsed again', () => {
|
||||
const editor = createEditor(TABLE_WITH_BREAKS)
|
||||
try {
|
||||
const rendered = document.createElement('div')
|
||||
rendered.innerHTML = editor.getHTML()
|
||||
expect(rendered.querySelectorAll('br[data-raw-markdown-html-inline]')).toHaveLength(3)
|
||||
expect(rendered.textContent).not.toContain('<br')
|
||||
|
||||
editor.commands.setContent(rendered.innerHTML)
|
||||
const output = editor.getMarkdown()
|
||||
expect(output.match(/<br\s*\/?>/g)).toEqual(['<br/>', '<br/>', '<br>'])
|
||||
expect(output).not.toContain('`normal` `defect`')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a case-insensitive break spelling through a DOM round trip', () => {
|
||||
const editor = createEditor('Before<BR />after\n')
|
||||
try {
|
||||
const rendered = document.createElement('div')
|
||||
rendered.innerHTML = editor.getHTML()
|
||||
|
||||
editor.commands.setContent(rendered.innerHTML)
|
||||
|
||||
expect(editor.getMarkdown()).toContain('Before<BR />after')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not accept arbitrary Markdown source from a forged break marker', () => {
|
||||
const editor = createEditor('Before<br/>after\n')
|
||||
try {
|
||||
const rendered = document.createElement('div')
|
||||
rendered.innerHTML = editor.getHTML()
|
||||
const breakNode = rendered.querySelector('br[data-raw-markdown-html-inline]')
|
||||
expect(breakNode).not.toBeNull()
|
||||
if (!breakNode) {
|
||||
throw new Error('Expected preserved break marker')
|
||||
}
|
||||
breakNode.setAttribute('data-raw-markdown-html-value', '<script>alert(1)</script>')
|
||||
|
||||
editor.commands.setContent(rendered.innerHTML)
|
||||
const output = editor.getMarkdown()
|
||||
expect(output).toContain('Before<br>after')
|
||||
expect(output).not.toContain('<script>')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { Node } from '@tiptap/core'
|
||||
import {
|
||||
rawMarkdownSourceParseRules,
|
||||
renderRawMarkdownSourceHtml
|
||||
} from './raw-markdown-html-source-dom'
|
||||
import { createMarkdownTokenizerStart } from './markdown-tokenizer-start'
|
||||
import {
|
||||
skipInlineTransportStartScan,
|
||||
@@ -95,25 +99,11 @@ function createRawSourceNode({
|
||||
renderText: ({ node }) => (typeof node.attrs.value === 'string' ? node.attrs.value : ''),
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}[${marker}]`,
|
||||
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
|
||||
}
|
||||
]
|
||||
return rawMarkdownSourceParseRules({ inline, kind, marker, className })
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
return [
|
||||
inline ? 'span' : 'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
[marker]: '',
|
||||
contenteditable: 'false',
|
||||
class: className
|
||||
}),
|
||||
inline ? value : ['pre', value]
|
||||
]
|
||||
return renderRawMarkdownSourceHtml({ inline, kind, marker, className }, HTMLAttributes, node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import type { DOMOutputSpec, Node as ProseMirrorNode, TagParseRule } from '@tiptap/pm/model'
|
||||
import type { RichMarkdownSourceKind } from './rich-markdown-source-transport'
|
||||
|
||||
// Preserve authored break spelling; native hardBreak serializes to a space in table cells.
|
||||
const HTML_LINE_BREAK_VALUE_ATTR = 'data-raw-markdown-html-value'
|
||||
|
||||
type RawMarkdownSourceDomConfig = {
|
||||
inline: boolean
|
||||
kind: RichMarkdownSourceKind
|
||||
marker: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function isHtmlLineBreak(value: string): boolean {
|
||||
return /^<br\s*\/?>$/i.test(value.trim())
|
||||
}
|
||||
|
||||
function parsedHtmlLineBreakValue(element: HTMLElement): string {
|
||||
const value = element.getAttribute(HTML_LINE_BREAK_VALUE_ATTR)
|
||||
// Clipboard markers may preserve break syntax only.
|
||||
return value && isHtmlLineBreak(value) ? value : '<br>'
|
||||
}
|
||||
|
||||
function nodeValue(node: ProseMirrorNode): string {
|
||||
return typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
}
|
||||
|
||||
function rendersLineBreaks(config: RawMarkdownSourceDomConfig): boolean {
|
||||
return config.inline && config.kind === 'inline-html'
|
||||
}
|
||||
|
||||
export function rawMarkdownSourceParseRules(config: RawMarkdownSourceDomConfig): TagParseRule[] {
|
||||
const rules: TagParseRule[] = [
|
||||
{
|
||||
tag: `${config.inline ? 'span' : 'div'}[${config.marker}]`,
|
||||
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
|
||||
}
|
||||
]
|
||||
if (rendersLineBreaks(config)) {
|
||||
// Parse preserved breaks before StarterKit's unmarked hardBreak rule.
|
||||
rules.push({
|
||||
tag: `br[${config.marker}]`,
|
||||
priority: 100,
|
||||
getAttrs: (element: HTMLElement) => ({ value: parsedHtmlLineBreakValue(element) })
|
||||
})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
export function renderRawMarkdownSourceHtml(
|
||||
config: RawMarkdownSourceDomConfig,
|
||||
attributes: Record<string, unknown>,
|
||||
node: ProseMirrorNode
|
||||
): DOMOutputSpec {
|
||||
const value = nodeValue(node)
|
||||
if (rendersLineBreaks(config) && isHtmlLineBreak(value)) {
|
||||
return [
|
||||
'br',
|
||||
mergeAttributes(attributes, { [config.marker]: '', [HTML_LINE_BREAK_VALUE_ATTR]: value })
|
||||
]
|
||||
}
|
||||
return [
|
||||
config.inline ? 'span' : 'div',
|
||||
mergeAttributes(attributes, {
|
||||
[config.marker]: '',
|
||||
contenteditable: 'false',
|
||||
class: config.className
|
||||
}),
|
||||
config.inline ? value : ['pre', value]
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user