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..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,6 +371,14 @@ interface BreadcrumbLocationPopoverProps { veilBoundaryRef: React.RefObject } +/** + * 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, breadcrumbs, @@ -381,22 +389,44 @@ function BreadcrumbLocationPopover({ const closeTimeoutRef = useRef | null>(null) const rootBreadcrumb = breadcrumbs[0] - const openPopover = () => { + 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 = () => { + cancelScheduledClose() setOpen(true) } const scheduleClose = () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - } + 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 + cancelScheduledClose() + setOpen(false) + onClick() } useEffect(() => { @@ -413,15 +443,11 @@ 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). 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/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) + }) +}) 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..d7654e9bb2d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -0,0 +1,113 @@ +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 { MarkdownLinkInputRule } from './link-input-rule' +import { MarkdownPaste } from './markdown-paste' +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. + * + * 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) => + renderTableToMarkdown(node, { + ...h, + renderChildren: (nodes, separator) => + h.renderChildren(nodes, separator).replace(/\|/g, '\\|'), + }) + .replace(/^\n+/, '') + .replace(/\n+$/, ''), +}) + +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, + MarkdownLinkInputRule, + 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, + MarkdownPaste, + Placeholder.configure({ placeholder }), + ] +} 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/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx new file mode 100644 index 00000000000..fc5868bff69 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -0,0 +1,249 @@ +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' +import { normalizeLinkHref } from './markdown-fidelity' + +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 + .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 to + * 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))}"`) + 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 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) } : {}, +} + +/** 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` (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, + 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, +}) + +/** + * 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 + href?: 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 + + // Sanitize the linked-image target before rendering the anchor — a parsed markdown href is + // untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only). + const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '') + + const image = ( + {attrs.alt + ) + + return ( + + {safeHref ? ( + + {image} + + ) : ( + image + )} + {(selected || dragging) && ( + + + + {shortcut ? {label} : label} + + + ) +} + +function ToolbarDivider() { + return
+} + +interface EditorBubbleMenuProps { + editor: Editor +} + +/** + * Floating formatting toolbar shown on text selection. 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..0d20baa0d57 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -0,0 +1,271 @@ +.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; +} + +/* An image is its own framed element; ring the image itself so the indicator hugs the picture + rather than the node's bounding box, which can be far wider/taller than a small image. */ +.rich-markdown-prose .ProseMirror-selectednode:has(img) { + outline: none; +} + +.rich-markdown-prose .ProseMirror-selectednode img { + outline: 2px solid var(--brand-secondary); + outline-offset: 2px; +} + +.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. The + `overflow-wrap`/`word-break` resets undo the editor-wide `overflow-wrap: anywhere`, which would + otherwise still break a long unbroken token even under `white-space: pre`. */ +.rich-markdown-prose pre.code-editor-theme, +.rich-markdown-prose pre.code-editor-theme code { + white-space: pre; + overflow-wrap: normal; + word-break: normal; +} + +.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..e659eb7b29a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -0,0 +1,279 @@ +'use client' + +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' +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' +import { + applyFrontmatter, + normalizeLinkHref, + postProcessSerializedMarkdown, + 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' + +const EXTENSIONS = createMarkdownEditorExtensions({ + placeholder: "Write something, or press '/' for commands…", +}) + +interface RichMarkdownEditorProps { + file: WorkspaceFileRecord + workspaceId: string + canEdit: boolean + autoFocus?: boolean + 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 — 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, + workspaceId, + canEdit, + autoFocus, + onDirtyChange, + onSaveStatusChange, + saveRef, + streamingContent, + streamingMode, + disableStreamingAutoScroll = false, + previewContextKey, +}: RichMarkdownEditorProps) { + const { + content, + setDraftContent, + isStreamInteractionLocked, + isContentLoading, + hasContentError, + saveImmediately, + } = useEditableFileContent({ + file, + workspaceId, + canEdit, + streamingContent, + streamingMode, + onDirtyChange, + onSaveStatusChange, + saveRef, + }) + + if (isContentLoading) return + + if (hasContentError) { + return ( +
+

Failed to load file content

+
+ ) + } + + if (isStreamInteractionLocked) { + return ( + + ) + } + + return ( + + ) +}) + +interface LoadedRichMarkdownEditorProps { + file: WorkspaceFileRecord + workspaceId: string + initialContent: string + canEdit: 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, + 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 + + // 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) + 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 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 + 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 + } + } + } + + 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 onSaveShortcutRef.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) => { + if (!view.editable) return false + 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) => { + if (!view.editable) return false + 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 md = postProcessSerializedMarkdown(editor.getMarkdown()) + onChangeRef.current(applyFrontmatter(frontmatter, md)) + }, + }) + editorInstanceRef.current = editor + + useEffect(() => { + editor?.setEditable(isEditable) + }, [editor, isEditable]) + + return ( +
+ {editor && } + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-editable-corpus.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-editable-corpus.test.ts new file mode 100644 index 00000000000..52102f31037 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-editable-corpus.test.ts @@ -0,0 +1,189 @@ +/** + * @vitest-environment jsdom + * + * Certainty corpus for the editability gate. A file opens editable for a permitted user iff + * {@link isRoundTripSafe} returns true on its content, so this asserts that realistic, full-length + * markdown documents — READMEs, notes, changelogs, docs with nested lists, tables, task lists, + * blockquotes, fenced code, inline formatting — are ALL editable. The probe must only ever refuse + * the genuinely lossy constructs (footnotes, raw HTML, >128KB), never ordinary prose. + */ +import { describe, expect, it } from 'vitest' +import { isRoundTripSafe } from './round-trip-safety' + +const README = `# Acme CLI + +[![build](https://img.shields.io/badge/build-passing-green)](https://example.com) + +Acme is a fast, friendly command-line tool. + +## Installation + +\`\`\`bash +npm install -g acme +acme --help +\`\`\` + +## Usage + +Run \`acme init\` to scaffold a project, then: + +1. Edit \`acme.config.json\` +2. Run \`acme build\` +3. Ship it + +> **Note:** requires Node 18+. + +| Flag | Description | Default | +| --- | --- | --- | +| \`--watch\` | Rebuild on change | \`false\` | +| \`--out\` | Output directory | \`dist\` | + +### Features + +- Zero-config defaults +- Incremental builds + - Caches by content hash + - Skips unchanged files +- Plugin system + +See the [docs](https://example.com/docs) for more. +` + +const MEETING_NOTES = `# Weekly Sync — 2026-06-18 + +**Attendees:** Alice, Bob, Carol + +## Agenda + +1. Roadmap review +2. Incident retro +3. Open questions + +## Notes + +- Roadmap is *on track* for Q3. +- The **incident** on Monday was a config regression. + 1. Root cause: a stale cache key + 2. Fix: invalidate on deploy +- Carol will own the migration. + +### Action items + +- [x] Write the retro doc +- [ ] Schedule the migration window +- [ ] Email the customers affected + +\`\`\`sql +SELECT count(*) FROM events WHERE created_at > now() - interval '7 days'; +\`\`\` + +That's all for today. +` + +const CHANGELOG = `# Changelog + +All notable changes are documented here. + +## [1.4.0] - 2026-06-01 + +### Added +- New \`--json\` output mode +- Support for \`AT&T\` style names and \`R&D\` labels + +### Fixed +- A crash when the input was empty +- Off-by-one in the progress bar + +## [1.3.2] - 2026-05-12 + +### Changed +- Bumped dependencies + +--- + +Older entries omitted. +` + +const NESTED_AND_QUOTES = `# Deep Doc + +> A blockquote +> spanning two lines. +> +> > And a nested one. + +1. First + - sub bullet with \`code\` + - another + 1. deep ordered + 2. item +2. Second + +\`\`\`typescript +function add(a: number, b: number): number { + return a + b +} +\`\`\` + +A paragraph with _emphasis_, **strong**, and ~~strikethrough~~ text. + +Math-ish prose like value $x^2 + y$ stays literal. +` + +const TABLES_AND_LINKS = `# Reference + +| Method | Path | Auth | +| :----- | :--: | ---: | +| GET | \`/items\` | yes | +| POST | \`/items\` | yes | + +Inline autolink: + +A normal link to [the site](https://sim.ai "title") and an image: + +![diagram](https://example.com/diagram.png) + +Use \`a & b\` and \`x < y\` in code freely. +` + +const EDITABLE_CORPUS: Record = { + README, + MEETING_NOTES, + CHANGELOG, + NESTED_AND_QUOTES, + TABLES_AND_LINKS, +} + +describe('editability gate — realistic documents stay editable', () => { + for (const [name, doc] of Object.entries(EDITABLE_CORPUS)) { + it(`opens editable: ${name}`, () => { + expect(isRoundTripSafe(doc)).toBe(true) + }) + } + + it('a large-but-ordinary document (just under the probe limit) stays editable', () => { + const big = `# Big Doc\n\n${'A paragraph of perfectly ordinary prose. '.repeat(2000)}` + expect(big.length).toBeLessThan(128 * 1024) + expect(isRoundTripSafe(big)).toBe(true) + }) + + it('frontmatter does not gate editability', () => { + expect(isRoundTripSafe('---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n\nText.')).toBe(true) + }) +}) + +/** + * The flip side: constructs the WYSIWYG schema genuinely cannot represent open read-only so an edit + * can't silently corrupt them. These are documented here as the EXACT boundary of the gate — common + * in hand-authored GitHub READMEs, rare in agent-generated docs and ordinary notes. + */ +describe('editability gate — genuinely lossy constructs open read-only', () => { + it('raw HTML blocks (
,
) open read-only', () => { + expect(isRoundTripSafe('
More\n\nbody\n\n
')).toBe(false) + expect(isRoundTripSafe('
\n\ncentered\n\n
')).toBe(false) + }) + + it('HTML comments and footnotes open read-only', () => { + expect(isRoundTripSafe('\n\ntext')).toBe(false) + expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(false) + }) +}) 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..f9dc6ec5a5b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts @@ -0,0 +1,95 @@ +/** + * @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 a linked image / badge (round-trips through the image node href)', () => { + expect(isRoundTripSafe('[![alt](https://e.com/i.png)](https://e.com)')).toBe(true) + expect( + isRoundTripSafe('[![build](https://img.shields.io/badge/x-green)](https://ci.example.com)') + ).toBe(true) + expect(isRoundTripSafe('[![alt](https://e.com/i.png "t")](https://e.com "h")')).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('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..c55f5982281 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -0,0 +1,105 @@ +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 read-only. + */ +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 images `[![alt](img)](href)` are handled by the image node and verified separately by + * the link-count check in {@link isRoundTripSafe}, not here.) + * + * - **Footnote** `[^id]` — not in the schema; the reference and definition serialize to escaped + * literal text, breaking the footnote. + * - **HTML comment** `` — dropped entirely. + * - **Raw HTML tag** `
`, `
`, ``, … — StarterKit has no HTML node, so the tag + * is stripped (content kept, structure lost). `
` and `` are excluded: `
` outside a + * table converts to a hard break, and `` is a first-class (resizable) image node. + * - **`
` 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 = [ + /\[\^[^\]]+]/, + / 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 linked image / badge (keeps the wrapping link)', () => { + const out = roundTrip( + '[![build](https://img.shields.io/badge/x-green)](https://ci.example.com)' + ) + expect(out).toContain( + '[![build](https://img.shields.io/badge/x-green)](https://ci.example.com)' + ) + expect(roundTrip(out)).toBe(out) + }) + + it('keeps a plain image plain (no spurious link wrapper)', () => { + const out = roundTrip('![alt](https://example.com/i.png)') + expect(out).not.toContain('](https://example.com/i.png)](') + expect(out.trim()).toBe('![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('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`~~') + }) + + 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') + }) + + it('does not churn blank lines around an interior table', () => { + const out = roundTrip('before\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter') + expect(out).not.toContain('\n\n\n') + expect(out).toContain('before') + expect(out).toContain('after') + expect(roundTrip(out)).toBe(out) + }) + + it('does not churn blank lines between two adjacent tables', () => { + const out = roundTrip('| a |\n| --- |\n| 1 |\n\n| b |\n| --- |\n| 2 |') + expect(out).not.toContain('\n\n\n') + expect(roundTrip(out)).toBe(out) + }) + + it('preserves blank lines inside a fenced code block (table trim must not touch code)', () => { + const out = roundTrip('```js\na\n\n\nb\n```') + expect(out).toContain('a\n\n\nb') + expect(roundTrip(out)).toBe(out) + }) +}) 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..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 @@ -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', () => { @@ -433,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/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..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' @@ -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' @@ -1074,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 }, ] : []), @@ -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', @@ -1477,7 +1482,7 @@ export function Files() { ? [ { text: 'Share', - icon: Link, + icon: Send, onSelect: handleShareSelected, }, { @@ -1860,7 +1865,7 @@ export function Files() { return ( -
+
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 050aa22b3d0..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 @@ -123,8 +123,12 @@ export const ResourceContent = memo(function ResourceContent({ const disableStreamingAutoScroll = previewSession?.operation === 'patch' const isTextPreview = !!previewSession && resolveFileCategory(null, previewSession.fileName) === 'text-editable' + // Feed streamed content only while actively streaming. On completion the session keeps + // `previewText` for history, but clearing it here lets the editor reconcile to the agent's + // server-side write and hand off to the editable surface (the agent persists, not the editor). const textStreamingContent = isTextPreview && + previewSession?.status === 'streaming' && typeof previewSession?.previewText === 'string' && hasRenderableFilePreviewContent(previewSession) ? previewSession.previewText @@ -262,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) 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..6cb035617c2 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,9 +99,12 @@ export const MothershipView = memo( canEdit && active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) && + // Markdown renders in the single-surface inline editor (streamed preview → editable in place), + // so it has no raw/split/preview toggle to offer. + !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. + // other rich types (html, svg, …) on the file list loading. !(isActiveCsv && filesLoading) && !(activeFile && isCsvStreamOnly(activeFile)) diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index 6e55df80ae3..824e2b415d0 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 @@ -45,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 () => { @@ -57,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) - 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(() => { @@ -86,16 +97,25 @@ export function useAutosave({ }, [content, enabled, isDirty, delay, save]) useEffect(() => { + // 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) clearTimeout(idleTimerRef.current) - if ( - enabledRef.current && - contentRef.current !== savedContentRef.current && - !savingRef.current - ) { - onSaveRef.current().catch(() => {}) - } + clearTimeout(displayTimerRef.current) + 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(() => {}) + } + })() } }, []) 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=="],