From af48c13a5e61f7ddfe09658922c45c0c09c63993 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Thu, 16 Jul 2026 15:57:35 +0800 Subject: [PATCH] fix(editor): render
in markdown table cells as line breaks in rich mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich Markdown editor encoded every inline HTML tag — including `
` and `
` — 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 `
` showed up as raw "
" instead of breaking the line. Render `
`/`
` 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 --- .../raw-markdown-html-line-break.test.ts | 77 +++++++++++++++++ .../editor/raw-markdown-html-source-dom.ts | 85 +++++++++++++++++++ .../components/editor/raw-markdown-html.ts | 24 ++---- 3 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/editor/raw-markdown-html-line-break.test.ts create mode 100644 src/renderer/src/components/editor/raw-markdown-html-source-dom.ts diff --git a/src/renderer/src/components/editor/raw-markdown-html-line-break.test.ts b/src/renderer/src/components/editor/raw-markdown-html-line-break.test.ts new file mode 100644 index 00000000000..079aa0a085a --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-html-line-break.test.ts @@ -0,0 +1,77 @@ +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:

`normal`
`defect` |\n' + +describe('raw markdown
line breaks in the rich editor', () => { + it('renders
and
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) + // Every
/
in the cell renders as an actual
element so the + // values appear on their own lines rather than as raw "
" text. + 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 hi after\n') + try { + const [spanNode] = findRawInlineNodes(editor) + expect(spanNode).toBeDefined() + expect(renderTag(spanNode)).toBe('span') + } finally { + editor.destroy() + } + }) + + it('round-trips the verbatim
tags in a table cell on save', () => { + const editor = createEditor(TABLE_WITH_BREAKS) + try { + const output = (editor as Editor & { getMarkdown(): string }).getMarkdown() + expect(output).toContain('
') + expect(output).toContain('
') + // The break must not silently collapse into a space on serialization. + expect(output).not.toContain('`normal` `defect`') + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/raw-markdown-html-source-dom.ts b/src/renderer/src/components/editor/raw-markdown-html-source-dom.ts new file mode 100644 index 00000000000..8edad202bb7 --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-html-source-dom.ts @@ -0,0 +1,85 @@ +import { mergeAttributes } from '@tiptap/core' +import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' +import type { RichMarkdownSourceKind } from './rich-markdown-source-transport' + +// Why: preserves the exact authored
spelling on the rendered break node so +// renderMarkdown can round-trip it verbatim instead of a native hardBreak, which +// serializes to a space and silently drops the tag inside Markdown table cells. +const HTML_LINE_BREAK_VALUE_ATTR = 'data-raw-markdown-html-value' + +type RawMarkdownSourceDomConfig = { + inline: boolean + kind: RichMarkdownSourceKind + marker: string + className?: string +} + +type RawMarkdownSourceParseRule = { + tag: string + priority?: number + getAttrs: (element: HTMLElement) => { value: string } +} + +function isHtmlLineBreak(value: string): boolean { + return /^$/i.test(value.trim()) +} + +function nodeValue(node: ProseMirrorNode): string { + return typeof node.attrs.value === 'string' ? node.attrs.value : '' +} + +// Why: only inline raw HTML can carry a
, so line-break rendering is scoped +// to that node kind; literal envelopes and block HTML stay verbatim. +function rendersLineBreaks(config: RawMarkdownSourceDomConfig): boolean { + return config.inline && config.kind === 'inline-html' +} + +export function rawMarkdownSourceParseRules( + config: RawMarkdownSourceDomConfig +): RawMarkdownSourceParseRule[] { + const rules: RawMarkdownSourceParseRule[] = [ + { + tag: `${config.inline ? 'span' : 'div'}[${config.marker}]`, + getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' }) + } + ] + if (rendersLineBreaks(config)) { + // Why: a
renders as a real break element (see renderHTML), so it must + // round-trip back to this node rather than StarterKit's hardBreak; the marker + // attribute and priority keep it ahead of hardBreak's bare `br` rule. + rules.push({ + tag: `br[${config.marker}]`, + priority: 100, + getAttrs: (element: HTMLElement) => ({ + value: element.getAttribute(HTML_LINE_BREAK_VALUE_ATTR) ?? '
' + }) + }) + } + return rules +} + +export function renderRawMarkdownSourceHtml( + config: RawMarkdownSourceDomConfig, + attributes: Record, + node: ProseMirrorNode +): DOMOutputSpec { + const value = nodeValue(node) + // Why:
is the one inline HTML tag with line-break semantics the rich + // surface can honor directly. Render it as an actual break so it displays + // correctly where blank-line breaks are impossible (e.g. Markdown table cells). + 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] + ] +} diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index 76c0285ca32..525154d1c8a 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -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 { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html' import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links' import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization' @@ -299,25 +303,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) } }) }