diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 205da75a97d..01a2e2c82dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -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, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index c99abf36c1c..1f29545bebb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -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, ] diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index 249f26512f5..3311dd5ea52 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -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', '

heading

')).toBe(false) + expect(paste(editor, '# heading', '

heading

')).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 = '
ab
12
' + 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 + '
'.length + 1 + editor.commands.insertContentAt(insertAt, '!') + expect(editor.getMarkdown()).toContain('
!') + }) + + 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('a')).toContain('![a](/x.png)') + }) + + 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 `[![alt](img)](href)` are handled by the image node and verified separately by * the link-count check in {@link isRoundTripSafe}, not here.) * - * - **Footnote** `[^id]` — not in the schema; the reference and definition serialize to escaped - * literal text, breaking the footnote. - * - **HTML comment** `` — dropped entirely. - * - **Raw HTML tag** `
`, `
`, ``, … — 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 = [ - /\[\^[^\]]+]/, - /