feat(rich-md-editor): table row/col toolbar, raw HTML/footnote support, paste fidelity (#5438)

* feat(rich-md-editor): table row/col toolbar, raw HTML/footnote support, paste fidelity

- Add a table toolbar (add/delete row, add/delete column, toggle header, delete table) wired to stock @tiptap/extension-table commands
- Add verbatim snippet nodes for raw HTML blocks, HTML comments, footnotes (def + ref), and inline raw HTML tags — these no longer force the whole document read-only, and the raw source is directly editable in place
- Fix an upstream @tiptap/markdown footgun found while building this: a custom tokenizer with no explicit `start` callback corrupts the shared lexer and silently drops unrelated content elsewhere in the document
- Prefer the markdown parser over generic HTML→DOM paste mapping when the plaintext clipboard side looks like markdown, even if an HTML sibling is present
- Fix a stale select-all selection surviving a stream-settle content replace, which permanently highlighted every divider/image after a file regeneration

* fix(rich-md-editor): address review findings from initial PR round

- Collapse the stream-settle selection unconditionally, not only when setContent re-runs — the last streaming tick already syncs lastSyncedBodyRef to the final body, so the fix was previously skipped in the common streamed-content case (Cursor Bugbot)
- Support GFM footnote definition continuation lines (>=4-space indented, with blank lines between paragraphs) instead of truncating to just the opening line (Greptile P1)
- Fix the inline raw-HTML tokenizer to find the balanced closing tag by tracking nesting depth, instead of matching the first same-name closing tag — fixes corruption on nested same-tag elements like <span>outer <span>inner</span></span> (Greptile P1)
- Stop caching the table toolbar's anchor rect by selection key — the same cell can move on screen from scrolling alone with no selection change, and the cached rect went stale (Greptile P2)

* fix(rich-md-editor): fix CI type errors in raw-markdown-snippet.tsx

- parseMarkdown callbacks returned null on no-match, which @tiptap/core's MarkdownParseResult type doesn't permit — switch to returning [] (empty array), matching the same no-match convention MarkdownCodeBlock already uses, with identical runtime behavior
- Pin NodeViewContent's generic to 'span' (NodeViewContent<'span'> as='span'), since it defaults to 'div' and rejects other `as` values without an explicit type argument

Verified with a full `tsc --noEmit` (NODE_OPTIONS=--max-old-space-size=8192, matching CI) — 0 errors, where it previously failed the Next.js build's type-check step.
This commit is contained in:
Waleed
2026-07-06 12:20:58 -07:00
committed by GitHub
parent 89e5a142e2
commit 5acdaeea02
14 changed files with 965 additions and 27 deletions
@@ -9,6 +9,7 @@ import { RichMarkdownKeymap } from './keymap'
import { MarkdownPaste } from './markdown-paste'
import { Mention } from './mention/mention'
import { MentionChip } from './mention/mention-chip'
import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet'
import { SlashCommand } from './slash-command/slash-command'
interface MarkdownEditorExtensionOptions {
@@ -36,6 +37,8 @@ export function createMarkdownEditorExtensions({
codeBlock: CodeBlockWithLanguage,
image: ResizableImage,
mention: MentionChip,
rawHtmlBlock: RawHtmlBlockWithView,
footnoteDef: FootnoteDefWithView,
}),
CodeBlockHighlight,
SlashCommand,
@@ -15,6 +15,7 @@ import { MarkdownImage } from './image'
import { MarkdownLinkInputRule } from './link-input-rule'
import { MarkdownMention } from './mention/mention-node'
import { SIM_LINK_SCHEME } from './mention/sim-link'
import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet'
/**
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
@@ -66,6 +67,8 @@ export interface ContentNodeViews {
codeBlock?: Node
image?: Node
mention?: Node
rawHtmlBlock?: Node
footnoteDef?: Node
}
/**
@@ -100,6 +103,10 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
TableRow,
TableHeader,
TableCell,
nodeViews.rawHtmlBlock ?? RawHtmlBlock,
nodeViews.footnoteDef ?? FootnoteDef,
FootnoteRef,
RawInlineHtml,
MarkdownLinkInputRule,
Markdown,
]
@@ -58,9 +58,32 @@ describe('markdown paste', () => {
expect(paste(editor, 'just a normal sentence with no syntax')).toBe(false)
})
it('does not markdown-parse a paste that carries richer HTML', () => {
it("prefers the markdown parser over DOM mapping when the HTML sibling's plain-text side also looks like markdown", () => {
editor = mount()
expect(paste(editor, '# heading', '<h1>heading</h1>')).toBe(false)
expect(paste(editor, '# heading', '<h1>heading</h1>')).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"heading"')
})
it('preserves GFM table alignment on a paste that carries both text/plain and text/html', () => {
editor = mount()
const table = '| a | b |\n| :-- | --: |\n| 1 | 2 |'
const html = '<table><tr><td>a</td><td>b</td></tr><tr><td>1</td><td>2</td></tr></table>'
expect(paste(editor, table, html)).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"align":"left"')
expect(json).toContain('"align":"right"')
})
it('still defers to DOM mapping when the HTML sibling has no markdown-shaped plain-text counterpart', () => {
editor = mount()
expect(
paste(
editor,
'just a normal sentence with no syntax',
'<p>just a normal sentence with no syntax</p>'
)
).toBe(false)
})
it('keeps pasted markdown literal inside a code block', () => {
@@ -24,8 +24,15 @@ function looksLikeMarkdown(text: string): boolean {
/**
* Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block
* are left untouched (code is meant to stay literal), as are pastes that carry richer HTML — those
* are handled by ProseMirror's own clipboard parsing.
* are left untouched (code is meant to stay literal).
*
* A clipboard entry that also carries `text/html` (copied from a browser, Slack, Notion, GitHub,
* or this editor itself) used to always defer entirely to ProseMirror's generic HTML→DOM mapping,
* even when the `text/plain` sibling was clean markdown our own parser round-trips more faithfully
* (GFM table alignment, escaping, the constructs `./raw-markdown-snippet.ts` now preserves). Only
* defer to DOM mapping when the plain-text sibling *doesn't* look like markdown — an HTML clipboard
* payload with no markdown-shaped plain-text counterpart (a genuinely rich paste from a word
* processor, a web page selection, …) still goes through the DOM path unchanged.
*/
export const MarkdownPaste = Extension.create({
name: 'markdownPaste',
@@ -38,8 +45,6 @@ export const MarkdownPaste = Extension.create({
handlePaste: (_view, event) => {
if (!editor.isEditable) return false
if (editor.isActive('codeBlock')) return false
const html = event.clipboardData?.getData('text/html')
if (html) return false
const text = event.clipboardData?.getData('text/plain')
if (!text || !looksLikeMarkdown(text)) return false
// Parse through the chunker (linear) so pasting a large markdown blob can't freeze the
@@ -0,0 +1,131 @@
/**
* @vitest-environment jsdom
*
* `TableBubbleMenu` (table-menu.tsx) is a thin UI wrapper around `@tiptap/extension-table`'s stock
* commands — the button that matters is the command it calls, not the floating-toolbar chrome. These
* exercise the exact commands the toolbar wires up (`addRowBefore`/`addRowAfter`/`deleteRow`,
* `addColumnBefore`/`addColumnAfter`/`deleteColumn`, `toggleHeaderRow`, `deleteTable`) against a real
* editor and assert the result round-trips through `PipeSafeTable` to clean, correctly-shaped GFM.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from '../extensions'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(markdown: string): Editor {
return new Editor({
extensions: createMarkdownContentExtensions(),
content: markdown,
contentType: 'markdown',
})
}
function firstCellPos(ed: Editor): number {
let pos = -1
ed.state.doc.descendants((node, p) => {
if (pos < 0 && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) pos = p + 1
})
return pos
}
describe('table toolbar commands', () => {
it('inserts a row after the current row and it round-trips as a clean GFM table', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addRowAfter()).toBe(true)
const rows = editor.state.doc.firstChild
expect(rows?.type.name).toBe('table')
expect(rows?.childCount).toBe(3) // header + original row + inserted row
const md = editor.getMarkdown().trim()
expect(md.split('\n')).toHaveLength(4)
expect(md).toContain('| a')
expect(md).toContain('| --- | --- |')
})
it('inserts a row before the current row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addRowBefore()).toBe(true)
expect(editor.state.doc.firstChild?.childCount).toBe(3)
})
it('deletes the current row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |')
// Select the second body row (skip header).
let pos = -1
let seen = 0
editor.state.doc.descendants((node, p) => {
if (node.type.name === 'tableRow') {
seen++
if (seen === 3) pos = p + 2
}
})
editor.commands.setTextSelection(pos)
expect(editor.commands.deleteRow()).toBe(true)
expect(editor.state.doc.firstChild?.childCount).toBe(2)
expect(editor.getMarkdown()).toContain('| 1 | 2 |')
expect(editor.getMarkdown()).not.toContain('3')
})
it('inserts and deletes a column', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addColumnAfter()).toBe(true)
let cols = 0
editor.state.doc.descendants((node) => {
if (node.type.name === 'tableRow' && cols === 0) cols = node.childCount
})
expect(cols).toBe(3)
// The insert shifted positions — the cursor's old cell no longer maps to the same column, so
// re-select the first cell before deleting, exactly as a real user would click a cell first.
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.deleteColumn()).toBe(true)
editor.state.doc.descendants((node) => {
if (node.type.name === 'tableRow') cols = node.childCount
})
expect(cols).toBe(2)
})
it('toggles the header row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
const before = editor.isActive('tableHeader')
expect(editor.commands.toggleHeaderRow()).toBe(true)
expect(editor.isActive('tableHeader')).toBe(!before)
})
it('deletes the whole table', () => {
editor = mount('before\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.deleteTable()).toBe(true)
const types: string[] = []
editor.state.doc.forEach((node) => types.push(node.type.name))
expect(types).not.toContain('table')
expect(editor.getMarkdown()).toContain('before')
expect(editor.getMarkdown()).toContain('after')
})
it('a full add-row + add-column + delete-row sequence stays idempotent on re-serialize', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
editor.commands.addRowAfter()
editor.commands.addColumnAfter()
const once = editor.getMarkdown().trim()
const reparsed = new Editor({
extensions: createMarkdownContentExtensions(),
content: once,
contentType: 'markdown',
})
const twice = reparsed.getMarkdown().trim()
reparsed.destroy()
expect(twice).toBe(once)
})
})
@@ -0,0 +1,128 @@
import { useCallback, useState } from 'react'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Columns3,
Rows3,
Table as TableIcon,
Trash2,
} from 'lucide-react'
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
/** Pins the toolbar to the viewport instead of tracking the (often wide) table as it scrolls horizontally. */
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
const APPEND_TO_BODY = () => document.body
interface TableBubbleMenuProps {
editor: Editor
/** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Floating toolbar shown whenever the selection is inside a table: row/column insert-before/after,
* row/column delete, header-row toggle, and delete-table. `@tiptap/extension-table` already exposes
* all of these as editor commands (`addRowBefore`, `addColumnAfter`, …) — this is UI only, no schema
* or serializer change.
*/
export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuProps) {
const [menuKey] = useState(() => new PluginKey('markdownTableMenu'))
const active = useEditorState({
editor,
selector: ({ editor: e }) => ({
headerRow: e.isActive('tableHeader'),
}),
})
// Recomputed on every call (not cached by selection key) — the same table cell can land at a
// different screen position purely from scrolling with no selection change, and Floating UI's
// `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time.
const resolveAnchor = useCallback(() => {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const selection = posToDOMRect(view, from, to)
const viewport = scrollContainerRef.current?.getBoundingClientRect()
const rect =
viewport && selection.top < viewport.top
? new DOMRect(selection.left, viewport.top, selection.width, 0)
: selection
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
}, [editor, scrollContainerRef])
return (
<BubbleMenu
editor={editor}
pluginKey={menuKey}
getReferencedVirtualElement={resolveAnchor}
options={FLOATING_OPTIONS}
appendTo={APPEND_TO_BODY}
role='toolbar'
aria-label='Table editing'
updateDelay={0}
shouldShow={({ editor: e }) => e.isEditable && e.isActive('table')}
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-150 ease-out motion-reduce:animate-none'
>
<ToolbarButton
icon={ArrowUp}
label='Insert row above'
isActive={false}
onClick={() => editor.chain().focus().addRowBefore().run()}
/>
<ToolbarButton
icon={ArrowDown}
label='Insert row below'
isActive={false}
onClick={() => editor.chain().focus().addRowAfter().run()}
/>
<ToolbarButton
icon={Rows3}
label='Delete row'
isActive={false}
onClick={() => editor.chain().focus().deleteRow().run()}
/>
<ToolbarDivider />
<ToolbarButton
icon={ArrowLeft}
label='Insert column left'
isActive={false}
onClick={() => editor.chain().focus().addColumnBefore().run()}
/>
<ToolbarButton
icon={ArrowRight}
label='Insert column right'
isActive={false}
onClick={() => editor.chain().focus().addColumnAfter().run()}
/>
<ToolbarButton
icon={Columns3}
label='Delete column'
isActive={false}
onClick={() => editor.chain().focus().deleteColumn().run()}
/>
<ToolbarDivider />
<ToolbarButton
icon={TableIcon}
label='Toggle header row'
isActive={active.headerRow}
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
/>
<ToolbarButton
icon={Trash2}
label='Delete table'
isActive={false}
onClick={() => editor.chain().focus().deleteTable().run()}
/>
</BubbleMenu>
)
}
@@ -0,0 +1,123 @@
/**
* @vitest-environment jsdom
*
* Integration coverage for the *live* editor stack (`createMarkdownEditorExtensions` — the same
* extension set the real component mounts, including the React node views): raw HTML/footnote
* content renders with its wrapper class and exact source in the DOM (not just parsing correctly
* headlessly), and — the point of holding it as `content: 'text*'` rather than an opaque blob — the
* text inside is genuinely editable via a normal ProseMirror transaction, surviving serialization
* back to markdown.
*/
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
let editor: Editor | null = null
beforeEach(() => {
// The live extension set's placeholder viewport-tracking and suggestion popups use these; jsdom
// lacks them (see keymap.test.ts for the same stub).
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
Element.prototype.scrollIntoView = vi.fn()
document.elementFromPoint = vi.fn(() => null)
})
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(markdown: string): Editor {
return new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content: markdown,
contentType: 'markdown',
})
}
function posOf(ed: Editor, typeName: string): number {
let pos = -1
ed.state.doc.descendants((node, p) => {
if (pos < 0 && node.type.name === typeName) pos = p
})
return pos
}
/** React node views flush on a microtask after mount, so DOM assertions need one tick. */
function nextTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
// The hover "Raw HTML"/"Footnote" badge is rendered by `RawBlockView` through
// `ReactNodeViewRenderer`, which only flushes its React portal once `@tiptap/react`'s
// `contentComponent` is set — that requires mounting through `<EditorContent>` (a real React render
// tree), which this repo's tests don't do for this directory (no `@testing-library/react` installed
// here) and constructing a plain `new Editor()` doesn't provide. What IS verifiable and matters more
// at this level — the node renders with the correct wrapper class and holds the exact raw source
// text — is covered below; the badge itself is decorative chrome, checked manually.
describe('raw markdown snippet node views (live editor)', () => {
it('renders a raw HTML block with the correct wrapper class and exact raw source', async () => {
editor = mount('<details><summary>More</summary>\n\nbody\n\n</details>')
await nextTick()
const el = editor.view.dom
const block = el.querySelector('.raw-markdown-block')
expect(block).not.toBeNull()
expect(block?.textContent).toContain('<details><summary>More</summary>')
})
it('renders a footnote definition block with the correct wrapper class and exact raw source', async () => {
editor = mount('a claim[^1]\n\n[^1]: the source')
await nextTick()
const el = editor.view.dom
const block = el.querySelector('.raw-markdown-block')
expect(block).not.toBeNull()
expect(block?.textContent).toContain('[^1]: the source')
})
it('renders inline raw HTML as a distinct inline chip, not a plain paragraph', async () => {
editor = mount('a <kbd>Ctrl</kbd> b')
await nextTick()
const el = editor.view.dom
const inline = el.querySelector('.raw-markdown-inline')
expect(inline).not.toBeNull()
expect(inline?.textContent).toBe('<kbd>Ctrl</kbd>')
})
it('the raw HTML block text is genuinely editable — a text edit round-trips into the markdown', () => {
editor = mount('<div align="center">\n\ncentered\n\n</div>')
const pos = posOf(editor, 'rawHtmlBlock')
expect(pos).toBeGreaterThanOrEqual(0)
// Insert text right after the opening tag, simulating a user fixing the raw source in place.
const insertAt = pos + '<div align="center">'.length + 1
editor.commands.insertContentAt(insertAt, '!')
expect(editor.getMarkdown()).toContain('<div align="center">!')
})
it('the footnote definition text is genuinely editable', () => {
editor = mount('a claim[^1]\n\n[^1]: old text')
const pos = posOf(editor, 'footnoteDef')
expect(pos).toBeGreaterThanOrEqual(0)
const node = editor.state.doc.nodeAt(pos)
const insertAt = pos + (node?.nodeSize ?? 1) - 1
editor.commands.insertContentAt(insertAt, ' EDITED')
expect(editor.getMarkdown()).toContain('[^1]: old text EDITED')
})
it('a table, a raw HTML block, and a code block all coexist with working node views', async () => {
editor = mount(
'<!-- note -->\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```'
)
await nextTick()
const el = editor.view.dom
expect(el.querySelector('.raw-markdown-block')).not.toBeNull()
expect(el.querySelector('table')).not.toBeNull()
expect(el.querySelector('pre.code-editor-theme')).not.toBeNull()
})
})
@@ -0,0 +1,98 @@
/**
* @vitest-environment jsdom
*
* Parse → serialize round-trip fixtures for the verbatim snippet nodes: raw HTML blocks, HTML
* comments, footnotes (def + ref), and inline raw HTML. Each must reproduce its input byte-for-byte
* and reach a fixpoint on a second pass (see `serializeMarkdownDocument` in `./markdown-parse.ts`).
*/
import { describe, expect, it } from 'vitest'
import { serializeMarkdownDocument } from './markdown-parse'
function roundTrip(input: string): string {
return serializeMarkdownDocument(input).trim()
}
describe('raw markdown snippet nodes', () => {
it('preserves a standalone HTML comment', () => {
const input = '<!-- a note -->\n\ntext'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a multi-line raw HTML block spanning blank lines', () => {
const input = '<details><summary>More</summary>\n\nbody\n\n</details>'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a raw HTML block with attributes', () => {
const input = '<div align="center">\n\ncentered\n\n</div>'
expect(roundTrip(input)).toBe(input)
})
it('preserves a footnote reference and definition', () => {
const input = 'a claim[^1]\n\n[^1]: the source'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves an inline raw HTML tag the schema has no mark/node for', () => {
for (const input of ['a <sub>b</sub> c', 'press <kbd>Ctrl</kbd> now', 'a <mark>hit</mark> b']) {
expect(roundTrip(input)).toBe(input)
}
})
it('leaves recognized inline tags to their real mark (not captured as raw)', () => {
expect(roundTrip('a <em>b</em> c')).toBe('a *b* c')
expect(roundTrip('a <strong>b</strong> c')).toBe('a **b** c')
})
it('leaves a lone <img>/<br> block tag to the stock image/hard-break handling', () => {
expect(roundTrip('<img src="/x.png" alt="a">')).toContain('![a](/x.png)')
})
it('preserves a raw HTML block inside a blockquote', () => {
const input = '> <div>\n>\n> quoted\n>\n> </div>'
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a footnote reference inside a list item', () => {
const input = '- a claim[^1]\n- another line\n\n[^1]: the source'
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('does not interfere with an adjacent table or code block', () => {
const input =
'<!-- note -->\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```'
expect(roundTrip(input)).toBe(input)
})
it('preserves a footnote definition with an indented continuation line', () => {
const input = 'a claim[^1]\n\n[^1]: the source\n continued here'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a footnote definition with a blank line between continuation paragraphs', () => {
const input = 'a claim[^1]\n\n[^1]: first paragraph\n\n second paragraph'
expect(roundTrip(input)).toBe(input)
})
it('does not swallow the next block into a footnote definition without continuation', () => {
const input = 'a claim[^1]\n\n[^1]: the source\n\nafter'
const out = roundTrip(input)
expect(out).toContain('[^1]: the source')
expect(out).toContain('after')
})
it('preserves nested same-tag inline HTML (balanced close, not first-match)', () => {
const input = 'a <span>outer <span>inner</span></span> b'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a self-closing same-name tag nested inside an inline HTML element', () => {
const input = 'a <span>before<span/>after</span> b'
expect(roundTrip(input)).toBe(input)
})
})
@@ -0,0 +1,329 @@
import type { JSONContent, MarkdownToken } from '@tiptap/core'
import { mergeAttributes, Node } from '@tiptap/core'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
/**
* Constructs the schema has no node/mark for: raw HTML blocks (`<div>`, `<details>`, …), HTML
* comments, and footnotes. Before this file, all four made the *entire* document open read-only
* (see {@link isRoundTripSafe in ./round-trip-safety}) because the stock pipeline silently drops
* or mangles them. Each node below instead holds the exact source text as its content and
* re-emits it byte-for-byte on serialize — the same "hold raw source, re-render specially" shape
* `MarkdownCodeBlock` uses for Mermaid (see `./code-block.tsx`), just without the diagram render.
*
* Inline tags already covered by a real mark/node — `em`/`i`, `strong`/`b`, `s`/`del`/`strike`,
* `code`, `a`, `br`, `img` — are deliberately excluded from {@link RawInlineHtml} so they keep
* parsing into their proper mark (e.g. `<em>x</em>` → italic) instead of freezing as raw source.
*/
const HANDLED_INLINE_TAGS = new Set([
'br',
'img',
'em',
'i',
'strong',
'b',
's',
'del',
'strike',
'code',
'a',
])
const VOID_TAGS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])
function verbatimText(node: JSONContent): string {
return (node.content ?? []).map((child) => child.text ?? '').join('')
}
/**
* Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment
* or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in
* `./extensions.ts` works around for tables) — storing it verbatim would double it up with the
* block joiner's own separator, growing by two newlines on every save. Block-level callers trim it;
* inline callers never carry one (inline tokens can't span a blank line), so trimming is a no-op there.
*/
function verbatimParse(raw: string): JSONContent[] {
const trimmed = raw.replace(/\n+$/, '')
return trimmed ? [{ type: 'text', text: trimmed }] : []
}
interface VerbatimNodeOptions {
name: string
/** Whether this node sits among block content (own line) or inline content (mid-paragraph). */
inline: boolean
badgeLabel: string
}
/**
* Shared shape for a node that holds a markdown construct's exact source text and re-emits it
* unchanged — parsing and rendering never inspect or transform the text, so there is nothing for
* these constructs to lose. `markdownTokenName`/`parseMarkdown`/`renderMarkdown` are read directly
* off the returned config by `@tiptap/markdown`'s `MarkdownManager` (see
* `node_modules/@tiptap/markdown/src/MarkdownManager.ts`), independent of the node's `name`.
*/
function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) {
return {
name,
inline,
group: inline ? 'inline' : 'block',
content: 'text*',
marks: '',
code: true,
defining: !inline,
selectable: true,
atom: false,
parseHTML() {
return [
{
tag: `${inline ? 'span' : 'div'}[data-raw-markdown="${name}"]`,
preserveWhitespace: 'full' as const,
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return [
inline ? 'span' : 'div',
mergeAttributes(HTMLAttributes, {
'data-raw-markdown': name,
'data-raw-markdown-label': badgeLabel,
class: inline ? 'raw-markdown-inline' : 'raw-markdown-block',
}),
0,
] as const
},
renderMarkdown(node: JSONContent) {
return verbatimText(node)
},
}
}
/** Block-level raw HTML — `<div>…</div>`, `<details>…</details>`, standalone `<!-- comment -->`, etc.
* Marked's own block tokenizer already classifies all of these as a single `'html'` token
* (`token.block === true`); `@tiptap/markdown`'s parser registry is checked *before* its built-in
* HTML handling for block tokens (unlike inline, see {@link RawInlineHtml}), so claiming the
* `'html'` token name here needs no custom tokenizer. */
const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i
export const RawHtmlBlock = Node.create({
...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }),
markdownTokenName: 'html',
parseMarkdown(token: MarkdownToken) {
if (!token.block) return []
const raw = token.raw ?? token.text ?? ''
if (!raw.trim()) return []
// A lone `<img>`/`<br>` tag block — leave it to the stock path (Image node / hard break),
// matching the same exclusion `round-trip-safety.ts` used to carve out for these two tags.
if (SKIP_BLOCK_HTML_TAGS.test(raw.trim())) return []
return { type: 'rawHtmlBlock', content: verbatimParse(raw) }
},
})
const FOOTNOTE_DEF_HEAD_RE = /^ {0,3}\[\^[^\]]+\]:/
const FOOTNOTE_CONTINUATION_RE = /^ {4,}\S/
/**
* Consume a footnote definition's opening line plus any continuation lines GFM allows — indented by
* ≥4 spaces, optionally with blank lines between them (a multi-paragraph definition). Stops at the
* first line that is neither indented nor blank, and never consumes a blank line that isn't followed
* by further continuation (that blank line belongs to whatever block comes next).
*/
function tokenizeFootnoteDef(src: string): MarkdownToken | undefined {
const lines = src.split('\n')
if (!FOOTNOTE_DEF_HEAD_RE.test(lines[0])) return undefined
let lineCount = 1
while (lineCount < lines.length) {
const line = lines[lineCount]
if (FOOTNOTE_CONTINUATION_RE.test(line)) {
lineCount += 1
continue
}
if (line === '' && FOOTNOTE_CONTINUATION_RE.test(lines[lineCount + 1] ?? '')) {
lineCount += 2
continue
}
break
}
const raw = lines.slice(0, lineCount).join('\n')
return { type: 'footnoteDef', raw, text: raw }
}
/** Footnote definition (`[^id]: the note`, with optional ≥4-space-indented continuation lines) —
* marked has no footnote syntax at all, so without this tokenizer the definition is swallowed as a
* plain paragraph and the reference/definition link is lost. */
export const FootnoteDef = Node.create({
...verbatimNodeConfig({ name: 'footnoteDef', inline: false, badgeLabel: 'Footnote' }),
markdownTokenName: 'footnoteDef',
markdownTokenizer: {
name: 'footnoteDef',
level: 'block' as const,
// Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown`
// auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which
// corrupts the in-progress lexer's shared state (verified directly — every other construct on the
// page silently loses its content once a tokenizer without an explicit `start` is registered).
// The cost is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no
// blank line between them) is picked up on the next block boundary instead of interrupting early.
start: () => -1,
tokenize: tokenizeFootnoteDef,
},
parseMarkdown(token: MarkdownToken) {
const raw = token.raw ?? token.text ?? ''
if (!raw) return []
return { type: 'footnoteDef', content: verbatimParse(raw) }
},
})
const FOOTNOTE_REF_RE = /^\[\^[^\]]+\]/
/** Footnote reference (`text[^id]`) — verbatim passthrough, same rationale as {@link FootnoteDef}. */
export const FootnoteRef = Node.create({
...verbatimNodeConfig({ name: 'footnoteRef', inline: true, badgeLabel: 'Footnote ref' }),
markdownTokenName: 'footnoteRef',
markdownTokenizer: {
name: 'footnoteRef',
level: 'inline' as const,
// See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer.
start: () => -1,
tokenize(src: string) {
const match = FOOTNOTE_REF_RE.exec(src)
if (!match) return undefined
return { type: 'footnoteRef', raw: match[0], text: match[0] }
},
},
parseMarkdown(token: MarkdownToken) {
const raw = token.raw ?? token.text ?? ''
if (!raw) return []
return { type: 'footnoteRef', content: verbatimParse(raw) }
},
})
const RAW_HTML_COMMENT_RE = /^<!--[\s\S]*?-->/
const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i
/**
* Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`,
* tracking nesting depth from `fromIndex` onward so `<span>outer <span>inner</span></span>` consumes
* both levels instead of stopping at the first (inner) `</span>`. Returns -1 if unterminated. A
* nested self-closing same-name tag (`<span/>`) is skipped — it neither opens nor closes a level.
*/
function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number {
const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi')
tagRe.lastIndex = fromIndex
let depth = 1
for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) {
const isClose = match[1] === '/'
const isSelfClosing = Boolean(match[2])
if (isSelfClosing) continue
if (isClose) {
depth -= 1
if (depth === 0) return match.index + match[0].length
} else {
depth += 1
}
}
return -1
}
/**
* Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single
* void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema
* already has a real mark/node for ({@link HANDLED_INLINE_TAGS}) so it keeps parsing normally, and
* for an unterminated open tag (rare/malformed input — falls back to the stock, lossy behavior
* rather than risk mis-consuming the rest of the document).
*/
function tokenizeRawInlineHtml(src: string): MarkdownToken | undefined {
const comment = RAW_HTML_COMMENT_RE.exec(src)
if (comment) return { type: 'rawInlineHtml', raw: comment[0], text: comment[0] }
const open = OPEN_TAG_RE.exec(src)
if (!open) return undefined
const tag = open[1].toLowerCase()
if (HANDLED_INLINE_TAGS.has(tag)) return undefined
if (open[2] || VOID_TAGS.has(tag)) {
return { type: 'rawInlineHtml', raw: open[0], text: open[0] }
}
const end = findBalancedCloseEnd(src, tag, open[0].length)
if (end < 0) return undefined
const raw = src.slice(0, end)
return { type: 'rawInlineHtml', raw, text: raw }
}
/** Inline raw HTML — `<kbd>`, `<sub>`, `<mark>`, `<span>`, `<u>` (no Underline mark is registered),
* and any other tag this schema has no mark/node for, plus an inline-position HTML comment. Marked
* classifies inline HTML as its own `'html'` token type, and `@tiptap/markdown`'s inline parser
* hardcodes handling for that type *before* checking its extension registry (unlike block tokens) —
* so claiming it here needs a custom tokenizer, registered under a different token name
* (`rawInlineHtml`) so it's never confused with the stock `'html'` inline path. marked.js runs
* custom extension tokenizers before its own built-ins at both block and inline level (see
* `blockTokens`/`inlineTokens` in `node_modules/marked/lib/marked.esm.js`), so this reliably wins
* the race against marked's default inline HTML/tag tokenizer. */
export const RawInlineHtml = Node.create({
...verbatimNodeConfig({ name: 'rawInlineHtml', inline: true, badgeLabel: 'Raw HTML' }),
markdownTokenName: 'rawInlineHtml',
markdownTokenizer: {
name: 'rawInlineHtml',
level: 'inline' as const,
// See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer.
start: () => -1,
tokenize: tokenizeRawInlineHtml,
},
parseMarkdown(token: MarkdownToken) {
const raw = token.raw ?? token.text ?? ''
if (!raw) return []
return { type: 'rawInlineHtml', content: verbatimParse(raw) }
},
})
const BLOCK_CONTROL_CLASS =
'pointer-events-none absolute top-1.5 right-2 select-none rounded-md bg-[var(--surface-4)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] uppercase tracking-wide opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100'
/** Badge text per block node type name — kept here rather than threaded through node options since
* {@link NodeViewProps} exposes no options/extension reference to the rendering component. */
const BLOCK_BADGE_LABEL: Record<string, string> = {
rawHtmlBlock: 'Raw HTML',
footnoteDef: 'Footnote',
}
function RawBlockView({ node }: ReactNodeViewProps) {
const label = BLOCK_BADGE_LABEL[node.type.name] ?? 'Raw'
return (
<NodeViewWrapper className='group relative'>
<span className={BLOCK_CONTROL_CLASS} contentEditable={false}>
{label}
</span>
<div className='raw-markdown-block'>
<NodeViewContent<'span'> as='span' />
</div>
</NodeViewWrapper>
)
}
/** Live variant of {@link RawHtmlBlock} with a hover "Raw HTML" badge — same schema/serializer. */
export const RawHtmlBlockWithView = RawHtmlBlock.extend({
addNodeView() {
return ReactNodeViewRenderer(RawBlockView)
},
})
/** Live variant of {@link FootnoteDef} with a hover "Footnote" badge — same schema/serializer. */
export const FootnoteDefWithView = FootnoteDef.extend({
addNodeView() {
return ReactNodeViewRenderer(RawBlockView)
},
})
@@ -277,6 +277,29 @@
line-height: 21px;
}
/* Raw, unrendered markdown constructs the schema has no real node/mark for (raw HTML blocks,
comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts).
Styled distinctly (monospace, tinted) so it's clear this text isn't interpreted, unlike a code block. */
.rich-markdown-prose .raw-markdown-block,
.rich-markdown-prose .raw-markdown-inline {
font-family: var(--font-martian-mono, ui-monospace, monospace);
font-size: 0.875em;
color: var(--text-muted);
background: color-mix(in srgb, var(--warning, orange) 8%, var(--surface-5));
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.rich-markdown-prose .raw-markdown-block {
border-radius: 8px;
padding: 0.75rem 1rem;
}
.rich-markdown-prose .raw-markdown-inline {
border-radius: 4px;
padding: 0.0625rem 0.3rem;
}
.rich-markdown-prose hr {
border: none;
border-top: 1px solid var(--divider);
@@ -24,6 +24,7 @@ import { parseMarkdownToDoc } from './markdown-parse'
import { useEditorMentions } from './mention'
import { EditorBubbleMenu } from './menus/bubble-menu'
import { LinkHoverCard } from './menus/link-hover-card'
import { TableBubbleMenu } from './menus/table-menu'
import { normalizeMarkdownContent } from './normalize-content'
import { isRoundTripSafe } from './round-trip-safety'
import '@sim/emcn/components/code/code.css'
@@ -413,6 +414,15 @@ export function LoadedRichMarkdownEditor({
emitUpdate: false,
})
}
// `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a
// select-all survives as "select everything," permanently painting every divider/image with the
// `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run
// on every settle regardless of whether `setContent` ran just above: the last streaming tick
// already syncs `lastSyncedBodyRef` to the final body before settle, so `body` usually already
// equals it here — collapsing only inside that `if` would skip the common streamed-content case
// entirely. `setTextSelection` (not `.focus()`) so this never steals DOM focus from whatever the
// user is doing outside the editor.
editor.commands.setTextSelection(editor.state.doc.content.size)
editor.setEditable(canEdit && settledRef.current.verdict)
if (isInitialSettle && autoFocus) editor.commands.focus('end')
return
@@ -433,6 +443,7 @@ export function LoadedRichMarkdownEditor({
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
>
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <TableBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard editor={editor} />}
<input
ref={imageInputRef}
@@ -37,11 +37,11 @@ describe('isRoundTripSafe', () => {
expect(isRoundTripSafe('> ```\n> code\n> ```')).toBe(true)
})
it('rejects stable-loss constructs the idempotency probe cannot see', () => {
expect(isRoundTripSafe('text[^1]\n\n[^1]: the note')).toBe(false)
expect(isRoundTripSafe('<!-- a note -->\n\ntext')).toBe(false)
expect(isRoundTripSafe('<details><summary>x</summary>body</details>')).toBe(false)
expect(isRoundTripSafe('a <sub>b</sub> c')).toBe(false)
it('preserves footnotes, HTML comments, and raw HTML tags via the verbatim snippet nodes', () => {
expect(isRoundTripSafe('text[^1]\n\n[^1]: the note')).toBe(true)
expect(isRoundTripSafe('<!-- a note -->\n\ntext')).toBe(true)
expect(isRoundTripSafe('<details><summary>x</summary>body</details>')).toBe(true)
expect(isRoundTripSafe('a <sub>b</sub> c')).toBe(true)
})
it('rejects a hard break inside a heading (serializer splits the heading)', () => {
@@ -263,15 +263,19 @@ describe('editability gate — realistic documents stay editable', () => {
})
// The flip side and exact boundary of the gate: constructs the WYSIWYG schema genuinely cannot
// represent open read-only so an edit can't silently corrupt them.
// represent open read-only so an edit can't silently corrupt them. Raw HTML blocks, comments, and
// footnotes used to be the canonical examples here — `./raw-markdown-snippet.ts` now holds each
// verbatim (including a multi-line block spanning blank lines, via the same `NON_CHUNKABLE`
// whole-document parse path `markdown-parse.ts` already uses for these constructs), so they moved
// to the "preserved" test above instead of staying here.
describe('editability gate — genuinely lossy constructs open read-only', () => {
it('raw HTML blocks (<details>, <div align>) open read-only', () => {
expect(isRoundTripSafe('<details><summary>More</summary>\n\nbody\n\n</details>')).toBe(false)
expect(isRoundTripSafe('<div align="center">\n\ncentered\n\n</div>')).toBe(false)
it('raw HTML blocks (<details>, <div align>) are preserved verbatim, not locked read-only', () => {
expect(isRoundTripSafe('<details><summary>More</summary>\n\nbody\n\n</details>')).toBe(true)
expect(isRoundTripSafe('<div align="center">\n\ncentered\n\n</div>')).toBe(true)
})
it('HTML comments and footnotes open read-only', () => {
expect(isRoundTripSafe('<!-- TODO: revise -->\n\ntext')).toBe(false)
expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(false)
it('HTML comments and footnotes are preserved verbatim, not locked read-only', () => {
expect(isRoundTripSafe('<!-- TODO: revise -->\n\ntext')).toBe(true)
expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(true)
})
})
@@ -15,12 +15,11 @@ const PROBE_SIZE_LIMIT = 256 * 1024
* (Linked images `[![alt](img)](href)` are handled by the image node and verified separately by
* the link-count check in {@link isRoundTripSafe}, not here.)
*
* - **Footnote** `[^id]` — not in the schema; the reference and definition serialize to escaped
* literal text, breaking the footnote.
* - **HTML comment** `<!-- … -->` — dropped entirely.
* - **Raw HTML tag** `<div>`, `<details>`, `<kbd>`, … — StarterKit has no HTML node, so the tag
* is stripped (content kept, structure lost). `<br>` and `<img>` are excluded: `<br>` outside a
* table converts to a hard break, and `<img>` is a first-class (resizable) image node.
* Footnotes, HTML comments, and raw HTML tags (`<div>`, `<details>`, `<kbd>`, …) used to be listed
* here — the schema had no node for any of them, so they were dropped or stripped (content kept,
* structure lost). `./raw-markdown-snippet.ts` now holds each construct's exact source text and
* re-emits it byte-for-byte, so none of them lose data on round-trip and none need a pattern below.
*
* - **`<br>` inside a table cell** — a GFM cell can't hold a real line break, so the serializer
* flattens `one<br>two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `<br>`.
* - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits
@@ -30,9 +29,6 @@ const PROBE_SIZE_LIMIT = 256 * 1024
* `&` with no `;` is left alone (it re-renders identically, so it's harmless churn).
*/
const STABLE_LOSS_PATTERNS: ReadonlyArray<RegExp> = [
/\[\^[^\]]+]/,
/<!--/,
/<\/?(?!(?:br|img)\b)[a-z][a-z0-9-]*(\s[^>]*)?\/?>/i,
/^(?=(?:[^\n]*\|){2})[^\n]*<br\s*\/?>/im,
/^#{1,6}\s.*(?: {2,}|\\)$/m,
/&(?!(?:amp|lt|gt);)(?:#x?[0-9a-f]+|[a-z][a-z0-9]*);/i,
@@ -0,0 +1,57 @@
/**
* @vitest-environment jsdom
*
* A select-all (`AllSelection`) maps through a whole-document `setContent` replace as "select
* everything in the new doc" — a real, non-empty range. If that range happens to sweep a divider or
* image, `keymap.ts`'s `richLeafSelectionHighlight` decoration paints it with `rich-leaf-in-selection`
* (a thick highlight, see `rich-markdown-editor.css`), and nothing collapses it afterward. This is
* exactly what `rich-markdown-editor.tsx`'s stream-settle handler works around by calling
* `editor.commands.setTextSelection(editor.state.doc.content.size)` after every settle-time
* `setContent` — this test locks in that the fix actually collapses the selection.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { parseMarkdownToDoc } from './markdown-parse'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
describe('stream-settle selection collapse', () => {
it('a select-all survives a whole-document setContent as a non-empty range', () => {
editor = new Editor({
extensions: createMarkdownContentExtensions(),
content: '# Title\n\n---\n\nbody',
contentType: 'markdown',
})
editor.commands.selectAll()
expect(editor.state.selection.empty).toBe(false)
editor.commands.setContent(parseMarkdownToDoc('# New title\n\n---\n\nnew body'), {
contentType: 'json',
emitUpdate: false,
})
// The bug: without an explicit selection reset, the mapped select-all still spans the new doc.
expect(editor.state.selection.empty).toBe(false)
})
it('setTextSelection(doc size) after setContent collapses the selection (the fix)', () => {
editor = new Editor({
extensions: createMarkdownContentExtensions(),
content: '# Title\n\n---\n\nbody',
contentType: 'markdown',
})
editor.commands.selectAll()
editor.commands.setContent(parseMarkdownToDoc('# New title\n\n---\n\nnew body'), {
contentType: 'json',
emitUpdate: false,
})
editor.commands.setTextSelection(editor.state.doc.content.size)
expect(editor.state.selection.empty).toBe(true)
})
})