From 9ecb00e1430de0e7b04f5bb7a45f2f21c0d1f6d8 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 17:32:25 -0700 Subject: [PATCH 01/41] feat(files): inline rich markdown editor Replace the raw/preview split for markdown files with a Linear-style inline WYSIWYG editor (TipTap/ProseMirror): bubble + slash menus, code-block language picker with Prism highlighting and line-wrap, resizable images (HTML ), GFM tables, and frontmatter held byte-exact out of band. A round-trip preflight gate (decided once per open) falls back to the raw Monaco editor for any file that can't be edited losslessly, so the rich editor never silently corrupts a file. --- .../resource-header/resource-header.tsx | 36 ++- .../components/file-viewer/file-viewer.tsx | 31 ++ .../files/components/file-viewer/index.ts | 8 +- .../rich-markdown-editor/code-block.tsx | 160 ++++++++++ .../code-highlight.test.ts | 43 +++ .../rich-markdown-editor/code-highlight.ts | 124 ++++++++ .../detect-language.test.ts | 25 ++ .../rich-markdown-editor/detect-language.ts | 49 +++ .../rich-markdown-editor/extensions.ts | 101 ++++++ .../rich-markdown-editor/image.tsx | 141 +++++++++ .../rich-markdown-editor/keymap.ts | 65 ++++ .../rich-markdown-editor/markdown-fidelity.ts | 62 ++++ .../markdown-file-editor.tsx | 102 +++++++ .../menus/bubble-menu.tsx | 289 ++++++++++++++++++ .../rich-markdown-editor.css | 256 ++++++++++++++++ .../rich-markdown-editor.tsx | 217 +++++++++++++ .../round-trip-safety.test.ts | 88 ++++++ .../rich-markdown-editor/round-trip-safety.ts | 89 ++++++ .../rich-markdown-editor/round-trip.test.ts | 218 +++++++++++++ .../slash-command/commands.test.ts | 51 ++++ .../slash-command/commands.ts | 147 +++++++++ .../slash-command/slash-command-list.tsx | 129 ++++++++ .../slash-command/slash-command.ts | 111 +++++++ .../file-viewer/text-editor-state.test.ts | 8 + .../file-viewer/text-editor-state.ts | 6 +- .../components/file-viewer/text-editor.tsx | 143 ++------- .../file-viewer/use-editable-file-content.ts | 192 ++++++++++++ .../workspace/[workspaceId]/files/files.tsx | 11 +- .../mothership-view/mothership-view.tsx | 4 + apps/sim/hooks/use-autosave.ts | 15 +- apps/sim/package.json | 12 + bun.lock | 122 +++++++- 32 files changed, 2911 insertions(+), 144 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index b544c525cae..d167783d8c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -379,26 +379,50 @@ function BreadcrumbLocationPopover({ }: BreadcrumbLocationPopoverProps) { const [open, setOpen] = useState(false) const closeTimeoutRef = useRef | null>(null) + /** + * Suppresses reopen for the brief window between a click-to-navigate and the + * route swap. Navigating away tears this popover down (the list and detail + * views render different subtrees), so if `open` were still true the dimming + * veil and popover content would snap away instead of fading — a visible + * flash. {@link navigateAndClose} closes the popover before running the + * crumb's handler and latches this so the pointer still resting on the + * trigger can't re-fire `openPopover` mid-navigation. It is cleared on the + * next pointer/focus exit so the popover keeps working when the handler does + * not actually navigate (e.g. an unsaved-changes guard that opens a modal). + */ + const navigatingRef = useRef(false) const rootBreadcrumb = breadcrumbs[0] - const openPopover = () => { + const clearCloseTimeout = () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current) closeTimeoutRef.current = null } + } + + const openPopover = () => { + if (navigatingRef.current) return + clearCloseTimeout() setOpen(true) } const scheduleClose = () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - } + navigatingRef.current = false + clearCloseTimeout() closeTimeoutRef.current = setTimeout(() => { setOpen(false) closeTimeoutRef.current = null }, 120) } + const navigateAndClose = (onClick?: () => void) => { + if (!onClick) return + navigatingRef.current = true + clearCloseTimeout() + setOpen(false) + onClick() + } + useEffect(() => { return () => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) @@ -413,7 +437,7 @@ function BreadcrumbLocationPopover({ + + + {LANGUAGE_OPTIONS.map((option) => ( + + updateAttributes({ language: option.value === PLAIN ? null : option.value }) + } + > + {option.label} + + ))} + + + + + +
+         as='code' />
+      
+ + ) +} + +function codeBlockText(node: JSONContent): string { + return (node.content ?? []).map((child) => child.text ?? '').join('') +} + +/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */ +function fenceFor(text: string): string { + const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length)) + return '`'.repeat(Math.max(3, longestRun + 1)) +} + +/** + * Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code + * block that itself contains a ``` line round-trips instead of shattering. Shared by the test + * (plain) and live ({@link CodeBlockWithLanguage}) paths. + */ +export const MarkdownCodeBlock = CodeBlock.extend({ + renderMarkdown: (node: JSONContent) => { + const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '' + const text = codeBlockText(node) + const fence = fenceFor(text) + return `${fence}${language}\n${text}\n${fence}` + }, +}) + +/** + * Code block with hover-revealed controls (language picker, line-wrap toggle, copy), in the + * style of Linear's editor. The `language` attribute drives {@link CodeBlockHighlight}'s Prism + * highlighting and serializes to the ```lang fence on save; wrap is a view-only preference. + */ +export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts new file mode 100644 index 00000000000..e970a74e5f9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { buildDecorations } from './code-highlight' +import { createMarkdownContentExtensions } from './extensions' + +let editor: Editor | null = null + +function decorationClassesFor(markdown: string): string[] { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + const decorations = buildDecorations(editor.state.doc).find() + editor.destroy() + editor = null + return decorations.map( + (decoration) => + (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class + ) +} + +afterEach(() => { + editor?.destroy() + editor = null +}) + +describe('code block syntax highlighting', () => { + it('emits Prism token decorations for a known language', () => { + const classes = decorationClassesFor('```js\nconst x = 1\n```') + expect(classes.length).toBeGreaterThan(0) + expect(classes.every((c) => c.startsWith('token'))).toBe(true) + expect(classes.some((c) => c.includes('keyword'))).toBe(true) + }) + + it('does not decorate plain prose', () => { + expect(decorationClassesFor('just some text')).toHaveLength(0) + }) + + it('does not decorate an unregistered language', () => { + expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts new file mode 100644 index 00000000000..90899bd7ed0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts @@ -0,0 +1,124 @@ +import { Extension } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import Prism, { type Token, type TokenStream } from 'prismjs' +import 'prismjs/components/prism-bash' +import 'prismjs/components/prism-css' +import 'prismjs/components/prism-markup' +import 'prismjs/components/prism-javascript' +import 'prismjs/components/prism-typescript' +import 'prismjs/components/prism-yaml' +import 'prismjs/components/prism-sql' +import 'prismjs/components/prism-python' +import 'prismjs/components/prism-json' +import { detectLanguage } from './detect-language' + +const KEY = new PluginKey('codeBlockHighlight') + +function tokenClasses(token: Token): string { + const classes = ['token', token.type] + if (token.alias) classes.push(...(Array.isArray(token.alias) ? token.alias : [token.alias])) + return classes.join(' ') +} + +/** + * Walks Prism's token tree, emitting one inline decoration per token over its text range. + * Nested tokens stack (ProseMirror nests overlapping inline decorations), reproducing the + * `.token`-class structure Prism would render as HTML. + */ +function collectTokenDecorations( + stream: TokenStream, + base: number, + offset: { value: number }, + decorations: Decoration[], + limit: number +) { + const tokens = Array.isArray(stream) ? stream : [stream] + for (const token of tokens) { + if (typeof token === 'string') { + offset.value += token.length + continue + } + const start = offset.value + collectTokenDecorations(token.content, base, offset, decorations, limit) + const from = base + start + const to = Math.min(base + offset.value, limit) + if (to > from) decorations.push(Decoration.inline(from, to, { class: tokenClasses(token) })) + } +} + +export function buildDecorations(doc: ProseMirrorNode): DecorationSet { + const decorations: Decoration[] = [] + doc.descendants((node, pos) => { + if (node.type.name !== 'codeBlock') return + const language = (node.attrs.language as string | null) ?? detectLanguage(node.textContent) + const grammar = language ? Prism.languages[language] : undefined + if (!grammar) return + // Defensive: a malformed grammar or a token/position mismatch must never throw here — a throw + // in the decorations plugin blanks the whole editor. The `limit` clamps any over-long token. + try { + const base = pos + 1 + collectTokenDecorations( + Prism.tokenize(node.textContent, grammar), + base, + { value: 0 }, + decorations, + base + node.content.size + ) + } catch {} + }) + return DecorationSet.create(doc, decorations) +} + +/** + * Whether the transaction's changed ranges intersect any code block in the new doc — including + * a `setNodeMarkup` language change (whose step range covers the node). When false, the cheap + * path just maps existing decorations instead of re-tokenizing. + */ +function changeTouchesCodeBlock(tr: Transaction, doc: ProseMirrorNode): boolean { + let touches = false + for (const map of tr.mapping.maps) { + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + if (touches) return + const from = Math.max(0, Math.min(newStart, doc.content.size)) + const to = Math.max(from, Math.min(newEnd, doc.content.size)) + doc.nodesBetween(from, to, (node) => { + if (node.type.name === 'codeBlock') touches = true + return !touches + }) + }) + } + return touches +} + +/** + * Syntax-highlights fenced code blocks with Prism, emitting the same `.token` classes the + * rest of the app uses so the `code-editor-theme` styles (light + dark) apply unchanged. + * Re-tokenizes only when a change actually touches a code block (typing in prose just maps + * the existing decorations), keeping the cost off the common keystroke path. + */ +export const CodeBlockHighlight = Extension.create({ + name: 'codeBlockHighlight', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: KEY, + state: { + init: (_, { doc }) => buildDecorations(doc), + apply: (tr, current) => { + if (tr.steps.length === 0) return current + if (!changeTouchesCodeBlock(tr, tr.doc)) return current.map(tr.mapping, tr.doc) + return buildDecorations(tr.doc) + }, + }, + props: { + decorations(state) { + return KEY.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts new file mode 100644 index 00000000000..f4c6939e242 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { detectLanguage } from './detect-language' + +describe('detectLanguage', () => { + it('returns null for empty or unrecognizable content', () => { + expect(detectLanguage('')).toBeNull() + expect(detectLanguage(' \n ')).toBeNull() + expect(detectLanguage('just some prose words here')).toBeNull() + }) + + it('detects common languages from content shape', () => { + expect(detectLanguage('{\n "a": 1,\n "b": [2, 3]\n}')).toBe('json') + expect(detectLanguage('const x = 1\nfunction go() {}')).toBe('javascript') + expect(detectLanguage('interface Foo { name: string }')).toBe('typescript') + expect(detectLanguage('def main():\n print("hi")')).toBe('python') + expect(detectLanguage('SELECT id FROM users WHERE id = 1')).toBe('sql') + expect(detectLanguage('#!/bin/bash\necho hello')).toBe('bash') + expect(detectLanguage('
hi
')).toBe('markup') + expect(detectLanguage('.btn { color: red; padding: 4px }')).toBe('css') + }) + + it('does not misclassify a JS object as JSON', () => { + expect(detectLanguage('const x = { a: 1 }')).toBe('javascript') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts new file mode 100644 index 00000000000..525b9cb9772 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts @@ -0,0 +1,49 @@ +/** + * Heuristic language detection for a fenced code block that has no explicit ` ```lang ` tag. + * Used only to drive syntax highlighting + the picker label — the detected value is NEVER + * written back to the markdown, so opening a file never mutates it. Restricted to the grammars + * {@link CodeBlockHighlight} actually registers with Prism; returns `null` when unsure. + */ +const DETECTORS: ReadonlyArray<{ language: string; test: RegExp }> = [ + { language: 'markup', test: /<\/?[a-z][\w-]*(\s[^>]*)?\/?>/i }, + { + language: 'sql', + test: /\b(?:select\s+[\w*]|insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+table)/i, + }, + { language: 'python', test: /^\s*(def|class)\s+\w+|^\s*(import|from)\s+\w|\bprint\(|\belif\b/m }, + { + language: 'bash', + test: /^#!.*\b(ba)?sh\b|^\s*(sudo|apt|brew|npm|yarn|bun|git|cd|echo|export|chmod|mkdir)\s|\$\(/m, + }, + { + language: 'typescript', + test: /\b(interface|type)\s+\w+\s*[={]|:\s*(string|number|boolean)\b|\bimport\s+type\b|\bas\s+\w+\s*;/, + }, + { + language: 'javascript', + test: /\b(const|let|var|function)\s|=>|console\.\w+|\brequire\(|\bexport\s+(default|const)\b/, + }, + { language: 'css', test: /[.#]?[\w-]+\s*\{[^}]*[\w-]+\s*:[^};]+;?[^}]*\}/ }, + { language: 'yaml', test: /^[\w-]+:\s+\S/m }, +] + +function looksLikeJson(sample: string): boolean { + const trimmed = sample.trim() + if (!/^[[{]/.test(trimmed)) return false + try { + JSON.parse(trimmed) + return true + } catch { + return false + } +} + +export function detectLanguage(code: string): string | null { + const sample = code.slice(0, 2000) + if (!sample.trim()) return null + if (looksLikeJson(sample)) return 'json' + for (const { language, test } of DETECTORS) { + if (test.test(sample)) return language + } + return null +} 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 new file mode 100644 index 00000000000..2302e7e756f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -0,0 +1,101 @@ +import type { Extensions, JSONContent, MarkdownRendererHelpers } from '@tiptap/core' +import { Code } from '@tiptap/extension-code' +import { TaskItem, TaskList } from '@tiptap/extension-list' +import Placeholder from '@tiptap/extension-placeholder' +import { + renderTableToMarkdown, + Table, + TableCell, + TableHeader, + TableRow, +} from '@tiptap/extension-table' +import { Markdown } from '@tiptap/markdown' +import StarterKit from '@tiptap/starter-kit' +import { CodeBlockWithLanguage, MarkdownCodeBlock } from './code-block' +import { CodeBlockHighlight } from './code-highlight' +import { MarkdownImage, ResizableImage } from './image' +import { EditorKeymap } from './keymap' +import { SlashCommand } from './slash-command/slash-command' + +/** + * Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`). + * The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and + * makes the bubble-menu toggles silently no-op over a code selection. + */ +const InlineCode = Code.extend({ excludes: '' }) + +/** + * Table that escapes interior `|` characters when serializing cells. The upstream serializer + * joins cells with `|` without escaping, so a cell containing a literal pipe silently splits + * into phantom columns on round-trip (data loss). Escaping must happen on the `table` node — + * `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. + */ +const PipeSafeTable = Table.extend({ + renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => + renderTableToMarkdown(node, { + ...h, + renderChildren: (nodes, separator) => + h.renderChildren(nodes, separator).replace(/\|/g, '\\|'), + }), +}) + +interface MarkdownEditorExtensionOptions { + placeholder: string +} + +interface ContentExtensionOptions { + /** Use the React node views (code-block language picker, image resize). Off for headless tests. */ + nodeViews?: boolean +} + +/** + * The schema + serialization extensions: the nodes/marks the document can contain and the + * Markdown ⇄ ProseMirror conversion. `StarterKit` provides core nodes/marks and the + * Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add + * `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown. + * + * The code block is the standalone `CodeBlock` so the live editor can swap in a node view; + * the schema and markdown output are identical either way. + */ +export function createMarkdownContentExtensions({ + nodeViews = false, +}: ContentExtensionOptions = {}): Extensions { + const codeBlock = (nodeViews ? CodeBlockWithLanguage : MarkdownCodeBlock).configure({ + HTMLAttributes: { class: 'code-editor-theme' }, + }) + return [ + StarterKit.configure({ + link: { openOnClick: false }, + underline: false, + codeBlock: false, + code: false, + }), + InlineCode, + codeBlock, + (nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }), + TaskList, + TaskItem.configure({ nested: true }), + PipeSafeTable.configure({ resizable: true }), + TableRow, + TableHeader, + TableCell, + Markdown, + ] +} + +/** + * The full extension set for the live editor: the content extensions plus the UI-only + * extensions — `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), and + * `Placeholder`. + */ +export function createMarkdownEditorExtensions({ + placeholder, +}: MarkdownEditorExtensionOptions): Extensions { + return [ + ...createMarkdownContentExtensions({ nodeViews: true }), + CodeBlockHighlight, + SlashCommand, + EditorKeymap, + Placeholder.configure({ placeholder }), + ] +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx new file mode 100644 index 00000000000..786c1d453ef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from 'react' +import type { JSONContent } from '@tiptap/core' +import { Image } from '@tiptap/extension-image' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' + +const MIN_WIDTH = 64 + +/** Escape a value for safe interpolation into a double-quoted HTML attribute. */ +function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +/** + * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when + * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML + * (the same convention GitHub uses). Unsized images stay clean `![alt](src)`. + */ +function imageMarkdown(node: JSONContent): string { + const attrs = node.attrs ?? {} + const src = typeof attrs.src === 'string' ? attrs.src : '' + const alt = typeof attrs.alt === 'string' ? attrs.alt : '' + const title = typeof attrs.title === 'string' ? attrs.title : '' + const width = attrs.width + const height = attrs.height + if (width || height) { + const parts = [`src="${escapeAttr(src)}"`] + if (alt) parts.push(`alt="${escapeAttr(alt)}"`) + if (title) parts.push(`title="${escapeAttr(title)}"`) + if (width) parts.push(`width="${escapeAttr(String(width))}"`) + if (height) parts.push(`height="${escapeAttr(String(height))}"`) + return `` + } + const titlePart = title ? ` "${title}"` : '' + return `![${alt}](${src}${titlePart})` +} + +const widthAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('width'), + renderHTML: (attributes: Record) => + attributes.width ? { width: String(attributes.width) } : {}, +} + +const heightAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('height'), + renderHTML: (attributes: Record) => + attributes.height ? { height: String(attributes.height) } : {}, +} + +/** + * Image node that carries optional `width`/`height` and serializes them as an HTML `` tag. + * Shared by the headless round-trip path (no node view) and the live {@link ResizableImage}. + */ +export const MarkdownImage = Image.extend({ + addAttributes() { + return { ...this.parent?.(), width: widthAttr, height: heightAttr } + }, + renderMarkdown: imageMarkdown, +}) + +/** + * Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging + * commits the new pixel width to the `width` attribute, which serializes to ``. + */ +function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewProps) { + const imageRef = useRef(null) + const dragAbortRef = useRef(null) + const [dragging, setDragging] = useState(false) + const attrs = node.attrs as { src?: string; alt?: string; title?: string; width?: string | null } + + useEffect(() => () => dragAbortRef.current?.abort(), []) + + const startResize = (event: React.PointerEvent) => { + event.preventDefault() + const image = imageRef.current + if (!image) return + const startX = event.clientX + const startWidth = image.offsetWidth + setDragging(true) + dragAbortRef.current?.abort() + const controller = new AbortController() + dragAbortRef.current = controller + const { signal } = controller + + window.addEventListener( + 'pointermove', + (move) => { + const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX))) + updateAttributes({ width: String(next) }) + }, + { signal } + ) + window.addEventListener( + 'pointerup', + () => { + setDragging(false) + controller.abort() + }, + { signal } + ) + } + + const widthStyle = attrs.width + ? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width } + : undefined + + return ( + + {attrs.alt + {(selected || dragging) && ( + + + + {shortcut ? {label} : label} + + + ) +} + +function ToolbarDivider() { + return
+} + +interface EditorBubbleMenuProps { + editor: Editor +} + +/** + * Floating formatting toolbar shown on text selection (Linear-style). Marks and the common + * block types; the link button swaps the bar into an inline URL editor. Richer block inserts + * live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar + * stays correct without re-rendering the editor on every transaction. + */ +export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) { + const [linkValue, setLinkValue] = useState(null) + const linkInputRef = useRef(null) + const linkRangeRef = useRef<{ from: number; to: number } | null>(null) + const isEditingLink = linkValue !== null + + const active = useEditorState({ + editor, + selector: ({ editor: e }) => ({ + bold: e.isActive('bold'), + italic: e.isActive('italic'), + strike: e.isActive('strike'), + code: e.isActive('code'), + link: e.isActive('link'), + heading1: e.isActive('heading', { level: 1 }), + heading2: e.isActive('heading', { level: 2 }), + bulletList: e.isActive('bulletList'), + orderedList: e.isActive('orderedList'), + taskList: e.isActive('taskList'), + blockquote: e.isActive('blockquote'), + }), + }) + + useEffect(() => { + if (isEditingLink) linkInputRef.current?.focus() + }, [isEditingLink]) + + useEffect(() => { + const exitOnCollapse = () => { + const { from, to } = editor.state.selection + if (from === to) setLinkValue(null) + } + editor.on('selectionUpdate', exitOnCollapse) + return () => { + editor.off('selectionUpdate', exitOnCollapse) + } + }, [editor]) + + const openLinkEditor = () => { + if (editor.isActive('codeBlock') || editor.isActive('code')) return + const { from, to } = editor.state.selection + linkRangeRef.current = { from, to } + setLinkValue(editor.getAttributes('link').href ?? '') + } + + useEffect(() => { + const dom = editor.view.dom + const openLinkOnShortcut = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.isComposing) return + if (event.key?.toLowerCase() !== 'k') return + const { from, to } = editor.state.selection + if (from === to || editor.isActive('codeBlock') || editor.isActive('code')) return + event.preventDefault() + linkRangeRef.current = { from, to } + setLinkValue(editor.getAttributes('link').href ?? '') + } + dom.addEventListener('keydown', openLinkOnShortcut) + return () => { + dom.removeEventListener('keydown', openLinkOnShortcut) + } + }, [editor]) + + // The captured range can outlive a programmatic doc change (image insert, content sync), so + // clamp it to the current document before re-selecting to avoid a "position out of range" throw. + const selectCapturedRange = (chain: ReturnType) => { + const range = linkRangeRef.current + if (!range) return chain + const max = editor.state.doc.content.size + return chain.setTextSelection({ from: Math.min(range.from, max), to: Math.min(range.to, max) }) + } + + const commitLink = () => { + const href = normalizeLinkHref((linkValue ?? '').trim()) + const chain = selectCapturedRange(editor.chain().focus()) + chain.extendMarkRange('link') + if (href) chain.setLink({ href }) + else chain.unsetLink() + chain.run() + setLinkValue(null) + } + + const removeLink = () => { + selectCapturedRange(editor.chain().focus()).extendMarkRange('link').unsetLink().run() + setLinkValue(null) + } + + return ( + { + if (isEditingLink) return true + if (!e.isEditable || e.isActive('codeBlock')) return false + return e.state.doc.textBetween(from, to, ' ').trim().length > 0 + }} + 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-100 motion-reduce:animate-none' + > + {isEditingLink ? ( + <> + setLinkValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commitLink() + } else if (event.key === 'Escape') { + event.preventDefault() + setLinkValue(null) + } + }} + placeholder='Paste or type a link…' + className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]' + /> + {active.link && ( + + )} + + + ) : ( + <> + editor.chain().focus().toggleBold().run()} + /> + editor.chain().focus().toggleItalic().run()} + /> + editor.chain().focus().toggleStrike().run()} + /> + editor.chain().focus().toggleCode().run()} + /> + + + editor.chain().focus().toggleHeading({ level: 1 }).run()} + /> + editor.chain().focus().toggleHeading({ level: 2 }).run()} + /> + + editor.chain().focus().toggleBulletList().run()} + /> + editor.chain().focus().toggleOrderedList().run()} + /> + editor.chain().focus().toggleTaskList().run()} + /> + editor.chain().focus().toggleBlockquote().run()} + /> + + )} + + ) +} 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 new file mode 100644 index 00000000000..5154eea1568 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -0,0 +1,256 @@ +.rich-markdown-prose { + flex: 1 1 auto; + outline: none; + color: var(--text-primary); + font-family: var(--font-inter); + font-size: 15px; + font-weight: 430; + line-height: 25px; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +.rich-markdown-prose img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid var(--border); +} + +/* One consistent ring for every node that can only be selected as a whole (divider, image, + code block, table) — so a mouse click and a keyboard NodeSelection look identical. */ +.rich-markdown-prose .ProseMirror-selectednode { + outline: 2px solid var(--brand-secondary); + outline-offset: 2px; + border-radius: 4px; +} + +.rich-markdown-prose > * + * { + margin-top: 0.6em; +} + +.rich-markdown-prose > :first-child { + margin-top: 0; +} + +.rich-markdown-prose h1, +.rich-markdown-prose h2, +.rich-markdown-prose h3, +.rich-markdown-prose h4 { + font-weight: 600; + line-height: 1.3; + color: var(--text-primary); +} + +.rich-markdown-prose h1 { + font-size: 1.6em; + margin-top: 1.4em; +} + +.rich-markdown-prose h2 { + font-size: 1.3em; + margin-top: 1.3em; +} + +.rich-markdown-prose h3 { + font-size: 1.1em; + margin-top: 1.2em; +} + +.rich-markdown-prose h4 { + font-size: 1em; + margin-top: 1.1em; +} + +.rich-markdown-prose strong { + font-weight: 600; + color: var(--text-primary); +} + +.rich-markdown-prose em { + font-style: italic; + color: var(--text-primary); +} + +.rich-markdown-prose del, +.rich-markdown-prose s { + color: var(--text-tertiary); + text-decoration: line-through; +} + +.rich-markdown-prose a { + color: var(--brand-secondary); + cursor: pointer; +} + +.rich-markdown-prose a:hover { + text-decoration: underline; +} + +/* Render the gap cursor (e.g. above a leading divider) as a normal vertical caret rather + than ProseMirror's default short horizontal bar, which reads as a stray underscore. */ +.rich-markdown-prose .ProseMirror-gapcursor::after { + top: 0; + width: 2px; + height: 1.25em; + border-top: none; + background-color: var(--text-primary); +} + +.rich-markdown-prose ul, +.rich-markdown-prose ol { + padding-left: 1.25em; +} + +.rich-markdown-prose ul { + list-style: disc; +} + +.rich-markdown-prose ol { + list-style: decimal; +} + +.rich-markdown-prose li > p { + margin: 0; +} + +.rich-markdown-prose li::marker { + color: var(--text-primary); +} + +.rich-markdown-prose ul[data-type="taskList"] { + list-style: none; + padding-left: 0; +} + +.rich-markdown-prose ul[data-type="taskList"] li { + display: flex; + align-items: flex-start; + gap: 0.5em; +} + +.rich-markdown-prose ul[data-type="taskList"] li > label { + margin-top: 0.28em; + flex-shrink: 0; + user-select: none; +} + +.rich-markdown-prose ul[data-type="taskList"] li > div { + flex: 1 1 auto; + min-width: 0; +} + +.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"] { + accent-color: var(--text-primary); + cursor: pointer; +} + +.rich-markdown-prose blockquote { + border-left: 2px solid var(--divider); + padding-left: 1rem; + color: var(--text-primary); + font-style: italic; +} + +.rich-markdown-prose code { + font-family: var(--font-martian-mono, ui-monospace, monospace); + font-size: 0.875em; + background: var(--surface-5); + color: var(--text-primary); + border-radius: 4px; + padding: 0.125rem 0.375rem; +} + +.rich-markdown-prose pre { + background: var(--surface-5); + border-radius: 8px; + padding: 1rem; + overflow-x: auto; +} + +/* Override ProseMirror's built-in `.ProseMirror pre { white-space: pre-wrap }` (the + `.code-editor-theme` class raises specificity so this wins): code blocks scroll by default and + only wrap when the line-wrap toggle sets data-wrap, breaking long unbroken tokens too. */ +.rich-markdown-prose pre.code-editor-theme, +.rich-markdown-prose pre.code-editor-theme code { + white-space: pre; +} + +.rich-markdown-prose pre.code-editor-theme[data-wrap="true"], +.rich-markdown-prose pre.code-editor-theme[data-wrap="true"] code { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.dark .rich-markdown-prose pre { + background: var(--code-bg); +} + +.rich-markdown-prose pre code { + background: none; + padding: 0; + font-size: 13px; + line-height: 21px; +} + +.rich-markdown-prose hr { + border: none; + border-top: 1px solid var(--divider); + margin: 1.5em 0; +} + +.rich-markdown-prose table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + margin: 1rem 0; + overflow: hidden; +} + +.rich-markdown-prose th, +.rich-markdown-prose td { + position: relative; + border: 1px solid var(--divider); + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; + font-size: 14px; + line-height: 1.5rem; +} + +.rich-markdown-prose th { + background: var(--surface-4); + font-weight: 600; +} + +.rich-markdown-prose th > p, +.rich-markdown-prose td > p { + margin: 0; +} + +.rich-markdown-prose .selectedCell::after { + content: ""; + position: absolute; + inset: 0; + background: var(--surface-active); + opacity: 0.5; + pointer-events: none; +} + +.rich-markdown-prose .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 3px; + background: var(--brand-secondary); + pointer-events: none; +} + +.rich-markdown-prose p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--text-subtle); + float: left; + height: 0; + pointer-events: none; +} 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 new file mode 100644 index 00000000000..8135b310761 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -0,0 +1,217 @@ +'use client' + +import { memo, useEffect, useRef } from 'react' +import type { Editor } from '@tiptap/react' +import { EditorContent, useEditor } from '@tiptap/react' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' +import type { SaveStatus } from '@/hooks/use-autosave' +import { PreviewLoadingFrame } from '../preview-shared' +import { useEditableFileContent } from '../use-editable-file-content' +import { createMarkdownEditorExtensions } from './extensions' +import { + applyFrontmatter, + normalizeLinkHref, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' +import { EditorBubbleMenu } from './menus/bubble-menu' +import '@/components/emcn/components/code/code.css' +import './rich-markdown-editor.css' + +const EXTENSIONS = createMarkdownEditorExtensions({ + placeholder: "Write something, or press '/' for commands…", +}) + +/** Image files from a paste/drop payload (screenshots, dragged files, copied images). */ +function extractImageFiles(transfer: DataTransfer | null): File[] { + if (!transfer) return [] + return Array.from(transfer.files).filter((file) => file.type.startsWith('image/')) +} + +interface RichMarkdownEditorProps { + file: WorkspaceFileRecord + workspaceId: string + canEdit: boolean + autoFocus?: boolean + onDirtyChange?: (isDirty: boolean) => void + onSaveStatusChange?: (status: SaveStatus) => void + saveRef?: React.MutableRefObject<(() => Promise) | null> +} + +/** + * Inline WYSIWYG markdown editor (TipTap/ProseMirror) for markdown files. Renders a + * single editing surface — markdown is transformed inline as you type, Linear-style — + * with no raw/preview split. Content loading and autosave are delegated to + * {@link useEditableFileContent}; this component only renders the editor and bridges + * markdown in and out of it. + */ +export const RichMarkdownEditor = memo(function RichMarkdownEditor({ + file, + workspaceId, + canEdit, + autoFocus, + onDirtyChange, + onSaveStatusChange, + saveRef, +}: RichMarkdownEditorProps) { + const { + content, + setDraftContent, + isStreamInteractionLocked, + isContentLoading, + hasContentError, + saveImmediately, + } = useEditableFileContent({ + file, + workspaceId, + canEdit, + onDirtyChange, + onSaveStatusChange, + saveRef, + }) + + const isEditable = canEdit && !isStreamInteractionLocked + + const syncedMarkdownRef = useRef(null) + const frontmatterRef = useRef('') + const frontmatterSourceRef = useRef(null) + const hasAutoFocusedRef = useRef(false) + const setDraftContentRef = useRef(setDraftContent) + setDraftContentRef.current = setDraftContent + const saveImmediatelyRef = useRef(saveImmediately) + saveImmediatelyRef.current = saveImmediately + + const uploadFile = useUploadWorkspaceFile() + const editorInstanceRef = useRef(null) + + /** + * Upload each image to the workspace, then insert it at `at` (paste = caret, drop = cursor + * under the pointer). Sequential so multiple images stack in order; the upload hook surfaces + * its own success/error toasts, so a failed upload is skipped without interrupting the rest. + * Held in a ref (reassigned each render) so the once-built `editorProps` handlers always reach + * the latest workspace/file values. + */ + const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => + Promise.resolve() + ) + insertImagesRef.current = async (images, at) => { + let position = at + for (const image of images) { + const result = await uploadFile + .mutateAsync({ workspaceId, file: image, folderId: file.folderId ?? null }) + .catch(() => null) + const editor = editorInstanceRef.current + if (!result || !editor) continue + // The doc may have shrunk during the await; clamp so the insert can't land out of bounds. + const safePosition = Math.min(position, editor.state.doc.content.size) + try { + editor + .chain() + .insertContentAt(safePosition, { + type: 'image', + attrs: { src: result.file.url, alt: image.name }, + }) + .run() + position = editor.state.selection.to + } catch { + position = editor.state.doc.content.size + } + } + } + + if (content !== frontmatterSourceRef.current) { + frontmatterSourceRef.current = content + frontmatterRef.current = splitFrontmatter(content).frontmatter + } + + const editor = useEditor({ + extensions: EXTENSIONS, + editable: isEditable, + autofocus: autoFocus ? 'end' : false, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + editorProps: { + attributes: { class: 'rich-markdown-prose' }, + handleKeyDown: (_view, event) => { + const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' + if (!isSaveShortcut) return false + event.preventDefault() + void saveImmediatelyRef.current() + return true + }, + handleClick: (_view, _pos, event) => { + if (!(event.metaKey || event.ctrlKey)) return false + const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') + if (!href) return false + const normalized = normalizeLinkHref(href) + if (!normalized) return false + window.open(normalized, '_blank', 'noopener,noreferrer') + return true + }, + handlePaste: (view, event) => { + const images = extractImageFiles(event.clipboardData) + if (images.length === 0) return false + event.preventDefault() + void insertImagesRef.current(images, view.state.selection.from) + return true + }, + handleDrop: (view, event) => { + const images = extractImageFiles(event.dataTransfer) + if (images.length === 0) return false + event.preventDefault() + const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos + void insertImagesRef.current(images, dropPos ?? view.state.selection.from) + return true + }, + }, + onUpdate: ({ editor }) => { + const body = postProcessSerializedMarkdown(editor.getMarkdown()) + const full = applyFrontmatter(frontmatterRef.current, body) + syncedMarkdownRef.current = full + setDraftContentRef.current(full) + }, + }) + + useEffect(() => { + editorInstanceRef.current = editor + }, [editor]) + + useEffect(() => { + editor?.setEditable(isEditable) + }, [editor, isEditable]) + + useEffect(() => { + if (!editor || content === syncedMarkdownRef.current) return + syncedMarkdownRef.current = content + editor + .chain() + .setMeta('addToHistory', false) + .setContent(splitFrontmatter(content).body, { contentType: 'markdown', emitUpdate: false }) + .run() + if (autoFocus && !hasAutoFocusedRef.current) { + hasAutoFocusedRef.current = true + editor.commands.focus('end') + } + }, [editor, content, autoFocus]) + + if (isContentLoading) return + + if (hasContentError) { + return ( +
+

Failed to load file content

+
+ ) + } + + return ( +
+ {editor && } + +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts new file mode 100644 index 00000000000..79cf335b437 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { isRoundTripSafe } from './round-trip-safety' + +describe('isRoundTripSafe', () => { + it('passes ordinary markdown and lossless normalizations', () => { + expect(isRoundTripSafe('# Title\n\nA **bold** word and a [link](https://sim.ai).')).toBe(true) + expect(isRoundTripSafe('- one\n- two\n\n```js\nconst x = 1\n```')).toBe(true) + expect(isRoundTripSafe('| a | b |\n| :-- | --: |\n| 1 | 2 |')).toBe(true) + expect(isRoundTripSafe('- [ ] a\n - [x] b')).toBe(true) + expect(isRoundTripSafe('line one \nline two')).toBe(true) + expect(isRoundTripSafe('value $x^2 + y$ here')).toBe(true) + expect(isRoundTripSafe('a & b < c')).toBe(true) + expect(isRoundTripSafe('Title\n=====\n\nbody')).toBe(true) + expect(isRoundTripSafe('')).toBe(true) + }) + + it('passes inline code without an interior backtick', () => { + expect(isRoundTripSafe('use `npm install` here')).toBe(true) + }) + + it('passes a code block followed by other content (idempotent block separation)', () => { + expect(isRoundTripSafe('```\ncode\n```\n\ntext after')).toBe(true) + expect( + isRoundTripSafe('```markdown\n\n```\n\n![s](/api/files/serve/x.png?context=workspace)') + ).toBe(true) + expect(isRoundTripSafe('> ```\n> code\n> ```')).toBe(true) + }) + + it('rejects stable-loss constructs the idempotency probe cannot see', () => { + expect(isRoundTripSafe('[![alt](https://e.com/i.png)](https://e.com)')).toBe(false) + 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('rejects a hard break inside a heading (serializer splits the heading)', () => { + expect(isRoundTripSafe('# one \ntwo')).toBe(false) + expect(isRoundTripSafe('## title\\\nmore')).toBe(false) + }) + + it('rejects HTML entities other than the canonical three (escaped to literal source)', () => { + expect(isRoundTripSafe('it's here')).toBe(false) + expect(isRoundTripSafe('© 2024')).toBe(false) + expect(isRoundTripSafe('a b')).toBe(false) + expect(isRoundTripSafe('a & b < c > d')).toBe(true) + expect(isRoundTripSafe('AT&T and R&D')).toBe(true) + }) + + it('does not flag HTML/comments/entities inside tilde or nested code fences', () => { + expect(isRoundTripSafe('~~~html\n\n~~~')).toBe(true) + expect(isRoundTripSafe('````md\n```\n
x
\n```\n````')).toBe(true) + }) + + it('rejects non-idempotent churn', () => { + expect(isRoundTripSafe('render `` a`b `` inline')).toBe(false) + }) + + it('does not flag
outside a table (converts losslessly to a hard break)', () => { + expect(isRoundTripSafe('a
b')).toBe(true) + expect(isRoundTripSafe('a line\n\nwith | a pipe but no break')).toBe(true) + expect(isRoundTripSafe('Use a
break or the pipe | operator.')).toBe(true) + }) + + it('rejects
inside a table cell (flattened to a space)', () => { + expect(isRoundTripSafe('| a | b |\n| --- | --- |\n| one
two | x |')).toBe(false) + }) + + it('allows (a supported, resizable image node)', () => { + expect(isRoundTripSafe('')).toBe(true) + }) + + it('does not flag a fenced block that merely contains html or backticks', () => { + expect(isRoundTripSafe('```html\n
hi
\n```')).toBe(true) + expect(isRoundTripSafe('````md\n```\ncode\n```\n````')).toBe(true) + }) + + it('does not flag markdown autolinks as raw html', () => { + expect(isRoundTripSafe('see for more')).toBe(true) + }) + + it('falls back for very large documents without probing', () => { + expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(110_000)}`)).toBe(false) + }) +}) 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 new file mode 100644 index 00000000000..b3dd9b4ab3e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -0,0 +1,89 @@ +import { Editor } from '@tiptap/core' +import { createMarkdownContentExtensions } from './extensions' +import { + applyFrontmatter, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' + +/** + * Above this size we don't run the (synchronous) round-trip probe — building two editors to + * serialize a large document blocks the main thread for too long, and a very large markdown file + * is heavier to edit richly anyway, so it opens in the raw editor. + */ +const PROBE_SIZE_LIMIT = 128 * 1024 + +/** + * Constructs the editor drops or mangles in a way that survives a second serialization + * unchanged — so the idempotency probe below can't see the loss. Each must be matched directly. + * + * - **Linked image** `[![alt](img)](href)` — the schema can't nest an image in a link, so the + * wrapping href is dropped. + * - **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. + * - **`
` 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 + * the heading, ejecting the second line into a separate paragraph. + * - **HTML entity** other than `&`/`<`/`>` (e.g. `©`, `'`, ` `) — the + * serializer escapes the `&`, turning the rendered character into literal entity source. A bare + * `&` with no `;` is left alone (it re-renders identically, so it's harmless churn). + */ +const STABLE_LOSS_PATTERNS: ReadonlyArray = [ + /\[\s*!\[[^\]]*]\([^)]*\)\s*]\([^)]*\)/, + /\[\^[^\]]+]/, + / B\n```', + 'horizontal rule': 'above\n\n---\n\nbelow', + table: '| a | b |\n| --- | --- |\n| 1 | 2 |', + 'strike code': '~~`x`~~', + 'bold code': '**`x`**', + 'heading strike code': '# ~~`x`~~', + 'table with pipe': '| x \\| y | 2 |\n| --- | --- |\n| a | b |', + } + + for (const [name, input] of Object.entries(cases)) { + it(`is idempotent for ${name}`, () => { + const once = roundTrip(input) + const twice = roundTrip(once) + expect(twice).toBe(once) + }) + } + + it('preserves frontmatter through a full round-trip', () => { + const input = '---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n\ntext' + const out = roundTrip(input) + expect(out).toContain('---\ntitle: Hello\ntags: [a, b]\n---') + expect(out).toContain('# Body') + expect(out).toBe(roundTrip(out)) + }) + + it('keeps GFM callout markers unescaped', () => { + expect(roundTrip('> [!NOTE]\n> Heads up')).toContain('[!NOTE]') + }) + + it('preserves an image url (does not drop the src)', () => { + const out = roundTrip('![alt](https://example.com/i.png)') + expect(out).toContain('![alt](https://example.com/i.png)') + }) + + it('round-trips a sized image as an HTML , plain images as markdown', () => { + const sized = roundTrip('d') + expect(sized).toContain('d') + expect(roundTrip(sized)).toBe(sized) + expect(roundTrip('![a](https://e.com/i.png)')).toContain('![a](https://e.com/i.png)') + }) + + it('preserves a sized base64 image and escapes quotes in attributes', () => { + const dataUrl = '' + expect(roundTrip(dataUrl)).toContain('data:image/png;base64,iVBORw0KGgo=') + expect(roundTrip(dataUrl)).toBe(roundTrip(roundTrip(dataUrl))) + const quoted = roundTrip('\'a"b\'') + expect(quoted).toContain('alt="a"b"') + expect(roundTrip(quoted)).toBe(quoted) + }) + + it('round-trips a code block that contains a fence line (sized fence)', () => { + const out = roundTrip('````md\n```\ncode\n```\n````') + expect(out).toContain('```\ncode\n```') + expect(roundTrip(out)).toBe(out) + }) + + it('keeps a mermaid block as a fenced code block', () => { + expect(roundTrip('```mermaid\ngraph TD\n A --> B\n```')).toContain('```mermaid') + }) + + it('keeps task list checkbox state', () => { + const out = roundTrip('- [ ] todo\n- [x] done') + expect(out).toContain('- [ ] todo') + expect(out).toContain('- [x] done') + }) + + it('keeps a table as a GFM pipe table with no leading blank line', () => { + const out = roundTrip('| a | b |\n| --- | --- |\n| 1 | 2 |') + expect(out.startsWith('|')).toBe(true) + expect(out).toContain('| --- |') + }) + + it('combines strikethrough with inline code (relaxed code mark)', () => { + expect(roundTrip('~~`x`~~')).toContain('~~`x`~~') + expect(roundTrip('# ~~`x`~~')).toContain('# ~~`x`~~') + }) + + it('escapes interior pipes in table cells (no phantom column split)', () => { + const out = roundTrip('| x \\| y | 2 |\n| --- | --- |\n| a | b |') + expect(out).toContain('x \\| y') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts new file mode 100644 index 00000000000..0df4315d2be --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { filterSlashCommands, SLASH_COMMANDS } from './commands' + +describe('filterSlashCommands', () => { + it('returns a copy of all commands for an empty query', () => { + const all = filterSlashCommands('') + expect(all).toHaveLength(SLASH_COMMANDS.length) + expect(all).not.toBe(SLASH_COMMANDS) + }) + + it('matches on title case-insensitively', () => { + expect(filterSlashCommands('HEAD').map((c) => c.title)).toEqual([ + 'Heading 1', + 'Heading 2', + 'Heading 3', + ]) + }) + + it('matches on alias', () => { + expect(filterSlashCommands('todo').map((c) => c.title)).toContain('Checklist') + expect(filterSlashCommands('hr').map((c) => c.title)).toContain('Divider') + }) + + it('trims whitespace in the query', () => { + expect(filterSlashCommands(' table ').map((c) => c.title)).toEqual(['Table']) + }) + + it('returns empty for no match', () => { + expect(filterSlashCommands('zzz')).toEqual([]) + }) +}) + +describe('SLASH_COMMANDS registry', () => { + it('every command has the required fields', () => { + for (const command of SLASH_COMMANDS) { + expect(command.title).toBeTruthy() + expect(command.group).toBeTruthy() + expect(command.icon).toBeTruthy() + expect(Array.isArray(command.aliases)).toBe(true) + expect(typeof command.run).toBe('function') + } + }) + + it('has unique titles (stable React keys)', () => { + const titles = SLASH_COMMANDS.map((c) => c.title) + expect(new Set(titles).size).toBe(titles.length) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts new file mode 100644 index 00000000000..acf945017d9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -0,0 +1,147 @@ +import type { Editor, Range } from '@tiptap/core' +import { + Code2, + Heading1, + Heading2, + Heading3, + List, + ListChecks, + ListOrdered, + type LucideIcon, + Minus, + Pilcrow, + Table as TableIcon, + TextQuote, +} from 'lucide-react' + +export interface SlashCommandContext { + editor: Editor + range: Range +} + +export interface SlashCommandItem { + title: string + /** Group heading the item is shown under in the menu. */ + group: string + icon: LucideIcon + /** Extra search terms matched against the slash query, beyond the title. */ + aliases: string[] + /** Keyboard shortcut shown on the right of the item (omitted when there is none). */ + shortcut?: string + run: (ctx: SlashCommandContext) => void +} + +/** + * The blocks insertable via the `/` menu. Each `run` first deletes the typed `/query` + * (`deleteRange(range)`) so the command replaces the trigger text rather than appending. + * Kept to blocks that round-trip cleanly through markdown — no media/embeds. + */ +export const SLASH_COMMANDS: readonly SlashCommandItem[] = [ + { + title: 'Text', + group: 'Basic', + icon: Pilcrow, + aliases: ['paragraph', 'body'], + run: ({ editor, range }) => editor.chain().focus().deleteRange(range).setParagraph().run(), + }, + { + title: 'Heading 1', + group: 'Basic', + icon: Heading1, + aliases: ['h1', 'title'], + shortcut: '⌘⌥1', + run: ({ editor, range }) => + editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(), + }, + { + title: 'Heading 2', + group: 'Basic', + icon: Heading2, + aliases: ['h2', 'subtitle'], + shortcut: '⌘⌥2', + run: ({ editor, range }) => + editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(), + }, + { + title: 'Heading 3', + group: 'Basic', + icon: Heading3, + aliases: ['h3'], + shortcut: '⌘⌥3', + run: ({ editor, range }) => + editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(), + }, + { + title: 'Bulleted list', + group: 'Lists', + icon: List, + aliases: ['unordered', 'ul', 'bullet'], + shortcut: '⌘⇧8', + run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBulletList().run(), + }, + { + title: 'Numbered list', + group: 'Lists', + icon: ListOrdered, + aliases: ['ordered', 'ol'], + shortcut: '⌘⇧7', + run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleOrderedList().run(), + }, + { + title: 'Checklist', + group: 'Lists', + icon: ListChecks, + aliases: ['todo', 'task', 'checkbox'], + shortcut: '⌘⇧9', + run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleTaskList().run(), + }, + { + title: 'Quote', + group: 'Blocks', + icon: TextQuote, + aliases: ['blockquote', 'citation'], + shortcut: '⌘⇧B', + run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBlockquote().run(), + }, + { + title: 'Code block', + group: 'Blocks', + icon: Code2, + aliases: ['codeblock', 'snippet', 'fence'], + shortcut: '⌘⌥C', + run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: 'Table', + group: 'Blocks', + icon: TableIcon, + aliases: ['grid', 'rows', 'columns'], + run: ({ editor, range }) => + editor + .chain() + .focus() + .deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(), + }, + { + title: 'Divider', + group: 'Blocks', + icon: Minus, + aliases: ['hr', 'horizontal rule', 'separator'], + run: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), + }, +] + +/** + * Filters commands by a case-insensitive match against title or aliases. Order is + * preserved so the menu stays stable as the query narrows. + */ +export function filterSlashCommands(query: string): SlashCommandItem[] { + const q = query.trim().toLowerCase() + if (!q) return [...SLASH_COMMANDS] + return SLASH_COMMANDS.filter( + (command) => + command.title.toLowerCase().includes(q) || command.aliases.some((alias) => alias.includes(q)) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx new file mode 100644 index 00000000000..d9f10a7e8db --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -0,0 +1,129 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { cn } from '@/lib/core/utils/cn' +import type { SlashCommandItem } from './commands' + +export interface SlashCommandListHandle { + onKeyDown: (props: { event: KeyboardEvent }) => boolean +} + +interface SlashCommandListProps { + items: SlashCommandItem[] + command: (item: SlashCommandItem) => void +} + +const SURFACE_CLASS = + 'min-w-[220px] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none' + +const ITEM_CLASS = + 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' + +/** + * The `/` command popup. Mirrors the Chat composer's skills menu — same item chrome, + * grouped headings, and arrow/enter keyboard navigation — so the two feel identical. + * Exposes an imperative `onKeyDown` driven by the TipTap suggestion plugin. + */ +export const SlashCommandList = forwardRef( + function SlashCommandList({ items, command }, ref) { + const [activeIndex, setActiveIndex] = useState(0) + const containerRef = useRef(null) + + useEffect(() => { + setActiveIndex(0) + }, [items]) + + useEffect(() => { + containerRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (items.length === 0) return false + if (event.key === 'ArrowUp') { + setActiveIndex((i) => (i + items.length - 1) % items.length) + return true + } + if (event.key === 'ArrowDown') { + setActiveIndex((i) => (i + 1) % items.length) + return true + } + if (event.key === 'Enter') { + const item = items[activeIndex] + if (!item) return false + command(item) + return true + } + return false + }, + })) + + const groups = useMemo(() => { + const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = [] + items.forEach((item, index) => { + const bucket = ordered.find((g) => g.group === item.group) + if (bucket) bucket.items.push({ item, index }) + else ordered.push({ group: item.group, items: [{ item, index }] }) + }) + return ordered + }, [items]) + + if (items.length === 0) { + return ( +
+

No results

+
+ ) + } + + return ( +
+ {groups.map((group) => ( +
+ + {group.items.map(({ item, index }) => { + const Icon = item.icon + return ( + + ) + })} +
+ ))} +
+ ) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts new file mode 100644 index 00000000000..2a5118ec1cf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -0,0 +1,111 @@ +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' +import type { Editor } from '@tiptap/core' +import { Extension } from '@tiptap/core' +import { ReactRenderer } from '@tiptap/react' +import Suggestion, { type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion' +import { filterSlashCommands, type SlashCommandContext, type SlashCommandItem } from './commands' +import { SlashCommandList, type SlashCommandListHandle } from './slash-command-list' + +type SlashSuggestionProps = SuggestionProps + +function positionPopup(element: HTMLElement, getRect: SlashSuggestionProps['clientRect']) { + const rect = getRect?.() + if (!rect) return + const virtualEl = { getBoundingClientRect: () => rect } + computePosition(virtualEl, element, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })], + }).then(({ x, y }) => { + if (!element.isConnected) return + element.style.left = `${x}px` + element.style.top = `${y}px` + }) +} + +function renderSlashSuggestion(): ReturnType> { + let component: ReactRenderer | null = null + let popup: HTMLElement | null = null + let boundEditor: Editor | null = null + let stopAutoUpdate: (() => void) | null = null + + const teardown = () => { + stopAutoUpdate?.() + stopAutoUpdate = null + boundEditor?.off('destroy', teardown) + boundEditor = null + popup?.remove() + component?.destroy() + popup = null + component = null + } + + return { + onStart: (props) => { + teardown() + component = new ReactRenderer(SlashCommandList, { props, editor: props.editor }) + popup = document.createElement('div') + popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' + popup.appendChild(component.element) + document.body.appendChild(popup) + boundEditor = props.editor + boundEditor.on('destroy', teardown) + const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() } + const surface = popup + stopAutoUpdate = autoUpdate(reference, surface, () => + positionPopup(surface, props.clientRect) + ) + }, + onUpdate: (props) => { + component?.updateProps(props) + if (popup) positionPopup(popup, props.clientRect) + }, + onKeyDown: (props) => { + if (props.event.isComposing) return false + if (props.event.key === 'Escape') { + teardown() + return true + } + return component?.ref?.onKeyDown(props) ?? false + }, + onExit: teardown, + } +} + +/** + * Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after + * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. + */ +export const SlashCommand = Extension.create({ + name: 'slashCommand', + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: '/', + allowSpaces: false, + startOfLine: false, + allow: ({ editor, range }) => { + if ( + editor.isActive('codeBlock') || + editor.isActive('table') || + editor.isActive('link') || + editor.isActive('code') + ) { + return false + } + const $from = editor.state.doc.resolve(range.from) + if ($from.parentOffset === 0) return true + return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset)) + }, + items: ({ query }) => filterSlashCommands(query), + command: ({ editor, range, props }) => { + const ctx: SlashCommandContext = { editor, range } + props.run(ctx) + }, + render: renderSlashSuggestion, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts index 88982dac9b7..06014a453ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts @@ -114,6 +114,14 @@ describe("reducer 'save-success' action", () => { expect(next.lastStreamedContent).toBeNull() expect(next.phase).toBe('ready') }) + + it('does not revert a keystroke typed while the save was in flight', () => { + const state = ready('ABC', 'old') + const next = textEditorContentReducer(state, { type: 'save-success', content: 'AB' }) + expect(next.content).toBe('ABC') + expect(next.savedContent).toBe('AB') + expect(next.content === next.savedContent).toBe(false) + }) }) describe('syncTextEditorContentState — initialization', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts index 78900f41507..2c8c74e1656 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts @@ -192,9 +192,12 @@ export function textEditorContentReducer( content: action.content, } case 'save-success': + // Advance only the saved baseline. Never roll `content` back to the saved snapshot: a + // keystroke landing while the save was in flight makes `content` newer than `action.content`, + // and overwriting it would silently drop that edit (and leave the doc looking clean so it's + // never re-saved). Leaving `content` ahead keeps the doc dirty so the trailing edit autosaves. if ( state.phase === 'ready' && - state.content === action.content && state.savedContent === action.content && state.lastStreamedContent === null ) { @@ -203,7 +206,6 @@ export function textEditorContentReducer( return { ...state, phase: 'ready', - content: action.content, savedContent: action.content, lastStreamedContent: null, } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 60b1ec2bc8a..547ce52d34f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -1,27 +1,18 @@ 'use client' -import { memo, useCallback, useEffect, useReducer, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import type { OnMount } from '@monaco-editor/react' import type { editor as MonacoEditorTypes } from 'monaco-editor' import dynamic from 'next/dynamic' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' -import { - useUpdateWorkspaceFileContent, - useWorkspaceFileContent, -} from '@/hooks/queries/workspace-files' -import { useAutosave } from '@/hooks/use-autosave' import { EditorContextMenu } from './editor-context-menu' import type { PreviewMode } from './file-viewer' import { PreviewPanel, resolvePreviewType } from './preview-panel' import { PreviewLoadingFrame } from './preview-shared' -import { - INITIAL_TEXT_EDITOR_CONTENT_STATE, - type StreamingMode, - type SyncTextEditorContentStateOptions, - textEditorContentReducer, -} from './text-editor-state' +import type { StreamingMode } from './text-editor-state' +import { useEditableFileContent } from './use-editable-file-content' const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [ { token: 'comment', foreground: '606060', fontStyle: 'italic' }, @@ -316,40 +307,6 @@ function resolveMonacoLanguage(file: { type: string; name: string }): string { return MONACO_LANGUAGE_BY_EXTENSION[ext] ?? MONACO_LANGUAGE_BY_MIME[file.type] ?? 'plaintext' } -function useTextEditorContentState(options: SyncTextEditorContentStateOptions) { - const [state, dispatch] = useReducer(textEditorContentReducer, INITIAL_TEXT_EDITOR_CONTENT_STATE) - - const prevOptionsRef = useRef(null) - const prev = prevOptionsRef.current - if ( - prev === null || - prev.canReconcileToFetchedContent !== options.canReconcileToFetchedContent || - prev.fetchedContent !== options.fetchedContent || - prev.streamingContent !== options.streamingContent || - prev.streamingMode !== options.streamingMode - ) { - prevOptionsRef.current = options - dispatch({ type: 'sync-external', ...options }) - } - - const setDraftContent = useCallback((content: string) => { - dispatch({ type: 'edit', content }) - }, []) - - const markSavedContent = (content: string) => { - dispatch({ type: 'save-success', content }) - } - - return { - content: state.content, - savedContent: state.savedContent, - isInitialized: state.phase !== 'uninitialized', - isStreamInteractionLocked: state.phase === 'streaming' || state.phase === 'reconciling', - setDraftContent, - markSavedContent, - } -} - function useMonacoTheme(): string { const [isDark, setIsDark] = useState( () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark') @@ -418,45 +375,25 @@ export const TextEditor = memo(function TextEditor({ hasSelection: boolean } | null>(null) - const { - data: fetchedContent, - isLoading, - error, - } = useWorkspaceFileContent( - workspaceId, - file.id, - file.key, - file.type === 'text/x-pptxgenjs' || - file.type === 'text/x-docxjs' || - file.type === 'text/x-pdflibjs' || - file.type === 'text/x-python-pdf' || - file.type === 'text/x-python-xlsx' - ) - - const updateContent = useUpdateWorkspaceFileContent() - const updateContentRef = useRef(updateContent) - updateContentRef.current = updateContent - const monacoLanguage = resolveMonacoLanguage(file) const monacoTheme = useMonacoTheme() - const onDirtyChangeRef = useRef(onDirtyChange) - const onSaveStatusChangeRef = useRef(onSaveStatusChange) - onDirtyChangeRef.current = onDirtyChange - onSaveStatusChangeRef.current = onSaveStatusChange - const { content, - savedContent, - isInitialized, - isStreamInteractionLocked, setDraftContent, - markSavedContent, - } = useTextEditorContentState({ - canReconcileToFetchedContent: file.key.length > 0, - fetchedContent, + isStreamInteractionLocked, + isContentLoading, + hasContentError, + saveImmediately, + } = useEditableFileContent({ + file, + workspaceId, + canEdit, streamingContent, streamingMode, + onDirtyChange, + onSaveStatusChange, + saveRef, }) contentRef.current = content @@ -533,42 +470,6 @@ export const TextEditor = memo(function TextEditor({ } }, [content, isStreamInteractionLocked, disableStreamingAutoScroll]) - async function onSave() { - if (content === savedContent) return - - await updateContentRef.current.mutateAsync({ - workspaceId, - fileId: file.id, - content, - }) - markSavedContent(content) - } - - const { saveStatus, saveImmediately, isDirty } = useAutosave({ - content, - savedContent, - onSave, - enabled: canEdit && isInitialized && !isStreamInteractionLocked, - }) - - useEffect(() => { - onDirtyChangeRef.current?.(isDirty) - }, [isDirty]) - - useEffect(() => { - onSaveStatusChangeRef.current?.(saveStatus) - }, [saveStatus]) - - useEffect(() => { - if (!saveRef) return - saveRef.current = saveImmediately - return () => { - if (saveRef.current === saveImmediately) { - saveRef.current = null - } - } - }, [saveImmediately, saveRef]) - useEffect(() => { if (!isResizing) return @@ -657,16 +558,14 @@ export const TextEditor = memo(function TextEditor({ const showEditor = effectiveMode !== 'preview' const showPreviewPane = effectiveMode !== 'editor' - if (streamingContent === undefined) { - if (isLoading) return + if (isContentLoading) return - if (error && !isInitialized) { - return ( -
-

Failed to load file content

-
- ) - } + if (hasContentError) { + return ( +
+

Failed to load file content

+
+ ) } const closeContextMenu = () => setContextMenu(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts new file mode 100644 index 00000000000..90a2cd8f810 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -0,0 +1,192 @@ +'use client' + +import { useCallback, useEffect, useReducer, useRef } from 'react' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + useUpdateWorkspaceFileContent, + useWorkspaceFileContent, +} from '@/hooks/queries/workspace-files' +import { type SaveStatus, useAutosave } from '@/hooks/use-autosave' +import { + INITIAL_TEXT_EDITOR_CONTENT_STATE, + type StreamingMode, + type SyncTextEditorContentStateOptions, + textEditorContentReducer, +} from './text-editor-state' + +/** + * Generated-document source files (`.pptx`/`.docx`/`.pdf`/`.xlsx` builders) whose + * editable text is the source program, not the compiled artifact. The serve route + * returns that source only when asked for the raw representation. + */ +const GENERATED_SOURCE_FILE_TYPES = new Set([ + 'text/x-pptxgenjs', + 'text/x-docxjs', + 'text/x-pdflibjs', + 'text/x-python-pdf', + 'text/x-python-xlsx', +]) + +interface UseEditableFileContentOptions { + file: WorkspaceFileRecord + workspaceId: string + canEdit: boolean + streamingContent?: string + streamingMode?: StreamingMode + onDirtyChange?: (isDirty: boolean) => void + onSaveStatusChange?: (status: SaveStatus) => void + saveRef?: React.MutableRefObject<(() => Promise) | null> +} + +interface EditableFileContent { + /** The current draft markdown/text, reflecting both user edits and streamed output. */ + content: string + /** Replace the draft content from an editing surface (no-op while streaming). */ + setDraftContent: (content: string) => void + /** True once the initial fetched content has been reconciled into editor state. */ + isInitialized: boolean + /** True while agent output is streaming in — surfaces should render read-only. */ + isStreamInteractionLocked: boolean + /** True when the initial content fetch is in flight and nothing is renderable yet. */ + isContentLoading: boolean + /** True when the initial content fetch failed before any content was shown. */ + hasContentError: boolean + saveStatus: SaveStatus + saveImmediately: () => Promise + isDirty: boolean +} + +/** + * Wraps the file-content reducer in editor-state semantics: reconciles fetched and + * streamed content into a single draft, and exposes edit/save commands. + */ +function useFileContentState(options: SyncTextEditorContentStateOptions) { + const [state, dispatch] = useReducer(textEditorContentReducer, INITIAL_TEXT_EDITOR_CONTENT_STATE) + + const prevOptionsRef = useRef(null) + const prev = prevOptionsRef.current + if ( + prev === null || + prev.canReconcileToFetchedContent !== options.canReconcileToFetchedContent || + prev.fetchedContent !== options.fetchedContent || + prev.streamingContent !== options.streamingContent || + prev.streamingMode !== options.streamingMode + ) { + prevOptionsRef.current = options + dispatch({ type: 'sync-external', ...options }) + } + + const setDraftContent = useCallback((content: string) => { + dispatch({ type: 'edit', content }) + }, []) + + const markSavedContent = useCallback((content: string) => { + dispatch({ type: 'save-success', content }) + }, []) + + return { + content: state.content, + savedContent: state.savedContent, + isInitialized: state.phase !== 'uninitialized', + isStreamInteractionLocked: state.phase === 'streaming' || state.phase === 'reconciling', + setDraftContent, + markSavedContent, + } +} + +/** + * The editing engine shared by every text-editable file surface (Monaco code + * editor, rich markdown editor). It owns content loading, the fetched/streamed/edited + * reconciliation, debounced autosave, and the dirty/save-status/`saveRef` prop bridge — + * leaving each surface responsible only for rendering and capturing edits. + */ +export function useEditableFileContent({ + file, + workspaceId, + canEdit, + streamingContent, + streamingMode = 'append', + onDirtyChange, + onSaveStatusChange, + saveRef, +}: UseEditableFileContentOptions): EditableFileContent { + const onDirtyChangeRef = useRef(onDirtyChange) + const onSaveStatusChangeRef = useRef(onSaveStatusChange) + onDirtyChangeRef.current = onDirtyChange + onSaveStatusChangeRef.current = onSaveStatusChange + + const { + data: fetchedContent, + isLoading, + error, + } = useWorkspaceFileContent( + workspaceId, + file.id, + file.key, + GENERATED_SOURCE_FILE_TYPES.has(file.type) + ) + + const updateContent = useUpdateWorkspaceFileContent() + const updateContentRef = useRef(updateContent) + updateContentRef.current = updateContent + + const { + content, + savedContent, + isInitialized, + isStreamInteractionLocked, + setDraftContent, + markSavedContent, + } = useFileContentState({ + canReconcileToFetchedContent: file.key.length > 0, + fetchedContent, + streamingContent, + streamingMode, + }) + + const contentRef = useRef(content) + contentRef.current = content + + const onSave = useCallback(async () => { + const next = contentRef.current + await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: next }) + markSavedContent(next) + }, [workspaceId, file.id, markSavedContent]) + + const { saveStatus, saveImmediately, isDirty } = useAutosave({ + content, + savedContent, + onSave, + enabled: canEdit && isInitialized && !isStreamInteractionLocked, + }) + + useEffect(() => { + onDirtyChangeRef.current?.(isDirty) + }, [isDirty]) + + useEffect(() => { + onSaveStatusChangeRef.current?.(saveStatus) + }, [saveStatus]) + + useEffect(() => { + if (!saveRef) return + saveRef.current = saveImmediately + return () => { + if (saveRef.current === saveImmediately) { + saveRef.current = null + } + } + }, [saveImmediately, saveRef]) + + return { + content, + setDraftContent, + isInitialized, + isStreamInteractionLocked, + isContentLoading: streamingContent === undefined && isLoading, + hasContentError: streamingContent === undefined && Boolean(error) && !isInitialized, + saveStatus, + saveImmediately, + isDirty, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index a56e008a857..e726660446e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -66,6 +66,7 @@ import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components import { FileViewer, isCsvStreamOnly, + isMarkdownFile, isPreviewable, isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -1422,7 +1423,11 @@ export function Files() { const streamOnly = isCsvStreamOnly(selectedFile) const canEditText = isTextEditable(selectedFile) && !streamOnly const canPreview = isPreviewable(selectedFile) && !streamOnly - const hasSplitView = canEditText && canPreview + // Markdown renders in the single-surface inline editor, which has no raw/split/preview + // modes — so it keeps Save but drops the mode toggle. + const isInlineMarkdown = isMarkdownFile(selectedFile) + const hasSplitView = canEditText && canPreview && !isInlineMarkdown + const showPreviewToggle = canPreview && !isInlineMarkdown const saveLabel = saveStatus === 'saving' @@ -1459,7 +1464,7 @@ export function Files() { onSelect: handleCyclePreviewMode, }, ] - : canPreview + : showPreviewToggle ? [ { text: previewMode === 'preview' ? 'Edit' : 'Preview', @@ -1860,7 +1865,7 @@ export function Files() { return ( -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index ba4dce41d4c..dc7bdc82e58 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -7,6 +7,7 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { isCsvStreamOnly, + isMarkdownFile, RICH_PREVIEWABLE_EXTENSIONS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' @@ -98,6 +99,9 @@ export const MothershipView = memo( canEdit && active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) && + // Markdown renders in the single-surface inline editor, which has no raw/split/preview + // modes — so it offers no mode toggle. + !isMarkdownFile({ type: '', name: active.title }) && // Only a CSV's previewability depends on its size (large = read-only, no editor). Wait for // the record before deciding so the toggle doesn't flash on for a large CSV — but don't gate // other rich types (markdown, html, svg, …) on the file list loading. diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index 6e55df80ae3..4715bb39138 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' -type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' interface UseAutosaveOptions { content: string @@ -33,6 +33,7 @@ export function useAutosave({ const [saveStatus, setSaveStatus] = useState('idle') const timerRef = useRef>(undefined) const idleTimerRef = useRef>(undefined) + const displayTimerRef = useRef>(undefined) const savingRef = useRef(false) const onSaveRef = useRef(onSave) onSaveRef.current = onSave @@ -66,7 +67,7 @@ export function useAutosave({ } finally { const elapsed = Date.now() - savingStartRef.current const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed) - setTimeout(() => { + displayTimerRef.current = setTimeout(() => { setSaveStatus(nextStatus) clearTimeout(idleTimerRef.current) idleTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) @@ -89,11 +90,11 @@ export function useAutosave({ return () => { clearTimeout(timerRef.current) clearTimeout(idleTimerRef.current) - if ( - enabledRef.current && - contentRef.current !== savedContentRef.current && - !savingRef.current - ) { + clearTimeout(displayTimerRef.current) + // Flush the latest content on unmount even if a save is mid-flight: that in-flight save + // captured an older snapshot, so skipping here would terminally drop any edit typed since. + // The duplicate PUT is idempotent. + if (enabledRef.current && contentRef.current !== savedContentRef.current) { onSaveRef.current().catch(() => {}) } } diff --git a/apps/sim/package.json b/apps/sim/package.json index be49eca24d7..fe18a3140b0 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -62,6 +62,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", + "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", "@linear/sdk": "40.0.0", @@ -104,6 +105,17 @@ "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-virtual": "3.13.24", + "@tiptap/core": "3.26.1", + "@tiptap/extension-code-block": "3.26.1", + "@tiptap/extension-image": "3.26.1", + "@tiptap/extension-list": "3.26.1", + "@tiptap/extension-placeholder": "3.26.1", + "@tiptap/extension-table": "3.26.1", + "@tiptap/markdown": "3.26.1", + "@tiptap/pm": "3.26.1", + "@tiptap/react": "3.26.1", + "@tiptap/starter-kit": "3.26.1", + "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "ajv": "8.18.0", "better-auth": "1.6.11", diff --git a/bun.lock b/bun.lock index ad58c9cb044..e0c665760fc 100644 --- a/bun.lock +++ b/bun.lock @@ -118,6 +118,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", + "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", "@linear/sdk": "40.0.0", @@ -160,6 +161,17 @@ "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-virtual": "3.13.24", + "@tiptap/core": "3.26.1", + "@tiptap/extension-code-block": "3.26.1", + "@tiptap/extension-image": "3.26.1", + "@tiptap/extension-list": "3.26.1", + "@tiptap/extension-placeholder": "3.26.1", + "@tiptap/extension-table": "3.26.1", + "@tiptap/markdown": "3.26.1", + "@tiptap/pm": "3.26.1", + "@tiptap/react": "3.26.1", + "@tiptap/starter-kit": "3.26.1", + "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "ajv": "8.18.0", "better-auth": "1.6.11", @@ -1568,6 +1580,72 @@ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + "@tiptap/core": ["@tiptap/core@3.26.1", "", { "peerDependencies": { "@tiptap/pm": "3.26.1" } }, "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-WaKjKmUaadgvZDDBk9JOn/oidlOFr6booqJIWHGL5S0aUUTKHS19oGfKQq/l9Z1y1niaRePk0Y4fy/jxCnfKPA=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q=="], + + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.26.1", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-Y3R9wFKP/U9M04JG+0PM/yW3OV+MSbUp6YBKQWZmUu8x6y7TbcNvDsaJ6QEFZt5aRMS6qH1ksYPTOz47JdjcfA=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.26.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.26.1" } }, "sha512-JB6bEJJHxXNAXEXTIAN3/j70p1ARHdeMfhzshGZswWKUWtDibTCrspIp7p1VNeiuVtJ/HB6PpFkGi7yWtQ3RTg=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-t9/VR5k3rGPyhcGau9YvVgaAQ+nP9R9WzS996bQQ7GIrMOTSXb0FWwoQFBiYl83V6VA16Tlj/oScC7SFlA8lvA=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.26.1", "", { "peerDependencies": { "@tiptap/extensions": "3.26.1" } }, "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.26.1", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.26.1", "", { "peerDependencies": { "@tiptap/extensions": "3.26.1" } }, "sha512-BWW1yMQQA4TbEU0LLK+4cd9ebLTuZG5KjHwFMBRD/bGiRW9V1gTWFsCqThBbczcANoQiZK9pn5/4Ad/rGM3HUg=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-gzNb1e/fK6HN+ko1axsrasjK7F1q0Bnm0G4ZY/0eq7pV7s1wZuwoCiGbvUx/9LCFKRV6+94FTqlb0A3NbYN36g=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-eRlv9XxzUL8FobKAiF1WjP35CT2QpbcxxeyYFF7BmGEONvKI7r5g7JGwyGli4Cvclh70h8w6JuoXSmGUVEU65A=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-l9lPZYeSmY90y/2GkQcKaICFD5Atr8sx2SzJGkQzpNC9tRxZXyAHnfJE3OjBkspuGzjWIN0DimxBj4ibz58sKw=="], + + "@tiptap/extension-image": ["@tiptap/extension-image@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-IjoT+kRK4a1sTImvUz257yfk5l9kMxXxfxCfix5AUKdiWyn8SGUjJZapLICcZVY05UDqXmwsBvBK9lHkKX5ERg=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.26.1", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-aLLGLgikuhLFHRbjfUC6D4gRg+NUty4uhW7YkyVl8AxxPME47dPbCOX4H6uLCjEZcn3WnfNuCTr6HCTl0KEmGA=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.26.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.26.1" } }, "sha512-5gLXJUiP763NA6i4HgrtcwUDXPP8820hsaBQyF1Y1VsXNi02uW9FVLe3RZK8jF0NZUNh9CqD0gogYJCbKOUU8A=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.26.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.26.1" } }, "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.26.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.26.1" } }, "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw=="], + + "@tiptap/extension-placeholder": ["@tiptap/extension-placeholder@3.26.1", "", { "peerDependencies": { "@tiptap/extensions": "3.26.1" } }, "sha512-oJCEVmaaUY1Jn5v8KbRMdgYLFH9aptLkir+M0ZMnl+8TTmvMdLK2H02X9ofZQwAb12qreQgb890hB3PFen7TDg=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-7hmQ2mBsA+75GRrJIKYxb+10H23mblEQSGGsv9Ptl7JLaGmj+8sv2HGQGSUT9QBiBVprxaYTqyWFXQC9akfLWg=="], + + "@tiptap/extension-table": ["@tiptap/extension-table@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-epxUhc5ecxsH39lzNejc2WxFPXAXWGs9g2ofKDrIaoSlZlfFHf89/sEGSz048a46E5Sb+fYCtzUvRUUx+aG4xw=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ=="], + + "@tiptap/markdown": ["@tiptap/markdown@3.26.1", "", { "dependencies": { "marked": "^17.0.1" }, "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-PpAi3hZqZnb7IsCiRnD6rZfauj8O19fvSzRRdx99Uwx14VnhznbO3WKpUMyleuLz5KjClidqqtKMQWDM6Wt0dA=="], + + "@tiptap/pm": ["@tiptap/pm@3.26.1", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.7", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.0", "prosemirror-transform": "^1.12.0", "prosemirror-view": "^1.41.8" } }, "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g=="], + + "@tiptap/react": ["@tiptap/react@3.26.1", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.26.1", "@tiptap/extension-floating-menu": "^3.26.1" }, "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Gl7AhTJM7pjQ2WFwdIwD736oQeqUcw3GVaXYmCKtwTSO3F9PszLgeKEp6DvM+CmctTNYhu/apRfzkH3vU0h0uA=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.26.1", "", { "dependencies": { "@tiptap/core": "^3.26.1", "@tiptap/extension-blockquote": "^3.26.1", "@tiptap/extension-bold": "^3.26.1", "@tiptap/extension-bullet-list": "^3.26.1", "@tiptap/extension-code": "^3.26.1", "@tiptap/extension-code-block": "^3.26.1", "@tiptap/extension-document": "^3.26.1", "@tiptap/extension-dropcursor": "^3.26.1", "@tiptap/extension-gapcursor": "^3.26.1", "@tiptap/extension-hard-break": "^3.26.1", "@tiptap/extension-heading": "^3.26.1", "@tiptap/extension-horizontal-rule": "^3.26.1", "@tiptap/extension-italic": "^3.26.1", "@tiptap/extension-link": "^3.26.1", "@tiptap/extension-list": "^3.26.1", "@tiptap/extension-list-item": "^3.26.1", "@tiptap/extension-list-keymap": "^3.26.1", "@tiptap/extension-ordered-list": "^3.26.1", "@tiptap/extension-paragraph": "^3.26.1", "@tiptap/extension-strike": "^3.26.1", "@tiptap/extension-text": "^3.26.1", "@tiptap/extension-underline": "^3.26.1", "@tiptap/extensions": "^3.26.1", "@tiptap/pm": "^3.26.1" } }, "sha512-A0zsvwGU9exLND34F8e8KqUXFSfs835tNN+VC+ZT3yNeaO/WXnlh/Cgal1F6pHHbcxy7RV2CRwJU5S3cWLPxrA=="], + + "@tiptap/suggestion": ["@tiptap/suggestion@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-Bg8IyuDC92InSPzcHvCT3+ZDCJSMJIEINdFg513RPQzwZTw1dsrU0K00XYcDT6lOhZwLM2IVTiE6sZl2GY25Rg=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@trigger.dev/build": ["@trigger.dev/build@4.4.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-t/hYmQiv2SdrUao9scoczrvfhyzSLkuT8DNyiBt9q29GKct37zytWyAo16hpN2Uf+yXh0EkdnkHbfR9odF0YtQ=="], @@ -1732,6 +1810,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], @@ -2328,6 +2408,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], @@ -2712,6 +2794,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "linkifyjs": ["linkifyjs@4.3.3", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="], + "lint-staged": ["lint-staged@16.0.0", "", { "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", "debug": "^4.4.0", "lilconfig": "^3.1.3", "listr2": "^8.3.3", "micromatch": "^4.0.8", "nano-spawn": "^1.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.7.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-sUCprePs6/rbx4vKC60Hez6X10HPkpDJaGcy3D1NdwR7g1RcNkWL8q9mJMreOqmHBTs+1sNFp+wOiX9fr+hoOQ=="], "listr2": ["listr2@6.6.1", "", { "dependencies": { "cli-truncate": "^3.1.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^5.0.1", "rfdc": "^1.3.0", "wrap-ansi": "^8.1.0" }, "peerDependencies": { "enquirer": ">= 2.3.0 < 3" }, "optionalPeers": ["enquirer"] }, "sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg=="], @@ -2770,7 +2854,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -3050,6 +3134,8 @@ "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -3172,6 +3258,32 @@ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="], + + "prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-model": ["prosemirror-model@1.25.8", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-BswA4BLSFEiORV6Vjj/yZBXDbos1zTEnhyeSSgT8psGFhstQS7UJ8/WOLiDos9Byaee27+tml0/DuMNxYR84zg=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], + + "prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], + + "prosemirror-view": ["prosemirror-view@1.41.9", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A=="], + "protobufjs": ["protobufjs@8.0.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -3312,6 +3424,8 @@ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -3678,6 +3792,8 @@ "vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], @@ -4280,6 +4396,8 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], @@ -4384,8 +4502,6 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], - "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], From 89eadf01a6f2b546e0360f8a212dfcdc67c3e1e0 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 17:43:39 -0700 Subject: [PATCH 02/41] fix(files): chain autosave unmount flush after in-flight save The unmount flush no longer fires a concurrent PUT alongside an in-flight save; it awaits the in-flight save and then writes the latest content sequentially, so an out-of-order completion can't clobber newer edits with a stale snapshot (addresses Cursor Bugbot). --- apps/sim/hooks/use-autosave.ts | 63 +++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index 4715bb39138..05e84a5df5a 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -46,6 +46,8 @@ export function useAutosave({ const isDirty = content !== savedContent const savingStartRef = useRef(0) + const inFlightRef = useRef | null>(null) + const unmountedRef = useRef(false) const MIN_SAVING_DISPLAY_MS = 600 const save = useCallback(async () => { @@ -58,25 +60,33 @@ export function useAutosave({ } savingRef.current = true savingStartRef.current = Date.now() - setSaveStatus('saving') - let nextStatus: SaveStatus = 'saved' - try { - await onSaveRef.current() - } catch { - nextStatus = 'error' - } finally { - const elapsed = Date.now() - savingStartRef.current - const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed) - displayTimerRef.current = setTimeout(() => { - setSaveStatus(nextStatus) - clearTimeout(idleTimerRef.current) - idleTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) - savingRef.current = false - if (nextStatus !== 'error' && contentRef.current !== savedContentRef.current) { - save() + if (!unmountedRef.current) setSaveStatus('saving') + const run = (async () => { + let nextStatus: SaveStatus = 'saved' + try { + await onSaveRef.current() + } catch { + nextStatus = 'error' + } finally { + if (unmountedRef.current) { + savingRef.current = false + } else { + const elapsed = Date.now() - savingStartRef.current + const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed) + displayTimerRef.current = setTimeout(() => { + setSaveStatus(nextStatus) + clearTimeout(idleTimerRef.current) + idleTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) + savingRef.current = false + if (nextStatus !== 'error' && contentRef.current !== savedContentRef.current) { + save() + } + }, remaining) } - }, remaining) - } + } + })() + inFlightRef.current = run + await run }, []) useEffect(() => { @@ -88,15 +98,20 @@ export function useAutosave({ useEffect(() => { return () => { + unmountedRef.current = true clearTimeout(timerRef.current) clearTimeout(idleTimerRef.current) clearTimeout(displayTimerRef.current) - // Flush the latest content on unmount even if a save is mid-flight: that in-flight save - // captured an older snapshot, so skipping here would terminally drop any edit typed since. - // The duplicate PUT is idempotent. - if (enabledRef.current && contentRef.current !== savedContentRef.current) { - onSaveRef.current().catch(() => {}) - } + if (!enabledRef.current || contentRef.current === savedContentRef.current) return + // Flush the latest content on unmount, but chain it AFTER any in-flight save rather than + // firing a concurrent PUT: the in-flight save captured an older snapshot, so writing the + // latest sequentially (last) prevents an out-of-order completion from clobbering it. + void (async () => { + await inFlightRef.current + if (contentRef.current !== savedContentRef.current) { + await onSaveRef.current().catch(() => {}) + } + })() } }, []) From ae9e331681b7cf37ced55b8712b5b85c0f5bee08 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 17:57:38 -0700 Subject: [PATCH 03/41] fix(files): read pasted images from clipboard items, not just files Some browsers expose a pasted or copied image only via DataTransfer.items (with an empty files list), so screenshot paste was silently ignored. extractImageFiles now falls back to items; moved to a testable module with unit tests (addresses Cursor Bugbot). --- .../rich-markdown-editor/image-paste.test.ts | 56 +++++++++++++++++++ .../rich-markdown-editor/image-paste.ts | 14 +++++ .../rich-markdown-editor.tsx | 7 +-- 3 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts new file mode 100644 index 00000000000..766e4c77ef6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { extractImageFiles } from './image-paste' + +function imageFile(name = 'shot.png'): File { + return new File([''], name, { type: 'image/png' }) +} + +function transfer( + files: File[], + items: Array<{ kind: string; type: string; file: File | null }> = [] +): DataTransfer { + return { + files, + items: items.map((entry) => ({ + kind: entry.kind, + type: entry.type, + getAsFile: () => entry.file, + })), + } as unknown as DataTransfer +} + +describe('extractImageFiles', () => { + it('returns nothing for a null payload or non-image files', () => { + expect(extractImageFiles(null)).toEqual([]) + expect(extractImageFiles(transfer([new File([''], 'a.txt', { type: 'text/plain' })]))).toEqual( + [] + ) + }) + + it('reads images from the files list (drag-drop)', () => { + const file = imageFile() + expect(extractImageFiles(transfer([file]))).toEqual([file]) + }) + + it('falls back to items when files is empty (pasted screenshot)', () => { + const file = imageFile() + const result = extractImageFiles(transfer([], [{ kind: 'file', type: 'image/png', file }])) + expect(result).toEqual([file]) + }) + + it('ignores non-file and non-image items', () => { + const result = extractImageFiles( + transfer( + [], + [ + { kind: 'string', type: 'text/plain', file: null }, + { kind: 'file', type: 'application/pdf', file: new File([''], 'a.pdf') }, + ] + ) + ) + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts new file mode 100644 index 00000000000..ff72fededf9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts @@ -0,0 +1,14 @@ +/** + * Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to + * `items` — many browsers expose a pasted or copied image (e.g. a screenshot) only through + * `DataTransfer.items` with an empty `files` list, so reading `files` alone misses them. + */ +export function extractImageFiles(transfer: DataTransfer | null): File[] { + if (!transfer) return [] + const fromFiles = Array.from(transfer.files).filter((file) => file.type.startsWith('image/')) + if (fromFiles.length > 0) return fromFiles + return Array.from(transfer.items) + .filter((item) => item.kind === 'file' && item.type.startsWith('image/')) + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) +} 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 8135b310761..db902b53abf 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 @@ -9,6 +9,7 @@ import type { SaveStatus } from '@/hooks/use-autosave' import { PreviewLoadingFrame } from '../preview-shared' import { useEditableFileContent } from '../use-editable-file-content' import { createMarkdownEditorExtensions } from './extensions' +import { extractImageFiles } from './image-paste' import { applyFrontmatter, normalizeLinkHref, @@ -23,12 +24,6 @@ const EXTENSIONS = createMarkdownEditorExtensions({ placeholder: "Write something, or press '/' for commands…", }) -/** Image files from a paste/drop payload (screenshots, dragged files, copied images). */ -function extractImageFiles(transfer: DataTransfer | null): File[] { - if (!transfer) return [] - return Array.from(transfer.files).filter((file) => file.type.startsWith('image/')) -} - interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string From 959a56008d2507ae72c2c96e0c6eddf6f2cba352 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 18:01:47 -0700 Subject: [PATCH 04/41] fix(files): destroy round-trip probe editor on serialization error Wrap the probe serialize() in try/finally so the throwaway Editor is always destroyed even if setContent/getMarkdown throws (addresses Greptile). Adds a test proving PipeSafeTable escapes only interior cell pipes, not structural delimiters. --- .../rich-markdown-editor/round-trip-safety.ts | 10 ++++++---- .../rich-markdown-editor/round-trip.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) 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 b3dd9b4ab3e..4505e13cb63 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 @@ -60,10 +60,12 @@ function stripCode(content: string): string { function serialize(content: string): string { const { frontmatter, body } = splitFrontmatter(content) const editor = new Editor({ extensions: createMarkdownContentExtensions() }) - editor.commands.setContent(body, { contentType: 'markdown' }) - const out = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown())) - editor.destroy() - return out + try { + editor.commands.setContent(body, { contentType: 'markdown' }) + return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown())) + } finally { + editor.destroy() + } } /** diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts index b5eed32d8b3..5a88a09fef8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts @@ -206,6 +206,17 @@ describe('editor markdown round-trip', () => { expect(out).toContain('| --- |') }) + it('escapes only interior cell pipes, not the structural delimiters', () => { + const out = roundTrip('| a | b |\n| --- | --- |\n| one \\| two | three |') + expect(out).toContain('one \\| two') + expect(out).toContain('| three |') + // Every row keeps exactly its two structural columns (3 pipes per line). + for (const line of out.trim().split('\n')) { + expect((line.match(/(? { expect(roundTrip('~~`x`~~')).toContain('~~`x`~~') expect(roundTrip('# ~~`x`~~')).toContain('# ~~`x`~~') From 5df266629354c9b67458cbf64635ad5e3ac164f1 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 18:18:12 -0700 Subject: [PATCH 05/41] fix(resource): hold breadcrumb nav latch across the route swap scheduleClose fired on the pointer/focus exit that immediately follows a click-to-navigate and was clearing the reopen latch before the route swapped, letting the popover flash back open. The latch is now released by a short timer instead (addresses Cursor Bugbot). --- .../resource-header/resource-header.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index d167783d8c7..710b8a9c4bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -371,6 +371,9 @@ interface BreadcrumbLocationPopoverProps { veilBoundaryRef: React.RefObject } +/** How long the reopen latch is held after a click-to-navigate, covering the route swap. */ +const NAVIGATE_LATCH_MS = 250 + function BreadcrumbLocationPopover({ icon: Icon, breadcrumbs, @@ -386,11 +389,14 @@ function BreadcrumbLocationPopover({ * veil and popover content would snap away instead of fading — a visible * flash. {@link navigateAndClose} closes the popover before running the * crumb's handler and latches this so the pointer still resting on the - * trigger can't re-fire `openPopover` mid-navigation. It is cleared on the - * next pointer/focus exit so the popover keeps working when the handler does - * not actually navigate (e.g. an unsaved-changes guard that opens a modal). + * trigger can't re-fire `openPopover` mid-navigation. The latch is held by a + * short timer (not cleared on the next pointer exit, which fires immediately + * and would let the popover flash back open before the route swaps); the + * timer releases it so the popover keeps working when the handler does not + * actually navigate (e.g. an unsaved-changes guard that opens a modal). */ const navigatingRef = useRef(false) + const navigateLatchRef = useRef | null>(null) const rootBreadcrumb = breadcrumbs[0] const clearCloseTimeout = () => { @@ -407,7 +413,6 @@ function BreadcrumbLocationPopover({ } const scheduleClose = () => { - navigatingRef.current = false clearCloseTimeout() closeTimeoutRef.current = setTimeout(() => { setOpen(false) @@ -421,11 +426,17 @@ function BreadcrumbLocationPopover({ clearCloseTimeout() setOpen(false) onClick() + if (navigateLatchRef.current) clearTimeout(navigateLatchRef.current) + navigateLatchRef.current = setTimeout(() => { + navigatingRef.current = false + navigateLatchRef.current = null + }, NAVIGATE_LATCH_MS) } useEffect(() => { return () => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) + if (navigateLatchRef.current) clearTimeout(navigateLatchRef.current) } }, []) From 4022c9e83bf59658f95f68055433d8f76e48ec0b Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 18:22:49 -0700 Subject: [PATCH 06/41] chore(files): drop platform references and non-essential inline comments --- .../resource-header/resource-header.tsx | 63 ++++++++----------- .../components/file-viewer/file-viewer.tsx | 2 - .../rich-markdown-editor/code-block.tsx | 6 +- .../rich-markdown-editor/image.tsx | 4 +- .../rich-markdown-editor/keymap.ts | 2 +- .../markdown-file-editor.tsx | 2 - .../menus/bubble-menu.tsx | 2 +- .../rich-markdown-editor.tsx | 5 +- 8 files changed, 34 insertions(+), 52 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 710b8a9c4bb..f03a8cdcdf2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -371,8 +371,13 @@ interface BreadcrumbLocationPopoverProps { veilBoundaryRef: React.RefObject } -/** How long the reopen latch is held after a click-to-navigate, covering the route swap. */ -const NAVIGATE_LATCH_MS = 250 +/** + * Grace period before a hover-out dismisses the path popover. Covers the gap + * the pointer crosses between the trigger and the popover content (and brief + * jitter at their edges); re-entering either within this window cancels the + * close. Standard hover-intent close delay — not tied to any navigation timing. + */ +const POPOVER_CLOSE_DELAY_MS = 120 function BreadcrumbLocationPopover({ icon: Icon, @@ -382,61 +387,51 @@ function BreadcrumbLocationPopover({ }: BreadcrumbLocationPopoverProps) { const [open, setOpen] = useState(false) const closeTimeoutRef = useRef | null>(null) - /** - * Suppresses reopen for the brief window between a click-to-navigate and the - * route swap. Navigating away tears this popover down (the list and detail - * views render different subtrees), so if `open` were still true the dimming - * veil and popover content would snap away instead of fading — a visible - * flash. {@link navigateAndClose} closes the popover before running the - * crumb's handler and latches this so the pointer still resting on the - * trigger can't re-fire `openPopover` mid-navigation. The latch is held by a - * short timer (not cleared on the next pointer exit, which fires immediately - * and would let the popover flash back open before the route swaps); the - * timer releases it so the popover keeps working when the handler does not - * actually navigate (e.g. an unsaved-changes guard that opens a modal). - */ - const navigatingRef = useRef(false) - const navigateLatchRef = useRef | null>(null) const rootBreadcrumb = breadcrumbs[0] - const clearCloseTimeout = () => { + const cancelScheduledClose = () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current) closeTimeoutRef.current = null } } + /** + * Hover-intent open. Driven only by pointer-/keyboard-enter — never by + * pointer movement. This is what makes the popover dismiss cleanly on a + * click-to-navigate: a stationary click fires no enter event, so once + * {@link navigateAndClose} sets `open` false nothing re-opens it before the + * route swaps. (A move-driven open would re-fire under the resting cursor and + * flash the popover/veil back in mid-navigation.) + */ const openPopover = () => { - if (navigatingRef.current) return - clearCloseTimeout() + cancelScheduledClose() setOpen(true) } const scheduleClose = () => { - clearCloseTimeout() + cancelScheduledClose() closeTimeoutRef.current = setTimeout(() => { setOpen(false) closeTimeoutRef.current = null - }, 120) + }, POPOVER_CLOSE_DELAY_MS) } + /** + * Closes the popover up front, then runs the crumb's handler. Closing first + * lets the veil fade and the popover play its exit animation instead of + * snapping away when navigation unmounts the header. + */ const navigateAndClose = (onClick?: () => void) => { if (!onClick) return - navigatingRef.current = true - clearCloseTimeout() + cancelScheduledClose() setOpen(false) onClick() - if (navigateLatchRef.current) clearTimeout(navigateLatchRef.current) - navigateLatchRef.current = setTimeout(() => { - navigatingRef.current = false - navigateLatchRef.current = null - }, NAVIGATE_LATCH_MS) } useEffect(() => { return () => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) - if (navigateLatchRef.current) clearTimeout(navigateLatchRef.current) } }, []) @@ -453,10 +448,6 @@ function BreadcrumbLocationPopover({ onBlur={scheduleClose} onMouseEnter={openPopover} onMouseLeave={scheduleClose} - onMouseMove={openPopover} - onPointerEnter={openPopover} - onPointerLeave={scheduleClose} - onPointerMove={openPopover} className={cn( chipVariants({ flush: true }), 'max-w-none gap-1.5 px-2 transition-colors', @@ -492,10 +483,6 @@ function BreadcrumbLocationPopover({ )} onMouseEnter={openPopover} onMouseLeave={scheduleClose} - onMouseMove={openPopover} - onPointerEnter={openPopover} - onPointerLeave={scheduleClose} - onPointerMove={openPopover} > diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 21c211e19f2..5ec870844c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -136,8 +136,6 @@ export function FileViewer({ return } - // Markdown renders in the inline rich editor when idle. During agent streaming we keep - // the raw/preview editor, which already handles incremental token reconciliation. if (isMarkdownFile(file) && streamingContent === undefined) { return ( ` tag when - * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML - * (the same convention GitHub uses). Unsized images stay clean `![alt](src)`. + * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to + * preserve its dimensions. Unsized images stay clean `![alt](src)`. */ function imageMarkdown(node: JSONContent): string { const attrs = node.attrs ?? {} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 40bbe14abd2..69a489fa570 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -30,7 +30,7 @@ function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean { * Backspace below a divider is a confusing no-op. * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the * block is already fully selected) falls through to the default whole-document select-all, the - * same scoped behavior as Linear and code editors. + * same scoped behavior as a code editor. * - **ArrowUp/ArrowDown** select an adjacent divider or image (see {@link selectAdjacentLeaf}). */ export const EditorKeymap = Extension.create({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx index 71d530c87e7..4dafc2f984a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx @@ -54,8 +54,6 @@ export function MarkdownFileEditor({ return } - // On a failed fetch, fall back to the raw editor rather than mount the rich editor over empty - // content; a later retry-success resolves `data` and the gate decides normally. if (decisionRef.current === null && error) { return ( null) const editor = editorInstanceRef.current if (!result || !editor) continue - // The doc may have shrunk during the await; clamp so the insert can't land out of bounds. const safePosition = Math.min(position, editor.state.doc.content.size) try { editor From 24641a368f0eac7b8bfda9f410f411e868abbc3f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 18:33:37 -0700 Subject: [PATCH 07/41] fix(files): scope inline markdown editor to the files view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mothership preview was routing streaming markdown through the inline editor path: it showed Monaco during streaming (previewMode fell back to 'editor') and lost the streamed content on the TextEditor→MarkdownFileEditor swap (the TextEditor unmounted before it could reconcile + autosave). The inline rich editor is now opt-in via a FileViewer prop that only the files view sets, so the mothership keeps its raw/preview streaming editor and persists as before. --- .../files/components/file-viewer/file-viewer.tsx | 9 ++++++++- apps/sim/app/workspace/[workspaceId]/files/files.tsx | 1 + .../home/components/mothership-view/mothership-view.tsx | 4 ---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 5ec870844c5..bb444bbdb6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -101,6 +101,12 @@ interface FileViewerProps { streamingMode?: StreamingMode disableStreamingAutoScroll?: boolean previewContextKey?: string + /** + * Render idle markdown in the inline rich editor. Off by default so preview surfaces (e.g. the + * Chat resource view, which streams content and persists via the raw editor) keep the + * mode-toggleable raw/preview editor; the files view opts in. + */ + inlineMarkdownEditor?: boolean } export function FileViewer({ @@ -117,6 +123,7 @@ export function FileViewer({ streamingMode, disableStreamingAutoScroll = false, previewContextKey, + inlineMarkdownEditor = false, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -136,7 +143,7 @@ export function FileViewer({ return } - if (isMarkdownFile(file) && streamingContent === undefined) { + if (inlineMarkdownEditor && isMarkdownFile(file) && streamingContent === undefined) { return ( Date: Thu, 18 Jun 2026 18:41:03 -0700 Subject: [PATCH 08/41] fix(mothership): use the inline markdown editor in the chat resource view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle markdown in the chat resource view now renders the single-surface inline editor (no raw/split/preview pencil toggle), matching the files view. While the agent streams, FileViewer forces the rendered preview instead of Monaco, and the streamed file persists via the agent's server write + the existing content-query invalidation on tool completion — so the idle editor refetches the persisted content. --- .../files/components/file-viewer/file-viewer.tsx | 7 +++++-- .../components/resource-content/resource-content.tsx | 2 ++ .../home/components/mothership-view/mothership-view.tsx | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index bb444bbdb6e..46a716b79b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -143,7 +143,10 @@ export function FileViewer({ return } - if (inlineMarkdownEditor && isMarkdownFile(file) && streamingContent === undefined) { + // In an inline-markdown surface, idle markdown is the rich editor; while the agent streams, + // show the rendered preview (never the raw Monaco view) until the content settles. + const isInlineMarkdown = inlineMarkdownEditor && isMarkdownFile(file) + if (isInlineMarkdown && streamingContent === undefined) { return (
) @@ -605,6 +606,7 @@ function EmbeddedFile({ streamingContent={streamingContent} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + inlineMarkdownEditor />
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index ba4dce41d4c..a88e987b4b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -7,6 +7,7 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { isCsvStreamOnly, + isMarkdownFile, RICH_PREVIEWABLE_EXTENSIONS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' @@ -98,6 +99,8 @@ export const MothershipView = memo( canEdit && active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) && + // Markdown uses the single-surface inline editor, which has no raw/split/preview modes. + !isMarkdownFile({ type: '', name: active.title }) && // Only a CSV's previewability depends on its size (large = read-only, no editor). Wait for // the record before deciding so the toggle doesn't flash on for a large CSV — but don't gate // other rich types (markdown, html, svg, …) on the file list loading. From 312b5ee5c98afed88e9a144c61d08a3276bcc11d Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 18:46:17 -0700 Subject: [PATCH 09/41] refactor(files): collapse the duplicate raw-editor fallback branch in the markdown gate --- .../markdown-file-editor.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx index 4dafc2f984a..c300c98e38a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx @@ -54,23 +54,9 @@ export function MarkdownFileEditor({ return } - if (decisionRef.current === null && error) { - return ( - - ) - } - - if (decisionRef.current === false) { + // Fall back to the raw editor when the content can't round-trip losslessly, or the fetch failed + // (a later retry-success resolves `data` and the gate decides normally). + if (decisionRef.current === false || (decisionRef.current === null && error)) { return ( Date: Thu, 18 Jun 2026 18:51:32 -0700 Subject: [PATCH 10/41] fix(mothership): swap to the inline editor once a file preview finishes streaming The preview session keeps status='complete' and previewText after streaming ends, so streamingContent stayed defined and the file stuck on the read-only rendered preview. Treat content as streaming only while status==='streaming'; once complete the EmbeddedFile sees no streamingContent and mounts the editable inline editor (which refetches the persisted content). The synthetic streaming-file stays a pure preview. --- .../components/resource-content/resource-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 66612e3bf74..030128af605 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -125,6 +125,7 @@ export const ResourceContent = memo(function ResourceContent({ !!previewSession && resolveFileCategory(null, previewSession.fileName) === 'text-editable' const textStreamingContent = isTextPreview && + previewSession?.status === 'streaming' && typeof previewSession?.previewText === 'string' && hasRenderableFilePreviewContent(previewSession) ? previewSession.previewText @@ -142,7 +143,6 @@ export const ResourceContent = memo(function ResourceContent({ streamingMode='replace' disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} - inlineMarkdownEditor />
) From dc48ea8688fbe439bf63df33b4adec494e87a028 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 19:01:37 -0700 Subject: [PATCH 11/41] Revert "fix(mothership): swap to the inline editor once a file preview finishes streaming" This reverts commit 25b12e4caa389109c2d5ed7f7d122e35d441f980. --- .../components/resource-content/resource-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 030128af605..66612e3bf74 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -125,7 +125,6 @@ export const ResourceContent = memo(function ResourceContent({ !!previewSession && resolveFileCategory(null, previewSession.fileName) === 'text-editable' const textStreamingContent = isTextPreview && - previewSession?.status === 'streaming' && typeof previewSession?.previewText === 'string' && hasRenderableFilePreviewContent(previewSession) ? previewSession.previewText @@ -143,6 +142,7 @@ export const ResourceContent = memo(function ResourceContent({ streamingMode='replace' disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + inlineMarkdownEditor /> ) From ce582c0273d54e63f15d446b53c0e5fa1f4b37fa Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 19:01:37 -0700 Subject: [PATCH 12/41] Revert "fix(mothership): use the inline markdown editor in the chat resource view" This reverts commit 9430aa7fdc5050d002f2312d31dcf4a255e2de18. --- .../files/components/file-viewer/file-viewer.tsx | 7 ++----- .../components/resource-content/resource-content.tsx | 2 -- .../home/components/mothership-view/mothership-view.tsx | 3 --- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 46a716b79b7..bb444bbdb6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -143,10 +143,7 @@ export function FileViewer({ return } - // In an inline-markdown surface, idle markdown is the rich editor; while the agent streams, - // show the rendered preview (never the raw Monaco view) until the content settles. - const isInlineMarkdown = inlineMarkdownEditor && isMarkdownFile(file) - if (isInlineMarkdown && streamingContent === undefined) { + if (inlineMarkdownEditor && isMarkdownFile(file) && streamingContent === undefined) { return ( ) @@ -606,7 +605,6 @@ function EmbeddedFile({ streamingContent={streamingContent} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} - inlineMarkdownEditor /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index a88e987b4b8..ba4dce41d4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -7,7 +7,6 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { isCsvStreamOnly, - isMarkdownFile, RICH_PREVIEWABLE_EXTENSIONS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' @@ -99,8 +98,6 @@ export const MothershipView = memo( canEdit && active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) && - // Markdown uses the single-surface inline editor, which has no raw/split/preview modes. - !isMarkdownFile({ type: '', name: active.title }) && // Only a CSV's previewability depends on its size (large = read-only, no editor). Wait for // the record before deciding so the toggle doesn't flash on for a large CSV — but don't gate // other rich types (markdown, html, svg, …) on the file list loading. From f0bd78be68e21965ce58e43cad4fdbb959d199b8 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 21:14:36 -0700 Subject: [PATCH 13/41] feat(files): rich markdown editor across files + chat, read-only for unsafe, robust load/save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat resource view streams into the rich editor (streamdown while streaming → editable on completion); agent persists server-side, editor never saves mid-stream - round-trip-unsafe / >128KB markdown renders read-only in the rich editor (no Monaco, no corruption) - markdown always uses the rich editor (dropped the inline-markdown opt-in flag) - editor loads content as TipTap's initial content keyed by file id — strict-mode/SSR-safe, no content-sync effect - fix autosave "Saving…" status suppression under React strict mode - lock the streamed-file persistence handoff with a state-machine lifecycle test --- .../components/file-viewer/file-viewer.tsx | 21 ++- .../markdown-file-editor.tsx | 62 +++---- .../rich-markdown-editor.tsx | 167 +++++++++++------- .../file-viewer/text-editor-state.test.ts | 56 ++++++ .../workspace/[workspaceId]/files/files.tsx | 7 +- .../resource-content/resource-content.tsx | 4 + .../mothership-view/mothership-view.tsx | 6 +- apps/sim/hooks/use-autosave.ts | 4 + 8 files changed, 219 insertions(+), 108 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index bb444bbdb6e..d6727c45b27 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -101,12 +101,6 @@ interface FileViewerProps { streamingMode?: StreamingMode disableStreamingAutoScroll?: boolean previewContextKey?: string - /** - * Render idle markdown in the inline rich editor. Off by default so preview surfaces (e.g. the - * Chat resource view, which streams content and persists via the raw editor) keep the - * mode-toggleable raw/preview editor; the files view opts in. - */ - inlineMarkdownEditor?: boolean } export function FileViewer({ @@ -123,7 +117,6 @@ export function FileViewer({ streamingMode, disableStreamingAutoScroll = false, previewContextKey, - inlineMarkdownEditor = false, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -135,6 +128,14 @@ export function FileViewer({ if (isCsvStreamOnly(file)) { return } + // Markdown renders through the inline rich editor (non-editable) so the public share + // surface matches the in-app reading experience; canEdit={false} disables autosave, + // the bubble menu, and every other editing affordance. + if (isMarkdownFile(file)) { + return ( + + ) + } return } // A large CSV can't be loaded whole into the editor (the browser OOMs on the full text). @@ -143,7 +144,7 @@ export function FileViewer({ return } - if (inlineMarkdownEditor && isMarkdownFile(file) && streamingContent === undefined) { + if (isMarkdownFile(file)) { return ( ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx index c300c98e38a..09ff8fe838c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx @@ -5,7 +5,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files' import type { SaveStatus } from '@/hooks/use-autosave' import { PreviewLoadingFrame } from '../preview-shared' -import { TextEditor } from '../text-editor' +import type { StreamingMode } from '../text-editor-state' import { RichMarkdownEditor } from './rich-markdown-editor' import { isRoundTripSafe } from './round-trip-safety' @@ -17,22 +17,22 @@ interface MarkdownFileEditorProps { onDirtyChange?: (isDirty: boolean) => void onSaveStatusChange?: (status: SaveStatus) => void saveRef?: React.MutableRefObject<(() => Promise) | null> + streamingContent?: string + streamingMode?: StreamingMode + disableStreamingAutoScroll?: boolean + previewContextKey?: string } /** - * Chooses the editing surface for a markdown file. Almost every file renders in the inline - * {@link RichMarkdownEditor}, but a small set of constructs can't survive the markdown - * round-trip losslessly (a linked image, inline code containing a backtick). For those we fall - * back to the raw {@link TextEditor} so the file is never silently corrupted on save. + * Renders a markdown file in the inline {@link RichMarkdownEditor} — the single surface for + * markdown everywhere. A small tail of constructs can't survive the markdown round-trip losslessly + * (raw HTML, footnotes, linked images, >128KB); editing those would corrupt them, so the gate marks + * them read-only (autosave never fires) while still rendering them in the same rich editor. There is + * no separate raw/Monaco editor. * - * The gate peeks the (React Query-cached) content before mounting either editor, so the chosen - * surface re-reads the same content instantly and only one autosave engine is ever live. - * - * The decision is made once — on the first loaded content — and locked for the lifetime of the - * mount (the component is keyed by file id, so it remounts per file). This keeps the editor from - * ever swapping out from under the user on a background refetch (window focus, post-save), and - * keeps the round-trip probe off the hot path. Anything typed in the rich editor is inherently - * round-trip-safe, so the lock can never cause silent data loss. + * The round-trip decision is made once, on the first SETTLED content, and locked for the mount + * (keyed by file id). It is deferred while streaming — partial content would misclassify, and the + * editor renders the live stream read-only regardless of the eventual verdict. */ export function MarkdownFileEditor({ file, @@ -42,45 +42,37 @@ export function MarkdownFileEditor({ onDirtyChange, onSaveStatusChange, saveRef, + streamingContent, + streamingMode, + disableStreamingAutoScroll, + previewContextKey, }: MarkdownFileEditorProps) { - const { data, isLoading, error } = useWorkspaceFileContent(workspaceId, file.id, file.key) + const { data, isLoading } = useWorkspaceFileContent(workspaceId, file.id, file.key) + + const isStreaming = streamingContent !== undefined const decisionRef = useRef(null) - if (decisionRef.current === null && data !== undefined) { + if (decisionRef.current === null && !isStreaming && data !== undefined) { decisionRef.current = isRoundTripSafe(data) } - if (decisionRef.current === null && isLoading) { + if (decisionRef.current === null && isLoading && !isStreaming) { return } - // Fall back to the raw editor when the content can't round-trip losslessly, or the fetch failed - // (a later retry-success resolves `data` and the gate decides normally). - if (decisionRef.current === false || (decisionRef.current === null && error)) { - return ( - - ) - } - return ( ) } 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 64873fee5b4..0ea99c34956 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 @@ -1,12 +1,15 @@ 'use client' -import { memo, useEffect, useRef } from 'react' +import { memo, useEffect, useMemo, useRef } from 'react' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' +import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' import type { SaveStatus } from '@/hooks/use-autosave' +import { PreviewPanel } from '../preview-panel' import { PreviewLoadingFrame } from '../preview-shared' +import type { StreamingMode } from '../text-editor-state' import { useEditableFileContent } from '../use-editable-file-content' import { createMarkdownEditorExtensions } from './extensions' import { extractImageFiles } from './image-paste' @@ -32,14 +35,24 @@ interface RichMarkdownEditorProps { onDirtyChange?: (isDirty: boolean) => void onSaveStatusChange?: (status: SaveStatus) => void saveRef?: React.MutableRefObject<(() => Promise) | null> + streamingContent?: string + streamingMode?: StreamingMode + disableStreamingAutoScroll?: boolean + previewContextKey?: string } /** - * Inline WYSIWYG markdown editor (TipTap/ProseMirror) for markdown files. Renders a - * single editing surface — markdown is transformed inline as you type — with no raw/preview - * split. Content loading and autosave are delegated to - * {@link useEditableFileContent}; this component only renders the editor and bridges - * markdown in and out of it. + * Inline WYSIWYG markdown editor (TipTap/ProseMirror) for markdown files — a single editing surface + * (markdown transformed inline as you type), no raw/preview split. Owns the file lifecycle through a + * single {@link useEditableFileContent} engine: while agent output streams in (and during the + * post-stream reconcile) it shows the read-only {@link PreviewPanel} with autosave disabled, so the + * editor never races the agent's server-side write. Once content is loaded and settled it mounts the + * actual editor. + * + * The editor is mounted only once content is ready, and is keyed by file id — so the loaded markdown + * is the editor's *initial* `content` (parsed at create time), not pushed in by a sync effect. That + * keeps it robust to TipTap's strict-mode/SSR instance lifecycle: there is no content-sync effect to + * race, so a freshly created (or strict-mode-recreated) editor is always born with the right document. */ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ file, @@ -49,6 +62,10 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onDirtyChange, onSaveStatusChange, saveRef, + streamingContent, + streamingMode, + disableStreamingAutoScroll = false, + previewContextKey, }: RichMarkdownEditorProps) { const { content, @@ -61,31 +78,93 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ file, workspaceId, canEdit, + streamingContent, + streamingMode, onDirtyChange, onSaveStatusChange, saveRef, }) - const isEditable = canEdit && !isStreamInteractionLocked + if (isContentLoading) return + + if (hasContentError) { + return ( +
+

Failed to load file content

+
+ ) + } + + if (isStreamInteractionLocked) { + return ( + + ) + } + + return ( + + ) +}) - const syncedMarkdownRef = useRef(null) - const frontmatterRef = useRef('') - const frontmatterSourceRef = useRef(null) - const hasAutoFocusedRef = useRef(false) - const setDraftContentRef = useRef(setDraftContent) - setDraftContentRef.current = setDraftContent - const saveImmediatelyRef = useRef(saveImmediately) - saveImmediatelyRef.current = saveImmediately +interface LoadedRichMarkdownEditorProps { + file: WorkspaceFileRecord + workspaceId: string + initialContent: string + isEditable: boolean + autoFocus?: boolean + onChange: (markdown: string) => void + onSaveShortcut: () => Promise +} + +/** + * The mounted TipTap editor. Receives the file's loaded markdown as {@link initialContent} and hands + * it to {@link useEditor} as the initial document (parsed at create time by the markdown extension), + * so there is no imperative content sync. Frontmatter is held aside and re-applied on every change, + * so the editor only ever round-trips the body. + */ +function LoadedRichMarkdownEditor({ + file, + workspaceId, + initialContent, + isEditable, + autoFocus, + onChange, + onSaveShortcut, +}: LoadedRichMarkdownEditorProps) { + const { frontmatter, body } = useMemo(() => splitFrontmatter(initialContent), [initialContent]) + const frontmatterRef = useRef(frontmatter) + frontmatterRef.current = frontmatter + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + const onSaveShortcutRef = useRef(onSaveShortcut) + onSaveShortcutRef.current = onSaveShortcut const uploadFile = useUploadWorkspaceFile() const editorInstanceRef = useRef(null) /** - * Upload each image to the workspace, then insert it at `at` (paste = caret, drop = cursor - * under the pointer). Sequential so multiple images stack in order; the upload hook surfaces - * its own success/error toasts, so a failed upload is skipped without interrupting the rest. - * Held in a ref (reassigned each render) so the once-built `editorProps` handlers always reach - * the latest workspace/file values. + * Upload each image to the workspace, then insert it at `at` (paste = caret, drop = cursor under + * the pointer). Sequential so multiple images stack in order; the upload hook surfaces its own + * success/error toasts, so a failed upload is skipped without interrupting the rest. Held in a ref + * (reassigned each render) so the once-built `editorProps` handlers always reach the latest values. */ const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => Promise.resolve() @@ -114,24 +193,21 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ } } - if (content !== frontmatterSourceRef.current) { - frontmatterSourceRef.current = content - frontmatterRef.current = splitFrontmatter(content).frontmatter - } - const editor = useEditor({ extensions: EXTENSIONS, editable: isEditable, autofocus: autoFocus ? 'end' : false, immediatelyRender: false, shouldRerenderOnTransaction: false, + content: body, + contentType: 'markdown', editorProps: { attributes: { class: 'rich-markdown-prose' }, handleKeyDown: (_view, event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' if (!isSaveShortcut) return false event.preventDefault() - void saveImmediatelyRef.current() + void onSaveShortcutRef.current() return true }, handleClick: (_view, _pos, event) => { @@ -160,47 +236,18 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ }, }, onUpdate: ({ editor }) => { - const body = postProcessSerializedMarkdown(editor.getMarkdown()) - const full = applyFrontmatter(frontmatterRef.current, body) - syncedMarkdownRef.current = full - setDraftContentRef.current(full) + const md = postProcessSerializedMarkdown(editor.getMarkdown()) + onChangeRef.current(applyFrontmatter(frontmatterRef.current, md)) }, }) - - useEffect(() => { - editorInstanceRef.current = editor - }, [editor]) + editorInstanceRef.current = editor useEffect(() => { editor?.setEditable(isEditable) }, [editor, isEditable]) - useEffect(() => { - if (!editor || content === syncedMarkdownRef.current) return - syncedMarkdownRef.current = content - editor - .chain() - .setMeta('addToHistory', false) - .setContent(splitFrontmatter(content).body, { contentType: 'markdown', emitUpdate: false }) - .run() - if (autoFocus && !hasAutoFocusedRef.current) { - hasAutoFocusedRef.current = true - editor.commands.focus('end') - } - }, [editor, content, autoFocus]) - - if (isContentLoading) return - - if (hasContentError) { - return ( -
-

Failed to load file content

-
- ) - } - return ( -
+
{editor && }
) -}) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts index 06014a453ec..8eccee0c6e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts @@ -441,3 +441,59 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m expect(next.lastStreamedContent).toBeNull() }) }) + +/** + * The chat resource view (mothership) streams agent output into an existing, initially-empty file + * (the agent's `create_file` writes an empty buffer, then `edit_content` persists the real content + * server-side). The editor must never autosave during this handoff — a save would race the agent's + * server write and could clobber it with empty/stale content. The engine guarantees this by staying + * stream-locked (phase `streaming`/`reconciling`, where autosave is disabled) from the first chunk + * until fetched content reconciles to the agent's saved write — at which point `content` and + * `savedContent` both equal that write, so the now-enabled autosave sees a clean doc and never fires. + */ +describe('syncTextEditorContentState — mothership streamed-file lifecycle (replace mode)', () => { + const isStreamLocked = (s: TextEditorContentState) => + s.phase === 'streaming' || s.phase === 'reconciling' + + it('stays locked through streaming + reconcile, then finalizes to the agent write with no empty save', () => { + const opts = (fetchedContent: string | undefined, streamingContent: string | undefined) => ({ + canReconcileToFetchedContent: true, + fetchedContent, + streamingContent, + streamingMode: 'replace' as const, + }) + + // 1. Empty file (create_file wrote an empty buffer); first streamed chunk arrives. + let state = syncTextEditorContentState( + INITIAL_TEXT_EDITOR_CONTENT_STATE, + opts('', '# Story\n\nOnce') + ) + expect(state.phase).toBe('streaming') + expect(isStreamLocked(state)).toBe(true) + + // 2. More chunks stream in (replace mode → content tracks the latest snapshot). + state = syncTextEditorContentState(state, opts('', '# Story\n\nOnce upon a time')) + expect(state.content).toBe('# Story\n\nOnce upon a time') + expect(isStreamLocked(state)).toBe(true) + + // 3. Stream completes (streamingContent cleared) but the agent's server write hasn't been + // refetched yet — must hold in reconciling (still locked, autosave still disabled). + state = syncTextEditorContentState(state, opts('', undefined)) + expect(state.phase).toBe('reconciling') + expect(isStreamLocked(state)).toBe(true) + expect(state.savedContent).toBe('') + + // 4. The agent's `edit_content` write lands in the refetched content → finalize to ready with + // content === savedContent === the agent write. Never an empty savedContent. + const agentWrite = '# Story\n\nOnce upon a time, the end.' + state = syncTextEditorContentState(state, opts(agentWrite, undefined)) + expect(state.phase).toBe('ready') + expect(isStreamLocked(state)).toBe(false) + expect(state.content).toBe(agentWrite) + expect(state.savedContent).toBe(agentWrite) + expect(state.lastStreamedContent).toBeNull() + + // 5. Now-enabled autosave compares content vs savedContent: equal → it never fires a save. + expect(state.content).toBe(state.savedContent) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 56a409dda92..46fb7b5c133 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -22,7 +22,7 @@ import { toast, Upload, } from '@/components/emcn' -import { Download, Link } from '@/components/emcn/icons' +import { Download, Send } from '@/components/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { captureEvent } from '@/lib/posthog/client' import { triggerFileDownload } from '@/lib/uploads/client/download' @@ -1075,7 +1075,7 @@ export function Files() { ...(canEdit ? [ { label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename }, - { label: 'Share', icon: Link, onClick: handleShareSelected }, + { label: 'Share', icon: Send, onClick: handleShareSelected }, { label: 'Delete', icon: Trash, onClick: handleDeleteSelected }, ] : []), @@ -1482,7 +1482,7 @@ export function Files() { ? [ { text: 'Share', - icon: Link, + icon: Send, onSelect: handleShareSelected, }, { @@ -1891,7 +1891,6 @@ export function Files() { onDirtyChange={setIsDirty} onSaveStatusChange={setSaveStatus} saveRef={saveRef} - inlineMarkdownEditor /> { + // Reset on every (re)mount, not only set on unmount: React strict mode runs effects + // mount → cleanup → mount, so without this the flag would stay `true` after the dev + // double-invoke and permanently suppress the "saving"/"saved" status updates below. + unmountedRef.current = false return () => { unmountedRef.current = true clearTimeout(timerRef.current) From 511fc6b5b2d1a2418544e4dd377b640f7704b575 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 21:23:15 -0700 Subject: [PATCH 14/41] chore(files): remove dead code (unused FileViewer logger + EmbeddedWorkflowActions router) --- .../[workspaceId]/files/components/file-viewer/file-viewer.tsx | 3 --- .../components/resource-content/resource-content.tsx | 1 - 2 files changed, 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index d6727c45b27..bf12e0f8057 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -1,7 +1,6 @@ 'use client' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createLogger } from '@sim/logger' import { Music } from 'lucide-react' import dynamic from 'next/dynamic' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -38,8 +37,6 @@ const MarkdownFileEditor = dynamic( { ssr: false, loading: () => } ) -const logger = createLogger('FileViewer') - /** * CSVs at or below this size load fully into the editor (editable, with an inline preview). * Larger CSVs would OOM the browser on `response.text()`, so they render a read-only, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index b65ca10697c..aef2f9f5644 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -266,7 +266,6 @@ interface EmbeddedWorkflowActionsProps { } export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { - const router = useRouter() const { navigateToSettings } = useSettingsNavigation() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) From 462cd811cde7abd432cc523791ee435e99c4e070 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 21:35:37 -0700 Subject: [PATCH 15/41] fix(files): derive markdown round-trip verdict from live content, not a locked stale snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate locked isRoundTripSafe on the first post-stream snapshot, which is often the empty create_file buffer before the agent's server write lands — wrongly leaving an unsafe document editable. Derive the verdict from the current content (memoized on the bytes) so canEdit tracks the real payload. --- .../markdown-file-editor.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx index 09ff8fe838c..409e19511d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef } from 'react' +import { useMemo } from 'react' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files' import type { SaveStatus } from '@/hooks/use-autosave' @@ -29,10 +29,6 @@ interface MarkdownFileEditorProps { * (raw HTML, footnotes, linked images, >128KB); editing those would corrupt them, so the gate marks * them read-only (autosave never fires) while still rendering them in the same rich editor. There is * no separate raw/Monaco editor. - * - * The round-trip decision is made once, on the first SETTLED content, and locked for the mount - * (keyed by file id). It is deferred while streaming — partial content would misclassify, and the - * editor renders the live stream read-only regardless of the eventual verdict. */ export function MarkdownFileEditor({ file, @@ -51,12 +47,17 @@ export function MarkdownFileEditor({ const isStreaming = streamingContent !== undefined - const decisionRef = useRef(null) - if (decisionRef.current === null && !isStreaming && data !== undefined) { - decisionRef.current = isRoundTripSafe(data) - } + // Whether the file's content round-trips losslessly through the editor. Derived from the live + // content — memoized on the bytes, so it only re-probes when they actually change — rather than + // locked on the first snapshot: locking could capture a stale/empty buffer (e.g. a just-created + // file before an agent stream's server write lands) and wrongly leave an unsafe document editable. + // Deferred while streaming: the content is partial and the editor renders the stream read-only. + const isContentRoundTripSafe = useMemo( + () => (isStreaming || data === undefined ? null : isRoundTripSafe(data)), + [isStreaming, data] + ) - if (decisionRef.current === null && isLoading && !isStreaming) { + if (isContentRoundTripSafe === null && isLoading && !isStreaming) { return } @@ -64,7 +65,7 @@ export function MarkdownFileEditor({ Date: Thu, 18 Jun 2026 21:42:32 -0700 Subject: [PATCH 16/41] =?UTF-8?q?test(files):=20guard=20the=20rich=20edito?= =?UTF-8?q?r=20dirty=20signal=20=E2=80=94=20open=20is=20never=20dirty,=20e?= =?UTF-8?q?dits=20emit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rich-markdown-editor/dirty-signal.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts new file mode 100644 index 00000000000..870907a9a38 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment jsdom + * + * The rich editor uses TipTap's initial-content model: opening a file loads its markdown as the + * editor's initial `content`, which must NOT emit an update — so a freshly opened file is never + * marked dirty (no spurious autosave / "unsaved changes"). Only a genuine edit emits, which is what + * flips the dirty/autosave state on. These two cases guard exactly that contract. + */ +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(content: string, onUpdate: () => void): Editor { + return new Editor({ + extensions: createMarkdownContentExtensions(), + content, + contentType: 'markdown', + onUpdate, + }) +} + +describe('rich markdown editor — dirty signal', () => { + it('opening a file emits no update (never dirty on open), including markdown that normalizes', () => { + // A trailing newline and `_emphasis_` both normalize on serialization; opening must still be clean. + let updates = 0 + editor = mount('# Title\n\nsome _emphasis_ here\n', () => { + updates++ + }) + expect(updates).toBe(0) + expect(editor.isEmpty).toBe(false) + }) + + it('opening an empty file emits no update and is editable', () => { + let updates = 0 + editor = mount('', () => { + updates++ + }) + expect(updates).toBe(0) + }) + + it('a genuine edit emits an update (marks dirty → triggers autosave)', () => { + let updates = 0 + editor = mount('hello', () => { + updates++ + }) + editor.commands.insertContent(' world') + expect(updates).toBeGreaterThan(0) + }) +}) From f844a6a65dafe9bf3ba6839b98cabfd51ccd79ea Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 21:50:01 -0700 Subject: [PATCH 17/41] fix(files): lock the markdown round-trip verdict on opened content, never strand dirty edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-trip-safety verdict now gates editability only at open time — computed once, on the exact content the editor mounts with, and locked for its lifetime. A dirty document is round-trip-safe by construction (the editor only emits safe markdown), so the verdict must never flip off mid-edit: doing so disabled autosave, ⌘S, the toolbar Save and the unmount flush, stranding unsaved edits. Locking on the opened (reconciled) content also fixes the stale post-stream empty-buffer snapshot, and lets the redundant MarkdownFileEditor gate (plus its duplicate content fetch) be deleted. --- .../components/file-viewer/file-viewer.tsx | 8 +- .../markdown-file-editor.tsx | 79 ------------------- .../rich-markdown-editor.tsx | 20 ++++- 3 files changed, 21 insertions(+), 86 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-file-editor.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index bf12e0f8057..21c7232a6b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -32,8 +32,8 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView ssr: false, }) -const MarkdownFileEditor = dynamic( - () => import('./rich-markdown-editor/markdown-file-editor').then((m) => m.MarkdownFileEditor), +const RichMarkdownEditor = dynamic( + () => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor), { ssr: false, loading: () => } ) @@ -130,7 +130,7 @@ export function FileViewer({ // the bubble menu, and every other editing affordance. if (isMarkdownFile(file)) { return ( - + ) } return @@ -143,7 +143,7 @@ export function FileViewer({ if (isMarkdownFile(file)) { return ( - void - onSaveStatusChange?: (status: SaveStatus) => void - saveRef?: React.MutableRefObject<(() => Promise) | null> - streamingContent?: string - streamingMode?: StreamingMode - disableStreamingAutoScroll?: boolean - previewContextKey?: string -} - -/** - * Renders a markdown file in the inline {@link RichMarkdownEditor} — the single surface for - * markdown everywhere. A small tail of constructs can't survive the markdown round-trip losslessly - * (raw HTML, footnotes, linked images, >128KB); editing those would corrupt them, so the gate marks - * them read-only (autosave never fires) while still rendering them in the same rich editor. There is - * no separate raw/Monaco editor. - */ -export function MarkdownFileEditor({ - file, - workspaceId, - canEdit, - autoFocus, - onDirtyChange, - onSaveStatusChange, - saveRef, - streamingContent, - streamingMode, - disableStreamingAutoScroll, - previewContextKey, -}: MarkdownFileEditorProps) { - const { data, isLoading } = useWorkspaceFileContent(workspaceId, file.id, file.key) - - const isStreaming = streamingContent !== undefined - - // Whether the file's content round-trips losslessly through the editor. Derived from the live - // content — memoized on the bytes, so it only re-probes when they actually change — rather than - // locked on the first snapshot: locking could capture a stale/empty buffer (e.g. a just-created - // file before an agent stream's server write lands) and wrongly leave an unsafe document editable. - // Deferred while streaming: the content is partial and the editor renders the stream read-only. - const isContentRoundTripSafe = useMemo( - () => (isStreaming || data === undefined ? null : isRoundTripSafe(data)), - [isStreaming, data] - ) - - if (isContentRoundTripSafe === null && isLoading && !isStreaming) { - return - } - - return ( - - ) -} 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 0ea99c34956..ed1f00f4097 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 @@ -20,6 +20,7 @@ import { splitFrontmatter, } from './markdown-fidelity' import { EditorBubbleMenu } from './menus/bubble-menu' +import { isRoundTripSafe } from './round-trip-safety' import '@/components/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -116,7 +117,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ file={file} workspaceId={workspaceId} initialContent={content} - isEditable={canEdit} + canEdit={canEdit} autoFocus={autoFocus} onChange={setDraftContent} onSaveShortcut={saveImmediately} @@ -128,7 +129,7 @@ interface LoadedRichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string initialContent: string - isEditable: boolean + canEdit: boolean autoFocus?: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise @@ -144,11 +145,24 @@ function LoadedRichMarkdownEditor({ file, workspaceId, initialContent, - isEditable, + canEdit, autoFocus, onChange, onSaveShortcut, }: LoadedRichMarkdownEditorProps) { + // Whether the opened content round-trips losslessly through the editor — computed once, on the + // exact content the editor opens with (keyed by file id, so it remounts per file), and locked for + // the editor's lifetime. A round-trip-unsafe document (raw HTML, footnotes, >128KB, …) opens + // read-only so an edit can't corrupt it; a safe one stays editable. It is never re-derived: a + // dirty document is round-trip-safe by construction (the editor only emits safe markdown), so + // flipping editability off mid-edit would only strand unsaved edits (autosave, ⌘S, the toolbar + // Save, and the unmount flush all gate on it). + const roundTripSafeRef = useRef(null) + if (roundTripSafeRef.current === null) { + roundTripSafeRef.current = isRoundTripSafe(initialContent) + } + const isEditable = canEdit && roundTripSafeRef.current + const { frontmatter, body } = useMemo(() => splitFrontmatter(initialContent), [initialContent]) const frontmatterRef = useRef(frontmatter) frontmatterRef.current = frontmatter From 89a269e0fcdd3a6dde1a7665fdf1028d51ab464e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 18 Jun 2026 22:32:02 -0700 Subject: [PATCH 18/41] improvement(file-viewer): reuse shared copy hook, lazy frontmatter split - code-block: replace hand-rolled copy-with-timeout with shared useCopyToClipboard - rich-markdown-editor: compute frontmatter split once via lazy ref, drop redundant frontmatterRef - round-trip-safety: correct stale comments (read-only, not raw editor fallback) --- .../rich-markdown-editor/code-block.tsx | 21 ++++--------------- .../rich-markdown-editor.tsx | 15 ++++++++----- .../rich-markdown-editor/round-trip-safety.ts | 8 +++---- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 05f6c56314d..b86a835665c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import type { JSONContent } from '@tiptap/core' import { CodeBlock } from '@tiptap/extension-code-block' import type { ReactNodeViewProps } from '@tiptap/react' @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { detectLanguage } from './detect-language' const PLAIN = 'plain' @@ -35,8 +36,8 @@ const CONTROL_CLASS = function CodeBlockView({ node, updateAttributes }: ReactNodeViewProps) { const [wrap, setWrap] = useState(false) - const [copied, setCopied] = useState(false) const [menuOpen, setMenuOpen] = useState(false) + const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) const explicitLanguage = node.attrs.language as string | null const language = explicitLanguage ?? detectLanguage(node.textContent) ?? PLAIN const label = @@ -44,20 +45,6 @@ function CodeBlockView({ node, updateAttributes }: ReactNodeViewProps) { explicitLanguage ?? 'Plain text' - useEffect(() => { - if (!copied) return - const timer = setTimeout(() => setCopied(false), 1500) - return () => clearTimeout(timer) - }, [copied]) - - const copy = async () => { - const ok = await navigator.clipboard - ?.writeText(node.textContent) - .then(() => true) - .catch(() => false) - if (ok) setCopied(true) - } - return (
event.preventDefault()} - onClick={copy} + onClick={() => copy(node.textContent)} className={CONTROL_CLASS} > {copied ? : } 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 ed1f00f4097..f224c527bc0 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 @@ -1,6 +1,6 @@ 'use client' -import { memo, useEffect, useMemo, useRef } from 'react' +import { memo, useEffect, useRef } from 'react' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { cn } from '@/lib/core/utils/cn' @@ -163,9 +163,14 @@ function LoadedRichMarkdownEditor({ } const isEditable = canEdit && roundTripSafeRef.current - const { frontmatter, body } = useMemo(() => splitFrontmatter(initialContent), [initialContent]) - const frontmatterRef = useRef(frontmatter) - frontmatterRef.current = frontmatter + // Split frontmatter off once, on the opened content (stable for the editor's lifetime, like the + // verdict above): the body seeds the editor's initial document, and the frontmatter is re-attached + // on every change so the editor only ever round-trips the body. + const splitRef = useRef<{ frontmatter: string; body: string } | null>(null) + if (splitRef.current === null) { + splitRef.current = splitFrontmatter(initialContent) + } + const { frontmatter, body } = splitRef.current const onChangeRef = useRef(onChange) onChangeRef.current = onChange const onSaveShortcutRef = useRef(onSaveShortcut) @@ -251,7 +256,7 @@ function LoadedRichMarkdownEditor({ }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) - onChangeRef.current(applyFrontmatter(frontmatterRef.current, md)) + onChangeRef.current(applyFrontmatter(frontmatter, md)) }, }) editorInstanceRef.current = editor 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 4505e13cb63..8372b0072f7 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 @@ -9,7 +9,7 @@ import { /** * Above this size we don't run the (synchronous) round-trip probe — building two editors to * serialize a large document blocks the main thread for too long, and a very large markdown file - * is heavier to edit richly anyway, so it opens in the raw editor. + * is heavier to edit richly anyway, so it opens read-only. */ const PROBE_SIZE_LIMIT = 128 * 1024 @@ -48,7 +48,7 @@ const STABLE_LOSS_PATTERNS: ReadonlyArray = [ * or tilde, length-matched on the closer so nested fences strip as one unit) and inline code. * Indented (4-space) code is deliberately NOT stripped — list/paragraph continuation lines are * also indented, and over-stripping would risk missing a real unsafe construct (a false negative, - * which is worse than the rare false positive of an indented code block opening in the raw editor). + * which is worse than the rare false positive of an indented code block opening read-only). */ function stripCode(content: string): string { return content @@ -70,8 +70,8 @@ function serialize(content: string): string { /** * Whether `content` survives the editor's markdown round-trip without data loss or autosave - * churn. Callers fall back to the raw text editor when this is false, so the gate is - * deliberately conservative: it rejects on any doubt rather than risk silently corrupting a file. + * churn. The editor opens the content read-only when this is false, so the probe is deliberately + * conservative: it rejects on any doubt rather than risk an edit silently corrupting a file. * * Two complementary checks: known stable-loss constructs are matched directly (the idempotency * probe is blind to them), and everything else must reach a fixpoint — `serialize(x)` twice in a From b7d87c809fe70c9020be45001d5b73fe616ed823 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 19 Jun 2026 00:10:21 -0700 Subject: [PATCH 19/41] feat(file-viewer): linked images, typed-link input rule, drag-to-reorder, churn fixes - image: round-trip linked images/badges via an href attr + custom markdown tokenizer; make the image a drag handle so it can be grabbed and reordered - link-input-rule: convert typed [text](url) to a link on the closing paren (normalized href) - markdown-paste: render pasted markdown as rich content, guarded against code blocks - round-trip-safety: behavioral link-count check replaces the static linked-image rejection - extensions: trim the table serializer's blank lines to stop interior-table whitespace churn --- .../rich-markdown-editor/extensions.ts | 14 +- .../rich-markdown-editor/image.tsx | 137 +++++++++++-- .../link-input-rule.test.ts | 70 +++++++ .../rich-markdown-editor/link-input-rule.ts | 45 +++++ .../markdown-paste.test.ts | 73 +++++++ .../rich-markdown-editor/markdown-paste.ts | 51 +++++ .../rich-markdown-editor.css | 17 +- .../round-trip-editable-corpus.test.ts | 189 ++++++++++++++++++ .../round-trip-safety.test.ts | 9 +- .../rich-markdown-editor/round-trip-safety.ts | 22 +- .../rich-markdown-editor/round-trip.test.ts | 36 ++++ 11 files changed, 639 insertions(+), 24 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-input-rule.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-input-rule.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-editable-corpus.test.ts 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 2302e7e756f..d7654e9bb2d 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,8 @@ import { CodeBlockWithLanguage, MarkdownCodeBlock } from './code-block' import { CodeBlockHighlight } from './code-highlight' import { MarkdownImage, ResizableImage } from './image' import { EditorKeymap } from './keymap' +import { MarkdownLinkInputRule } from './link-input-rule' +import { MarkdownPaste } from './markdown-paste' import { SlashCommand } from './slash-command/slash-command' /** @@ -29,6 +31,12 @@ const InlineCode = Code.extend({ excludes: '' }) * joins cells with `|` without escaping, so a cell containing a literal pipe silently splits * into phantom columns on round-trip (data loss). Escaping must happen on the `table` node — * `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. + * + * The upstream serializer also wraps the table in its own leading/trailing blank lines; left in, + * the block joiner adds another, so an interior table churns its surrounding whitespace to + * `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single + * blank-line separator — without touching blank lines inside fenced code (those live in the code + * node's text, not here). */ const PipeSafeTable = Table.extend({ renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => @@ -36,7 +44,9 @@ const PipeSafeTable = Table.extend({ ...h, renderChildren: (nodes, separator) => h.renderChildren(nodes, separator).replace(/\|/g, '\\|'), - }), + }) + .replace(/^\n+/, '') + .replace(/\n+$/, ''), }) interface MarkdownEditorExtensionOptions { @@ -79,6 +89,7 @@ export function createMarkdownContentExtensions({ TableRow, TableHeader, TableCell, + MarkdownLinkInputRule, Markdown, ] } @@ -96,6 +107,7 @@ export function createMarkdownEditorExtensions({ CodeBlockHighlight, SlashCommand, EditorKeymap, + MarkdownPaste, Placeholder.configure({ placeholder }), ] } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index d2342198999..9161aa4d0ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -6,6 +6,16 @@ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' const MIN_WIDTH = 64 +/** + * A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical + * form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an + * image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize + * the whole construct ourselves and hang the link target on the image node's `href` attribute, so it + * round-trips losslessly (and the file stays editable rather than opening read-only). + */ +const LINKED_IMAGE_RE = + /^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/ + /** Escape a value for safe interpolation into a double-quoted HTML attribute. */ function escapeAttr(value: string): string { return value @@ -18,25 +28,68 @@ function escapeAttr(value: string): string { /** * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to - * preserve its dimensions. Unsized images stay clean `![alt](src)`. + * preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is + * wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`. */ function imageMarkdown(node: JSONContent): string { const attrs = node.attrs ?? {} const src = typeof attrs.src === 'string' ? attrs.src : '' const alt = typeof attrs.alt === 'string' ? attrs.alt : '' const title = typeof attrs.title === 'string' ? attrs.title : '' + const href = typeof attrs.href === 'string' ? attrs.href : '' + const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : '' const width = attrs.width const height = attrs.height + let image: string if (width || height) { const parts = [`src="${escapeAttr(src)}"`] if (alt) parts.push(`alt="${escapeAttr(alt)}"`) if (title) parts.push(`title="${escapeAttr(title)}"`) if (width) parts.push(`width="${escapeAttr(String(width))}"`) if (height) parts.push(`height="${escapeAttr(String(height))}"`) - return `` + image = `` + } else { + const titlePart = title ? ` "${title}"` : '' + image = `![${alt}](${src}${titlePart})` + } + if (!href) return image + const hrefTitlePart = hrefTitle ? ` "${hrefTitle}"` : '' + return `[${image}](${href}${hrefTitlePart})` +} + +interface MarkdownImageToken { + /** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */ + src?: string + alt?: string + title?: string | null + /** Built-in image token holds the source URL here; our linked token holds the link target. */ + href?: string + hrefTitle?: string | null + /** Built-in image token holds the alt text here. */ + text?: string +} + +/** Map both the built-in image token and our linked-image token onto the image node's attributes. */ +function parseImageToken(token: MarkdownImageToken): JSONContent { + const isLinked = typeof token.src === 'string' + return { + type: 'image', + attrs: isLinked + ? { + src: token.src, + alt: token.alt ?? '', + title: token.title ?? null, + href: token.href ?? null, + hrefTitle: token.hrefTitle ?? null, + } + : { + src: token.href ?? '', + alt: token.text ?? '', + title: token.title ?? null, + href: null, + hrefTitle: null, + }, } - const titlePart = title ? ` "${title}"` : '' - return `![${alt}](${src}${titlePart})` } const widthAttr = { @@ -53,14 +106,44 @@ const heightAttr = { attributes.height ? { height: String(attributes.height) } : {}, } +/** Link target of a linked image — markdown-only state, never emitted as an HTML `` attribute. */ +const hrefAttr = { default: null, rendered: false } +const hrefTitleAttr = { default: null, rendered: false } + /** - * Image node that carries optional `width`/`height` and serializes them as an HTML `` tag. - * Shared by the headless round-trip path (no node view) and the live {@link ResizableImage}. + * Image node that carries optional `width`/`height` (serialized as an HTML `` tag) and an + * optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless + * round-trip path (no node view) and the live {@link ResizableImage}. */ export const MarkdownImage = Image.extend({ addAttributes() { - return { ...this.parent?.(), width: widthAttr, height: heightAttr } + return { + ...this.parent?.(), + width: widthAttr, + height: heightAttr, + href: hrefAttr, + hrefTitle: hrefTitleAttr, + } }, + markdownTokenizer: { + name: 'image', + level: 'inline', + start: (src: string) => src.indexOf('[!['), + tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => { + const match = LINKED_IMAGE_RE.exec(src) + if (!match) return undefined + return { + type: 'image', + raw: match[0], + alt: match[1] ?? '', + src: match[2], + title: match[3] ?? null, + href: match[4], + hrefTitle: match[5] ?? null, + } + }, + }, + parseMarkdown: parseImageToken, renderMarkdown: imageMarkdown, }) @@ -72,7 +155,13 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP const imageRef = useRef(null) const dragAbortRef = useRef(null) const [dragging, setDragging] = useState(false) - const attrs = node.attrs as { src?: string; alt?: string; title?: string; width?: string | null } + const attrs = node.attrs as { + src?: string + alt?: string + title?: string + width?: string | null + href?: string | null + } useEffect(() => () => dragAbortRef.current?.abort(), []) @@ -110,17 +199,31 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP ? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width } : undefined + const image = ( + {attrs.alt + ) + return ( - {attrs.alt + {attrs.href ? ( + + {image} + + ) : ( + image + )} {(selected || dragging) && (