From ecbe1919d8525e68f28e5c54faf03c52439e0bcd Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 19 Jun 2026 18:32:42 -0700 Subject: [PATCH] feat(files): inline rich markdown editor (#5133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(files): inline rich markdown editor Replace the raw/preview split for markdown files with a Linear-style inline WYSIWYG editor (TipTap/ProseMirror): bubble + slash menus, code-block language picker with Prism highlighting and line-wrap, resizable images (HTML ), GFM tables, and frontmatter held byte-exact out of band. A round-trip preflight gate (decided once per open) falls back to the raw Monaco editor for any file that can't be edited losslessly, so the rich editor never silently corrupts a file. * fix(files): chain autosave unmount flush after in-flight save The unmount flush no longer fires a concurrent PUT alongside an in-flight save; it awaits the in-flight save and then writes the latest content sequentially, so an out-of-order completion can't clobber newer edits with a stale snapshot (addresses Cursor Bugbot). * fix(files): read pasted images from clipboard items, not just files Some browsers expose a pasted or copied image only via DataTransfer.items (with an empty files list), so screenshot paste was silently ignored. extractImageFiles now falls back to items; moved to a testable module with unit tests (addresses Cursor Bugbot). * fix(files): destroy round-trip probe editor on serialization error Wrap the probe serialize() in try/finally so the throwaway Editor is always destroyed even if setContent/getMarkdown throws (addresses Greptile). Adds a test proving PipeSafeTable escapes only interior cell pipes, not structural delimiters. * fix(resource): hold breadcrumb nav latch across the route swap scheduleClose fired on the pointer/focus exit that immediately follows a click-to-navigate and was clearing the reopen latch before the route swapped, letting the popover flash back open. The latch is now released by a short timer instead (addresses Cursor Bugbot). * chore(files): drop platform references and non-essential inline comments * fix(files): scope inline markdown editor to the files view The mothership preview was routing streaming markdown through the inline editor path: it showed Monaco during streaming (previewMode fell back to 'editor') and lost the streamed content on the TextEditor→MarkdownFileEditor swap (the TextEditor unmounted before it could reconcile + autosave). The inline rich editor is now opt-in via a FileViewer prop that only the files view sets, so the mothership keeps its raw/preview streaming editor and persists as before. * fix(mothership): use the inline markdown editor in the chat resource view Idle markdown in the chat resource view now renders the single-surface inline editor (no raw/split/preview pencil toggle), matching the files view. While the agent streams, FileViewer forces the rendered preview instead of Monaco, and the streamed file persists via the agent's server write + the existing content-query invalidation on tool completion — so the idle editor refetches the persisted content. * refactor(files): collapse the duplicate raw-editor fallback branch in the markdown gate * fix(mothership): swap to the inline editor once a file preview finishes streaming The preview session keeps status='complete' and previewText after streaming ends, so streamingContent stayed defined and the file stuck on the read-only rendered preview. Treat content as streaming only while status==='streaming'; once complete the EmbeddedFile sees no streamingContent and mounts the editable inline editor (which refetches the persisted content). The synthetic streaming-file stays a pure preview. * Revert "fix(mothership): swap to the inline editor once a file preview finishes streaming" This reverts commit 25b12e4caa389109c2d5ed7f7d122e35d441f980. * Revert "fix(mothership): use the inline markdown editor in the chat resource view" This reverts commit 9430aa7fdc5050d002f2312d31dcf4a255e2de18. * feat(files): rich markdown editor across files + chat, read-only for unsafe, robust load/save - chat resource view streams into the rich editor (streamdown while streaming → editable on completion); agent persists server-side, editor never saves mid-stream - round-trip-unsafe / >128KB markdown renders read-only in the rich editor (no Monaco, no corruption) - markdown always uses the rich editor (dropped the inline-markdown opt-in flag) - editor loads content as TipTap's initial content keyed by file id — strict-mode/SSR-safe, no content-sync effect - fix autosave "Saving…" status suppression under React strict mode - lock the streamed-file persistence handoff with a state-machine lifecycle test * chore(files): remove dead code (unused FileViewer logger + EmbeddedWorkflowActions router) * fix(files): derive markdown round-trip verdict from live content, not a locked stale snapshot The gate locked isRoundTripSafe on the first post-stream snapshot, which is often the empty create_file buffer before the agent's server write lands — wrongly leaving an unsafe document editable. Derive the verdict from the current content (memoized on the bytes) so canEdit tracks the real payload. * test(files): guard the rich editor dirty signal — open is never dirty, edits emit * fix(files): lock the markdown round-trip verdict on opened content, never strand dirty edits The round-trip-safety verdict now gates editability only at open time — computed once, on the exact content the editor mounts with, and locked for its lifetime. A dirty document is round-trip-safe by construction (the editor only emits safe markdown), so the verdict must never flip off mid-edit: doing so disabled autosave, ⌘S, the toolbar Save and the unmount flush, stranding unsaved edits. Locking on the opened (reconciled) content also fixes the stale post-stream empty-buffer snapshot, and lets the redundant MarkdownFileEditor gate (plus its duplicate content fetch) be deleted. * improvement(file-viewer): reuse shared copy hook, lazy frontmatter split - code-block: replace hand-rolled copy-with-timeout with shared useCopyToClipboard - rich-markdown-editor: compute frontmatter split once via lazy ref, drop redundant frontmatterRef - round-trip-safety: correct stale comments (read-only, not raw editor fallback) * feat(file-viewer): linked images, typed-link input rule, drag-to-reorder, churn fixes - image: round-trip linked images/badges via an href attr + custom markdown tokenizer; make the image a drag handle so it can be grabbed and reordered - link-input-rule: convert typed [text](url) to a link on the closing paren (normalized href) - markdown-paste: render pasted markdown as rich content, guarded against code blocks - round-trip-safety: behavioral link-count check replaces the static linked-image rejection - extensions: trim the table serializer's blank lines to stop interior-table whitespace churn * improvement(file-viewer): Backspace at start of a heading reverts it to a paragraph Notion-style: ProseMirror's default joins or no-ops at a heading boundary, stranding the heading style. A second Backspace then merges as usual. * fix(file-viewer): don't upload pasted/dropped images into a read-only editor handlePaste/handleDrop ran the workspace image upload without checking editability, so a read-only doc (canEdit=false or a round-trip-unsafe file) could still trigger an upload. Guard both on view.editable. * fix(file-viewer): sanitize linked-image href; drop global leading-newline strip - image: run the linked-image (badge) anchor target through normalizeLinkHref so a javascript:/data: href in a file can't execute on click; the markdown still preserves the raw target (file content unchanged) - markdown-fidelity: the table serializer now trims its own surrounding blank lines, so the global leading-newline strip in postProcessSerializedMarkdown is redundant — removing it stops clobbering content that legitimately begins with whitespace * feat(file-viewer): stream agent output directly into the rich editor; add more code languages - rich-markdown-editor: the TipTap editor is now the only markdown surface. Agent output streams into it read-only (synced per chunk, autoscrolled), then the same instance hands off to an editable editor on settle — no separate streamdown preview, so no stream→edit flash. The round-trip verdict + frontmatter lock when the content settles. - code-block/code-highlight/detect-language: register Go, Rust, Java, C, C++, C#, Ruby, PHP grammars and add detectors, so those blocks highlight and the picker offers them. - css: style h5/h6 in the prose stylesheet. * fix(sidebar): hydrate collapse state before paint to stop refresh flash The collapsed sidebar swaps entire subtrees (collapsed flyout vs expanded lists), but isCollapsed only resolved after the first paint via auto rehydration, so a collapsed reload rendered the expanded tree into the 51px rail and then reflowed — the misplaced/flashing content on refresh. Adopt zustand's documented SSR pattern: skipHydration on the persist config (first render keeps the default false, matching SSR HTML) and flush persist.rehydrate() from a useLayoutEffect so the correct structure commits in the same pre-paint frame. Removes the old race where onRehydrateStorage lifted the data-sidebar-collapsed mask before React committed the rail. * refactor(file-viewer): audit fixes — stale docs, DRY settle-lock, language detection - rich-markdown-editor: rewrite the now-stale single-surface docstring (no PreviewPanel); extract a shared lockSettled() helper used by both the mount and stream-settle paths; guard the settle re-seed so it only setContent's when the body actually changed (no redundant doc rebuild) - detect-language: stop misreading generics (List) as HTML markup; detect Go type/struct - code-block: export LANGUAGE_OPTIONS + add a test asserting every picker language has a registered Prism grammar (prevents picker/highlighter drift) * refactor(file-viewer): remove dead markdown-preview renderer now superseded by the rich editor Markdown files route exclusively to RichMarkdownEditor on both the read-only and editable paths, so PreviewPanel's markdown branch and its Streamdown-based renderer were unreachable. Delete MarkdownPreview and its renderers, callout/ frontmatter/checkbox machinery, and the now-unused remark/rehype/prism/streamdown imports; drop the dead toggleMarkdownCheckbox/onCheckboxToggle plumbing in text-editor. Keep the html/csv/svg/mermaid branches intact. * refactor(file-viewer): drop dead streamingMode/append path, align naming, cover autosave The streaming engine only ever runs in 'replace' mode (the only runtime callers pass it); the 'append' branch of resolveStreamingEditorContent was unreachable. Remove streamingMode + the StreamingMode type and thread it out of the 6 components that forwarded it — nextContent is now simply the streamed snapshot, behavior-identical on the live path. Rename for codebase semantics: the boolean prop streaming -> isStreaming, EditorKeymap -> RichMarkdownKeymap, the highlight PluginKey KEY -> HIGHLIGHT_PLUGIN_KEY. Add a defensive isEditable guard to the markdown paste handler (parity with the image handler; read-only must never mutate). Add a dependency-free useAutosave test suite (debounce, min-display window, no-data-loss when an edit lands mid-save, error/no-retry, Cmd+S flush, streaming-disabled lock, unmount flush). * fix(file-viewer): re-lock round-trip verdict + frontmatter on each stream settle LoadedRichMarkdownEditor stays mounted across multiple agent edits to the same file within a chat (previewContextKey is the chat id), but the settle effect only locked settledRef when it was null — so a second stream into the same instance kept editability and frontmatter tied to the first settled snapshot. A repeat edit that is round-trip-unsafe would stay editable, and saves would re-attach the stale frontmatter. Track wasStreaming and re-derive the verdict + frontmatter on every stream->settle transition (user edits never re-derive, preserving the don't-strand-edits rule). Verified red/green in the e2e streaming harness. * test(file-viewer): lock link href sanitization for dangerous schemes from file content Greptile flagged a possible javascript: link XSS. Verified TipTap 3.26.1 already neutralizes javascript:/data:/vbscript: (and mixed-case/whitespace variants) from file-loaded markdown to an empty href. Add a committed regression test that asserts this against the real headless editor, so a future TipTap bump can't silently reintroduce the issue. * perf(file-viewer): cap the round-trip probe at 24KB and coalesce streaming syncs @tiptap/markdown's parse is superlinear (~O(n2)) in document size — measured ~170ms at 11KB, ~875ms at 23KB, multiple seconds past ~35KB — and it runs synchronously at mount inside the round-trip-safety probe (twice) and the editor's own setContent. The 128KB cap allowed multi-second main-thread freezes; lower it to 24KB so the worst-case mount stays near a second while still covering the vast majority of real markdown files (larger files open read-only). Separately, coalesce streaming chunk-syncs to one re-parse per animation frame so a fast-streaming agent doesn't re-parse the whole accumulating doc per token. Typing latency was measured to be already excellent (sub-ms median, no change needed); the only hot cost was the mount parse. * perf(file-viewer): chunked markdown parsing to remove the O(n2) mount cost @tiptap/markdown's whole-document setContent(md,'markdown') is superlinear in size, freezing the main thread at mount for large files (~2.5s at 34KB, ~11s at 65KB) and forcing a restrictive read-only cap. Parse block-by-block instead: a conservative blank-line/fence-aware splitter (merges list/quote runs and indented continuations so ambiguous structures stay atomic; reference-link/footnote/raw-HTML docs fall back to a whole parse), each block parsed with the editor's own lexer via one reused headless parser, assembled into a doc. This is linear and byte-identical to the one-shot parse — measured ~15ms vs multiple seconds at 124KB+ — so the editor mount, streaming sync, and round-trip probe are all linear, and the editable-size cap goes 24KB -> 256KB (covers the p99 of real files). Fidelity + idempotency are pinned by unit tests, a 400-document property/fuzz test, and adversarial edge cases (nested/loose lists, blockquotes, setext, indented code, lazy continuation, HTML, reference links). * fix(sidebar): render collapse state from a cookie so SSR matches The server couldn't read localStorage, so a collapsed user's first paint rendered the *expanded* tree at 51px — prefetched chat/workflow lists, pinned-chat pin icons, and loading skeletons all crammed into the rail and then reflowed once the store hydrated. Mirror the collapse state into a sidebar_collapsed cookie (the shadcn/ui sidebar pattern), read it in the workspace server layout, and seed the sidebar's first render with it: structure is now correct on the server, so the first paint is the real rail with no skeleton/pin/shift. The store remains the post-hydration source of truth; the blocking script honors the cookie for width when localStorage is absent so width and structure agree. * refactor(sidebar): make the cookie the single source of truth for collapse Consolidates the collapse machinery onto one source of truth instead of layering the cookie on top of the legacy localStorage + CSS-mask system: - Collapse persists only in the sidebar_collapsed cookie; the store seeds isCollapsed from it and drops it from localStorage (partialize + merge), removing the dual-write and the cross-tab desync it caused. - Retire the redundant html[data-sidebar-collapsed] attribute + CSS mask now that the server emits the correct data-collapsed structure; also delete the dead sidebar-collapse-show/-remove/-btn rules. - Blocking script reads the cookie for collapse (width stays in localStorage) and seeds the cookie once from the legacy flag so existing collapsed users keep their preference. - Keep skipHydration + a pre-paint rehydrate for width only — the documented zustand SSR pattern, so _hasHydrated is deterministically false during SSR. Width stays in localStorage; each field now has exactly one home. * refactor(file-viewer): simplify + cleanup chunked-parse (linear merge, parse-once seed) From the /simplify + /cleanup passes: - splitMarkdownBlocks: build continuation runs and join each once instead of concatenating onto the growing previous block per group, which was O(n2) for a pathological single long loose list (now linear: 208KB loose list splits in ~3ms). - rich-markdown-editor: seed the editor's initial content via a lazy useState initializer instead of useRef(parseMarkdownToDoc(...)), whose argument re-parsed the whole document on every render (i.e. every keystroke). Parses exactly once at mount. - Document that the indent-merge rule is load-bearing for nested fenced code, and tighten the verbose inline comment blocks. * refactor(sidebar): drop orphaned sidebar-collapse-btn class Its CSS rule was removed with the data-sidebar-collapsed mask; the button's collapse behavior is fully driven by the React isCollapsed ternary, leaving the class name pointing at nothing. * test(file-viewer): consolidate split test files into one per module Match the dir's one-test-per-module convention: fold the markdown-parse property/fuzz suite into markdown-parse.test.ts and the editability corpus into round-trip-safety.test.ts (both already tested the same module from a separate-concern file). No coverage change — same assertions, fewer files (12 -> 10). * fix(file-viewer): make all editor controls respect read-only permissions Every interactive control that calls updateAttributes/dispatches a command mutates the doc even when read-only (ProseMirror commands run regardless of editable), so gate them on editor.isEditable: - bubble menu: the Cmd/Ctrl+K shortcut and shouldShow now bail when not editable, so a read-only doc can't open the link bar and setLink into it (Cursor finding). - code block: the language picker renders as a static label when read-only (its onSelect mutates); copy + view-only wrap stay. - image: no drag-to-reorder (draggable=false, no drag handle) and no resize handle when read-only; the image still renders and follows its link. - links: a plain click now follows the link in read-only (reader) mode, while edit mode still requires a modifier so a plain click can place the cursor (Cursor finding). Verified with new read-only permission e2e tests. * fix(sidebar): honor collapsed cookie even when localStorage is corrupt The blocking script read the collapse cookie inside the same try as JSON.parse(localStorage); invalid persisted JSON fell through to the 248px fallback and ignored a collapsed cookie, painting an expanded-width rail on first load. Read collapse from the cookie first and parse the persisted width in its own try so the two are independent. * docs(sidebar): convert inline comments to TSDoc * fix(file-viewer): resolve in-app workspace image URLs in the rich editor The removed MarkdownPreview rewrote /workspace/{id}/files/{fileId} image src to the serving endpoint /api/files/view/{fileId}; without it, in-app image URLs 404 in the rich editor (Cursor finding). Re-add the rewrite as a display-only transform on the rendered — the node's stored src attribute keeps the original path so markdown round-trips unchanged. Absolute/non-workspace URLs pass through. Unit tested. * fix(files): restore same-page anchor links in the rich markdown editor Headings rendered by the TipTap editor had no slug ids (the old MarkdownPreview got them from rehype-slug), so in-document table-of-contents links like [section](#section) had no targets. Resolve the slug to its heading on click (GitHub-style, duplicate-disambiguated) and scroll to it, with zero per-keystroke cost. * feat(files): render mermaid diagrams in the rich markdown editor A code block renders as a Mermaid diagram when it is fenced ```mermaid or auto-detected (an untagged fence whose first line opens with a diagram keyword, the Linear/GitHub heuristic). Detection is display-only — the node stays an ordinary code block and the markdown round-trips unchanged. - Source while the caret is inside the block, diagram on blur; a Show source / Show diagram control plus copy, matching the code block's hover chrome. - Clicking the diagram selects the node (same ring as an image), not flips source. - Theme-aware (light/dark) via next-themes; the diagram frame shares the code block's chrome (one CSS source of truth). - Extracted MermaidDiagram into a shared module so the editor reuses it without pulling preview-panel's heavy deps; rendered SVGs are memoized so toggling the source view and back is instant. Covered by mermaid-diagram unit tests and the editor e2e harness. * fix(files): harden the markdown editor (CRLF chunking, href allowlist, image escaping) Final-audit follow-ups: - splitMarkdownBlocks normalizes CRLF/CR first — a closing fence ending in \r no longer fails to match, which had collapsed Windows-authored files with fenced code into one block and defeated the linear chunker (perf regression). - normalizeLinkHref rejects file://, blob:, and other non-network schemes (script/data schemes already rejected); network scheme:// (http/ftp/…) and bare host:port still pass. - Image markdown serialization escapes alt/title delimiters and angle-brackets a src with spaces/parens, so they round-trip losslessly; linked-image anchors open in a new tab (target=_blank). - Markdown paste routes through the chunker so a large pasted blob can't freeze the main thread. * test(files): cover the code-highlight incremental re-tokenization gate Export and unit-test changeTouchesCodeBlock: prose-only edits map decorations (false), edits inside a code block or a setNodeMarkup language change re-tokenize (true) — the perf-correctness path that keeps highlighting off the keystroke path. * fix(files): keep relative links relative, navigate in-app links within the SPA - normalizeLinkHref no longer prefixes `./`/`../` relative paths into `https://./…` (they round-trip and resolve correctly). - Following a same-origin in-app link (e.g. /workspace/…) routes through the Next router (same tab) instead of always opening a new tab; modifier-click and external URLs still open a new tab. * fix(files): linked images don't open a tab on a plain click in the editor The linked-image anchor's native navigation was firing on a plain click in edit mode (where handleClick intentionally returns false for caret placement). Prevent the anchor's default so the editor's handleClick — gated on editable/modifier, matching text links via openOnClick:false — is the sole navigator. * fix(sidebar): match the collapse cookie value strictly (not a substring) A substring search for 'sidebar_collapsed=1' also matched 'sidebar_collapsed=10', desyncing the pre-paint sidebar rail and client store from the strict server read. Parse the cookie value and compare it to '1' exactly, in both the pre-paint inline script and readCollapsedCookie. Added a store test. * fix(sidebar): reconcile migrated-legacy collapse before paint A user whose collapse lived only in localStorage has no sidebar_collapsed cookie at SSR (initialCollapsed=false), but the pre-paint script migrates them to a cookie. The store's persist.rehydrate() is async (flips _hasHydrated after paint), so the first paint showed expanded labels in the collapsed 51px rail. Reconcile to the cookie synchronously in a useLayoutEffect (first render still matches the server, so no hydration mismatch) — no narrow-rail flash. --- apps/sim/app/_styles/globals.css | 29 +- apps/sim/app/layout.tsx | 46 +- .../resource-header/resource-header.tsx | 52 +- .../workspace-chrome/workspace-chrome.tsx | 6 +- .../components/file-viewer/file-viewer.tsx | 52 +- .../files/components/file-viewer/index.ts | 8 +- .../file-viewer/mermaid-diagram.test.ts | 63 ++ .../file-viewer/mermaid-diagram.tsx | Bin 0 -> 7062 bytes .../components/file-viewer/preview-panel.tsx | 924 +----------------- .../rich-markdown-editor/code-block.tsx | 262 +++++ .../code-highlight.test.ts | 81 ++ .../rich-markdown-editor/code-highlight.ts | 133 +++ .../code-languages.test.ts | 21 + .../detect-language.test.ts | 38 + .../rich-markdown-editor/detect-language.ts | 63 ++ .../rich-markdown-editor/dirty-signal.test.ts | 55 ++ .../rich-markdown-editor/extensions.ts | 113 +++ .../heading-anchors.test.ts | 57 ++ .../rich-markdown-editor/heading-anchors.ts | 36 + .../rich-markdown-editor/image-paste.test.ts | 56 ++ .../rich-markdown-editor/image-paste.ts | 14 + .../rich-markdown-editor/image.test.ts | 27 + .../rich-markdown-editor/image.tsx | 283 ++++++ .../rich-markdown-editor/keymap.ts | 70 ++ .../link-input-rule.test.ts | 70 ++ .../rich-markdown-editor/link-input-rule.ts | 45 + .../rich-markdown-editor/markdown-fidelity.ts | 75 ++ .../markdown-parse.test.ts | 212 ++++ .../rich-markdown-editor/markdown-parse.ts | 143 +++ .../markdown-paste.test.ts | 73 ++ .../rich-markdown-editor/markdown-paste.ts | 55 ++ .../menus/bubble-menu.tsx | 293 ++++++ .../rich-markdown-editor.css | 293 ++++++ .../rich-markdown-editor.tsx | 384 ++++++++ .../round-trip-safety.test.ts | 277 ++++++ .../rich-markdown-editor/round-trip-safety.ts | 101 ++ .../rich-markdown-editor/round-trip.test.ts | 319 ++++++ .../slash-command/commands.test.ts | 51 + .../slash-command/commands.ts | 147 +++ .../slash-command/slash-command-list.tsx | 129 +++ .../slash-command/slash-command.ts | 111 +++ .../file-viewer/text-editor-state.test.ts | 130 ++- .../file-viewer/text-editor-state.ts | 36 +- .../components/file-viewer/text-editor.tsx | 170 +--- .../file-viewer/use-editable-file-content.ts | 187 ++++ .../workspace/[workspaceId]/files/files.tsx | 17 +- .../resource-content/resource-content.tsx | 10 +- .../mothership-view/mothership-view.tsx | 6 +- .../app/workspace/[workspaceId]/layout.tsx | 6 +- .../w/components/sidebar/sidebar.tsx | 46 +- apps/sim/hooks/use-autosave.test.tsx | 267 +++++ apps/sim/hooks/use-autosave.ts | 72 +- apps/sim/package.json | 12 + apps/sim/stores/sidebar/store.test.ts | 34 + apps/sim/stores/sidebar/store.ts | 45 +- bun.lock | 122 ++- 56 files changed, 5130 insertions(+), 1297 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-input-rule.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-input-rule.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts create mode 100644 apps/sim/hooks/use-autosave.test.tsx create mode 100644 apps/sim/stores/sidebar/store.test.ts diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 0baeb6d70a..d8765bb9fd 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -66,38 +66,11 @@ opacity: 0; } -html[data-sidebar-collapsed] .sidebar-container span, -html[data-sidebar-collapsed] .sidebar-container .text-small { - opacity: 0; -} - .sidebar-container .sidebar-collapse-hide { transition: opacity 60ms ease; } -.sidebar-container .sidebar-collapse-show { - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease-out; -} - -.sidebar-container[data-collapsed] .sidebar-collapse-hide, -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide { - opacity: 0; -} - -.sidebar-container[data-collapsed] .sidebar-collapse-show, -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show { - opacity: 1; - pointer-events: auto; -} - -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove { - display: none; -} - -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { - width: 0; +.sidebar-container[data-collapsed] .sidebar-collapse-hide { opacity: 0; } diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 82e6f107b7..4ab0bddef7 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -78,26 +78,36 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // window yields a width >= MIN instead of a sub-minimum sliver. var defaultSidebarWidth = 248; try { - var stored = localStorage.getItem('sidebar-state'); - if (stored) { - var parsed = JSON.parse(stored); - var state = parsed && parsed.state; - var isCollapsed = state && state.isCollapsed; + // Collapse comes from the cookie (independent of localStorage + // parsing); the persisted width is read defensively below. Match the + // value strictly so 'sidebar_collapsed=10' isn't read as collapsed. + var cookieMatch = document.cookie.match(/(?:^|;\s*)sidebar_collapsed=([^;]*)/); + var hasCookie = cookieMatch !== null; + var collapsed = cookieMatch !== null && cookieMatch[1] === '1'; - if (isCollapsed) { - document.documentElement.style.setProperty('--sidebar-width', '51px'); - document.documentElement.setAttribute('data-sidebar-collapsed', ''); - } else { - var width = state && state.sidebarWidth; - var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); - var finalWidth = - typeof width === 'number' && isFinite(width) - ? Math.min(Math.max(width, 248), maxSidebarWidth) - : defaultSidebarWidth; - document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); - } + var state = null; + try { + var stored = localStorage.getItem('sidebar-state'); + state = stored ? JSON.parse(stored).state : null; + } catch (e) {} + + // One-time migration: seed the cookie from the legacy localStorage + // flag for users who collapsed before the cookie existed. + if (!hasCookie && state && typeof state.isCollapsed === 'boolean') { + collapsed = state.isCollapsed; + document.cookie = 'sidebar_collapsed=' + (collapsed ? '1' : '0') + '; path=/; max-age=31536000; samesite=lax'; + } + + if (collapsed) { + document.documentElement.style.setProperty('--sidebar-width', '51px'); } else { - document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); + var width = state && state.sidebarWidth; + var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); + var finalWidth = + typeof width === 'number' && isFinite(width) + ? Math.min(Math.max(width, 248), maxSidebarWidth) + : defaultSidebarWidth; + document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); } } catch (e) { document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); 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 b544c525ca..f03a8cdcdf 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({ + )} + {!isMermaid && + (editor.isEditable ? ( + // Editable: a language picker. Read-only: a static label — selecting a language calls + // updateAttributes, which would mutate a doc that must not change. + + + + + + {LANGUAGE_OPTIONS.map((option) => ( + + updateAttributes({ language: option.value === PLAIN ? null : option.value }) + } + > + {option.label} + + ))} + + + ) : ( + + {label} + + ))} + {!isMermaid && ( + + )} + + +
+         as='code' />
+      
+ {showDiagram && ( + // Clicking the diagram selects the whole node (same selection ring as an image/code block) + // instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret, + // which would otherwise flip to source. Editing is an explicit Show source / blur action. +
{ + event.preventDefault() + const pos = typeof getPos === 'function' ? getPos() : null + if (typeof pos === 'number') editor.commands.setNodeSelection(pos) + }} + > + +
+ )} + + ) +} + +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 0000000000..6b74e26da3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { buildDecorations, changeTouchesCodeBlock } from './code-highlight' +import { createMarkdownContentExtensions } from './extensions' + +let editor: Editor | null = null + +/** Position just inside the first code block in the current editor doc. */ +function codeBlockPos(ed: Editor): number { + let pos = -1 + ed.state.doc.descendants((node, p) => { + if (pos === -1 && node.type.name === 'codeBlock') pos = p + return pos === -1 + }) + if (pos === -1) throw new Error('no code block') + return pos +} + +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) + }) +}) + +describe('changeTouchesCodeBlock (incremental re-tokenization gate)', () => { + function mount(markdown: string): Editor { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor + } + + it('is false when an edit lands only in prose (decorations are mapped, not rebuilt)', () => { + const ed = mount('intro text\n\n```js\nconst x = 1\n```') + const tr = ed.state.tr.insertText('Z', 1) // inside the leading paragraph + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(false) + }) + + it('is true when an edit lands inside a code block (forces a re-tokenize)', () => { + const ed = mount('intro\n\n```js\nconst x = 1\n```') + const tr = ed.state.tr.insertText('y', codeBlockPos(ed) + 1) + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true) + }) + + it('is true when the code block language changes via setNodeMarkup', () => { + const ed = mount('```js\nconst x = 1\n```') + const pos = codeBlockPos(ed) + const tr = ed.state.tr.setNodeMarkup(pos, undefined, { language: 'python' }) + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true) + }) +}) 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 0000000000..5609f56922 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts @@ -0,0 +1,133 @@ +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 'prismjs/components/prism-c' +import 'prismjs/components/prism-cpp' +import 'prismjs/components/prism-csharp' +import 'prismjs/components/prism-go' +import 'prismjs/components/prism-java' +import 'prismjs/components/prism-markup-templating' +import 'prismjs/components/prism-php' +import 'prismjs/components/prism-ruby' +import 'prismjs/components/prism-rust' +import { detectLanguage } from './detect-language' + +const HIGHLIGHT_PLUGIN_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. + */ +export 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: HIGHLIGHT_PLUGIN_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 HIGHLIGHT_PLUGIN_KEY.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts new file mode 100644 index 0000000000..d3f830e2ee --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment jsdom + * + * Guards against drift between the code-block language picker and the Prism grammars actually + * registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it + * would silently fall back to no highlighting. + */ +import Prism from 'prismjs' +import { describe, expect, it } from 'vitest' +import { LANGUAGE_OPTIONS } from './code-block' +// Importing the highlighter registers all the prism-* grammars as a side effect. +import './code-highlight' + +describe('code-block languages', () => { + it('every selectable language has a registered Prism grammar', () => { + for (const { value } of LANGUAGE_OPTIONS) { + if (value === 'plain') continue + expect(Prism.languages[value], `no Prism grammar registered for "${value}"`).toBeDefined() + } + }) +}) 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 0000000000..a5c9194a7f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts @@ -0,0 +1,38 @@ +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') + }) + + it('detects Go, Rust, Java', () => { + expect(detectLanguage('package main\n\nfunc main() {\n\tfmt.Println("hi")\n}')).toBe('go') + expect(detectLanguage('type User struct {\n\tName string\n}')).toBe('go') + expect(detectLanguage('fn main() {\n let mut x = 1;\n println!("{}", x);\n}')).toBe('rust') + expect(detectLanguage('public class Box {\n private int n;\n}')).toBe('java') + }) + + it('does not misread generics as HTML markup', () => { + expect(detectLanguage('public class Box { private List items; }')).toBe('java') + expect(detectLanguage('let v: Vec = Vec::new();\nfn f() {}')).toBe('rust') + expect(detectLanguage('func Map[T any](s []T) {}\npackage x')).toBe('go') + }) +}) 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 0000000000..d391ed13d2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts @@ -0,0 +1,63 @@ +/** + * 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 }> = [ + // Real HTML: a closing tag, an opening tag with an attribute, or a doctype/comment. Deliberately + // NOT a bare `` so generics (`List`, `Vec`) aren't misread as markup. + { language: 'markup', test: /<\/[a-z][\w-]*\s*>|<[a-z][\w-]*\s+[\w:-]+=||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 0000000000..870907a9a3 --- /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 0000000000..2192198177 --- /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 { RichMarkdownKeymap } 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, + RichMarkdownKeymap, + MarkdownPaste, + Placeholder.configure({ placeholder }), + ] +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts new file mode 100644 index 0000000000..45a0cb92ae --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from './extensions' +import { findHeadingPos, slugifyHeading } from './heading-anchors' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */ +function docOf(markdown: string) { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor.state.doc +} + +describe('slugifyHeading', () => { + it('lowercases, drops punctuation, and hyphenates whitespace (GitHub-style)', () => { + expect(slugifyHeading('Getting Started')).toBe('getting-started') + expect(slugifyHeading('API Reference!')).toBe('api-reference') + expect(slugifyHeading(' Spaced Out ')).toBe('spaced-out') + expect(slugifyHeading('Node.js & Bun')).toBe('nodejs-bun') + }) + + it('returns an empty string for punctuation-only text', () => { + expect(slugifyHeading('!!!')).toBe('') + expect(slugifyHeading('')).toBe('') + }) +}) + +describe('findHeadingPos', () => { + it('resolves a fragment slug to its heading position', () => { + const doc = docOf('# Intro\n\ntext\n\n## Getting Started\n\nmore') + expect(findHeadingPos(doc, 'intro')).toBeGreaterThanOrEqual(0) + expect(findHeadingPos(doc, 'getting-started')).toBeGreaterThan(findHeadingPos(doc, 'intro')) + }) + + it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => { + const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc') + const first = findHeadingPos(doc, 'notes') + const second = findHeadingPos(doc, 'notes-1') + const third = findHeadingPos(doc, 'notes-2') + expect(first).toBeGreaterThanOrEqual(0) + expect(second).toBeGreaterThan(first) + expect(third).toBeGreaterThan(second) + }) + + it('returns -1 when no heading matches', () => { + const doc = docOf('# Only Heading\n\nbody') + expect(findHeadingPos(doc, 'missing')).toBe(-1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts new file mode 100644 index 0000000000..677964d65e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts @@ -0,0 +1,36 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** + * Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so + * that `[label](#slug)` fragment links — written against how GitHub renders the same markdown — + * resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview. + */ +export function slugifyHeading(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') +} + +/** + * The document position of the heading a `#slug` fragment link targets, or -1 if none matches. + * Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate + * slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, … + */ +export function findHeadingPos(doc: ProseMirrorNode, slug: string): number { + const seen = new Map() + let found = -1 + doc.descendants((node, pos) => { + if (found >= 0) return false + if (node.type.name !== 'heading') return true + const base = slugifyHeading(node.textContent) + if (!base) return true + const n = seen.get(base) ?? 0 + seen.set(base, n + 1) + if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos + return found < 0 + }) + return found +} 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 0000000000..766e4c77ef --- /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 0000000000..ff72fededf --- /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.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts new file mode 100644 index 0000000000..41e2f88840 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts @@ -0,0 +1,27 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { resolveDisplaySrc } from './image' + +describe('resolveDisplaySrc', () => { + it('rewrites an in-app workspace file path to its serving endpoint (display only)', () => { + expect(resolveDisplaySrc('/workspace/W1/files/F123')).toBe('/api/files/view/F123') + expect(resolveDisplaySrc('/workspace/any-ws-id/files/abc-def')).toBe('/api/files/view/abc-def') + }) + + it('leaves absolute and non-workspace URLs untouched', () => { + expect(resolveDisplaySrc('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png') + expect(resolveDisplaySrc('http://localhost/workspace/W1/files/F1')).toBe( + 'http://localhost/workspace/W1/files/F1' + ) + expect(resolveDisplaySrc('/other/path/files/x')).toBe('/other/path/files/x') + expect(resolveDisplaySrc('relative/image.png')).toBe('relative/image.png') + }) + + it('passes through empty/undefined and unparseable values', () => { + expect(resolveDisplaySrc(undefined)).toBeUndefined() + expect(resolveDisplaySrc('')).toBe('') + expect(resolveDisplaySrc('/workspace/W1/files/')).toBe('/workspace/W1/files/') + }) +}) 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 0000000000..8e76a4244b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -0,0 +1,283 @@ +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, '>') +} + +/** + * Rewrite an in-app workspace file path (`/workspace/{id}/files/{fileId}`) to its serving endpoint + * (`/api/files/view/{fileId}`) for display only — the stored `src` attribute keeps the original path + * so markdown round-trips unchanged. Absolute and non-workspace URLs pass through untouched. + */ +export function resolveDisplaySrc(src: string | undefined): string | undefined { + if (!src) return src + try { + const parsed = new URL(src, 'http://placeholder') + if (parsed.origin !== 'http://placeholder') return src + const [, seg1, , seg3, fileId] = parsed.pathname.split('/') + if (seg1 === 'workspace' && seg3 === 'files' && fileId) return `/api/files/view/${fileId}` + } catch { + // not a parseable URL — render as-is + } + return src +} + +/** + * 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 { + // Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax + // and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark). + const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : '' + const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src + image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${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, editor }: 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 : '') + + // Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move, + // mutating a doc that must not change. The image still renders (and follows its link on click). + const editable = editor.isEditable + + const image = ( + {attrs.alt + ) + + return ( + + {safeHref ? ( + // The editor's handleClick is the sole navigator (gated on editable/modifier, like text links + // via openOnClick:false): prevent the anchor's own navigation so a plain click in edit mode + // places the caret / selects the node instead of opening a tab. + event.preventDefault()} + > + {image} + + ) : ( + image + )} + {editable && (selected || dragging) && ( +