')).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 = '
a
b
1
2
'
+ 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',
+ '
just a normal sentence with no syntax
'
+ )
+ ).toBe(false)
})
it('keeps pasted markdown literal inside a code block', () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts
index 808c0fa377e..48bca04aceb 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts
@@ -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
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts
new file mode 100644
index 00000000000..8c4346499e5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts
@@ -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)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx
new file mode 100644
index 00000000000..e62fa8fdcb0
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx
@@ -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
+}
+
+/**
+ * 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 (
+ 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'
+ >
+ editor.chain().focus().addRowBefore().run()}
+ />
+ editor.chain().focus().addRowAfter().run()}
+ />
+ editor.chain().focus().deleteRow().run()}
+ />
+
+ editor.chain().focus().addColumnBefore().run()}
+ />
+ editor.chain().focus().addColumnAfter().run()}
+ />
+ editor.chain().focus().deleteColumn().run()}
+ />
+
+ editor.chain().focus().toggleHeaderRow().run()}
+ />
+ editor.chain().focus().deleteTable().run()}
+ />
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts
new file mode 100644
index 00000000000..18afe06fd51
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts
@@ -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 {
+ 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 `` (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('More\n\nbody\n\n')
+ await nextTick()
+ const el = editor.view.dom
+ const block = el.querySelector('.raw-markdown-block')
+ expect(block).not.toBeNull()
+ expect(block?.textContent).toContain('More')
+ })
+
+ 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 Ctrl b')
+ await nextTick()
+ const el = editor.view.dom
+ const inline = el.querySelector('.raw-markdown-inline')
+ expect(inline).not.toBeNull()
+ expect(inline?.textContent).toBe('Ctrl')
+ })
+
+ it('the raw HTML block text is genuinely editable — a text edit round-trips into the markdown', () => {
+ editor = mount('
\n\ncentered\n\n
')
+ 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 + '
!')
+ })
+
+ 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(
+ '\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()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts
new file mode 100644
index 00000000000..e086d0dc27e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts
@@ -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 = '\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 = 'More\n\nbody\n\n'
+ expect(roundTrip(input)).toBe(input)
+ expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
+ })
+
+ it('preserves a raw HTML block with attributes', () => {
+ const input = '
\n\ncentered\n\n
'
+ 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 b c', 'press Ctrl now', 'a hit b']) {
+ expect(roundTrip(input)).toBe(input)
+ }
+ })
+
+ it('leaves recognized inline tags to their real mark (not captured as raw)', () => {
+ expect(roundTrip('a b c')).toBe('a *b* c')
+ expect(roundTrip('a b c')).toBe('a **b** c')
+ })
+
+ it('leaves a lone / block tag to the stock image/hard-break handling', () => {
+ expect(roundTrip('')).toContain('')
+ })
+
+ it('preserves a raw HTML block inside a blockquote', () => {
+ const input = '>
\n>\n> quoted\n>\n>
'
+ 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 =
+ '\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 outer inner 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 beforeafter b'
+ expect(roundTrip(input)).toBe(input)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx
new file mode 100644
index 00000000000..9081fe3fa7c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx
@@ -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 (`
`, ``, …), 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. `x` → 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 }) {
+ 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 — `
…
`, `…`, standalone ``, 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 ``/` ` 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 = /^/
+
+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 `outer inner` consumes
+ * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A
+ * nested self-closing same-name tag (``) 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 — ``, ``, ``, ``, `` (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 = {
+ rawHtmlBlock: 'Raw HTML',
+ footnoteDef: 'Footnote',
+}
+
+function RawBlockView({ node }: ReactNodeViewProps) {
+ const label = BLOCK_BADGE_LABEL[node.type.name] ?? 'Raw'
+ return (
+
+
+ {label}
+
+
+ as='span' />
+
+
+ )
+}
+
+/** 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)
+ },
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
index 84d727723b3..febdb83e3aa 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
@@ -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);
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
index 52f8982c1bd..b141d8bab64 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
@@ -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 && }
+ {editor && }
{editor && }
{
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('\n\ntext')).toBe(false)
- expect(isRoundTripSafe('xbody')).toBe(false)
- expect(isRoundTripSafe('a b 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('\n\ntext')).toBe(true)
+ expect(isRoundTripSafe('xbody')).toBe(true)
+ expect(isRoundTripSafe('a b 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 (,
) open read-only', () => {
- expect(isRoundTripSafe('More\n\nbody\n\n')).toBe(false)
- expect(isRoundTripSafe('
\n\ncentered\n\n
')).toBe(false)
+ it('raw HTML blocks (,
) are preserved verbatim, not locked read-only', () => {
+ expect(isRoundTripSafe('More\n\nbody\n\n')).toBe(true)
+ expect(isRoundTripSafe('
\n\ncentered\n\n
')).toBe(true)
})
- it('HTML comments and footnotes open read-only', () => {
- expect(isRoundTripSafe('\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('\n\ntext')).toBe(true)
+ expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(true)
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts
index a3d473b3193..423669de7c8 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts
@@ -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** `
`, ``, ``, … — StarterKit has no HTML node, so the tag
- * is stripped (content kept, structure lost). ` ` and `` are excluded: ` ` outside a
- * table converts to a hard break, and `` is a first-class (resizable) image node.
+ * Footnotes, HTML comments, and raw HTML tags (`
`, ``, ``, …) 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.
+ *
* - **` ` inside a table cell** — a GFM cell can't hold a real line break, so the serializer
* flattens `one two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a ` `.
* - **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 = [
- /\[\^[^\]]+]/,
- /