From 39fc4f7688769c079ff42347d3d9ead02715c838 Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Sat, 24 Oct 2026 10:20:09 +0800 Subject: [PATCH] fix(editor): render authored Markdown breaks as rich-editor line breaks (#8995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): render
in markdown table cells as line breaks in rich mode 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 * 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 --- .../raw-markdown-html-line-break.test.ts | 116 ++++++++++++++++++ .../editor/raw-markdown-html-nodes.ts | 24 ++-- .../editor/raw-markdown-html-source-dom.ts | 72 +++++++++++ 3 files changed, 195 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..74e8c24ca62 --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-html-line-break.test.ts @@ -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:

`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) + 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 verbatim
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('/g)).toEqual(['
', '
', '
']) + 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
after\n') + try { + const rendered = document.createElement('div') + rendered.innerHTML = editor.getHTML() + + editor.commands.setContent(rendered.innerHTML) + + expect(editor.getMarkdown()).toContain('Before
after') + } finally { + editor.destroy() + } + }) + + it('does not accept arbitrary Markdown source from a forged break marker', () => { + const editor = createEditor('Before
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', '') + + editor.commands.setContent(rendered.innerHTML) + const output = editor.getMarkdown() + expect(output).toContain('Before
after') + expect(output).not.toContain('