mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-12 23:33:47 +08:00
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:
+3
@@ -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,
|
||||
|
||||
+7
@@ -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,
|
||||
]
|
||||
|
||||
+25
-2
@@ -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', () => {
|
||||
|
||||
+9
-4
@@ -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
|
||||
|
||||
+131
@@ -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)
|
||||
})
|
||||
})
|
||||
+128
@@ -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>
|
||||
)
|
||||
}
|
||||
+123
@@ -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()
|
||||
})
|
||||
})
|
||||
+98
@@ -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('')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
+329
@@ -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)
|
||||
},
|
||||
})
|
||||
+23
@@ -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);
|
||||
|
||||
+11
@@ -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}
|
||||
|
||||
+16
-12
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+5
-9
@@ -15,12 +15,11 @@ const PROBE_SIZE_LIMIT = 256 * 1024
|
||||
* (Linked images `[](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,
|
||||
|
||||
+57
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user