mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(files): inline rich markdown editor (#5133)
* 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 <img>), 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<String>) 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 <img src> — 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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+28
-18
@@ -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');
|
||||
|
||||
+37
-15
@@ -371,6 +371,14 @@ interface BreadcrumbLocationPopoverProps {
|
||||
veilBoundaryRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ReturnType<typeof setTimeout> | 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({
|
||||
<button
|
||||
type='button'
|
||||
aria-label={rootBreadcrumb?.label ?? 'Path'}
|
||||
onClick={rootBreadcrumb?.onClick}
|
||||
onClick={() => navigateAndClose(rootBreadcrumb?.onClick)}
|
||||
onFocus={openPopover}
|
||||
onBlur={scheduleClose}
|
||||
onMouseEnter={openPopover}
|
||||
onMouseLeave={scheduleClose}
|
||||
onMouseMove={openPopover}
|
||||
onPointerEnter={openPopover}
|
||||
onPointerLeave={scheduleClose}
|
||||
onPointerMove={openPopover}
|
||||
className={cn(
|
||||
chipVariants({ flush: true }),
|
||||
'max-w-none gap-1.5 px-2 transition-colors',
|
||||
@@ -457,10 +483,6 @@ function BreadcrumbLocationPopover({
|
||||
)}
|
||||
onMouseEnter={openPopover}
|
||||
onMouseLeave={scheduleClose}
|
||||
onMouseMove={openPopover}
|
||||
onPointerEnter={openPopover}
|
||||
onPointerLeave={scheduleClose}
|
||||
onPointerMove={openPopover}
|
||||
>
|
||||
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
@@ -474,7 +496,7 @@ function BreadcrumbLocationPopover({
|
||||
key={`${crumb.label}-${index}`}
|
||||
icon={crumb.icon || (index === 0 ? Icon : undefined)}
|
||||
label={crumb.label}
|
||||
onClick={crumb.onClick}
|
||||
onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined}
|
||||
active={index === breadcrumbs.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
+4
-2
@@ -15,6 +15,8 @@ const SLIDE_TRANSITION =
|
||||
|
||||
interface WorkspaceChromeProps {
|
||||
children: React.ReactNode
|
||||
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
|
||||
initialSidebarCollapsed?: boolean
|
||||
}
|
||||
|
||||
function isFullscreenPath(pathname: string | null): boolean {
|
||||
@@ -41,7 +43,7 @@ function isFullscreenPath(pathname: string | null): boolean {
|
||||
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
|
||||
* so no slide plays (CSS transitions don't run on mount).
|
||||
*/
|
||||
export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
|
||||
export function WorkspaceChrome({ children, initialSidebarCollapsed }: WorkspaceChromeProps) {
|
||||
const pathname = usePathname()
|
||||
const isFullscreen = isFullscreenPath(pathname)
|
||||
|
||||
@@ -103,7 +105,7 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
|
||||
isFullscreen && '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
<Sidebar initialCollapsed={initialSidebarCollapsed} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Music } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
|
||||
import { resolveFileCategory } from './file-category'
|
||||
import type { StreamingMode } from './text-editor-state'
|
||||
import { useDocPreviewBinary } from './use-doc-preview-binary'
|
||||
|
||||
export type { StreamingMode } from './text-editor-state'
|
||||
|
||||
import { CsvTablePreview } from './csv-table-preview'
|
||||
import { DocxPreview } from './docx-preview'
|
||||
import { resolveFileCategory } from './file-category'
|
||||
import { ImagePreview } from './image-preview'
|
||||
import type { PdfDocumentSource } from './pdf-viewer'
|
||||
import { PptxPreview } from './pptx-preview'
|
||||
@@ -27,13 +21,17 @@ import {
|
||||
resolvePreviewError,
|
||||
} from './preview-shared'
|
||||
import { TextEditor } from './text-editor'
|
||||
import { useDocPreviewBinary } from './use-doc-preview-binary'
|
||||
import { XlsxPreview } from './xlsx-preview'
|
||||
|
||||
const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const logger = createLogger('FileViewer')
|
||||
const RichMarkdownEditor = dynamic(
|
||||
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
|
||||
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }
|
||||
)
|
||||
|
||||
/**
|
||||
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
|
||||
@@ -50,6 +48,15 @@ export function isPreviewable(file: { type: string; name: string }): boolean {
|
||||
return resolvePreviewType(file.type, file.name) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
|
||||
* the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
|
||||
* which don't apply to the single-surface editor.
|
||||
*/
|
||||
export function isMarkdownFile(file: { type: string; name: string }): boolean {
|
||||
return resolvePreviewType(file.type, file.name) === 'markdown'
|
||||
}
|
||||
|
||||
/**
|
||||
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
|
||||
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
|
||||
@@ -84,7 +91,6 @@ interface FileViewerProps {
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
streamingContent?: string
|
||||
streamingMode?: StreamingMode
|
||||
disableStreamingAutoScroll?: boolean
|
||||
previewContextKey?: string
|
||||
}
|
||||
@@ -100,7 +106,6 @@ export function FileViewer({
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
streamingContent,
|
||||
streamingMode,
|
||||
disableStreamingAutoScroll = false,
|
||||
previewContextKey,
|
||||
}: FileViewerProps) {
|
||||
@@ -114,6 +119,14 @@ export function FileViewer({
|
||||
if (isCsvStreamOnly(file)) {
|
||||
return <UnsupportedPreview file={file} />
|
||||
}
|
||||
// Markdown renders through the inline rich editor (non-editable) so the public share
|
||||
// surface matches the in-app reading experience; canEdit={false} disables autosave,
|
||||
// the bubble menu, and every other editing affordance.
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
|
||||
)
|
||||
}
|
||||
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
|
||||
@@ -122,6 +135,24 @@ export function FileViewer({
|
||||
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<RichMarkdownEditor
|
||||
key={file.id}
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
autoFocus={autoFocus}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
streamingContent={streamingContent}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TextEditor
|
||||
file={file}
|
||||
@@ -133,7 +164,6 @@ export function FileViewer({
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
streamingContent={streamingContent}
|
||||
streamingMode={streamingMode}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export { resolveFileCategory } from './file-category'
|
||||
export type { PreviewMode } from './file-viewer'
|
||||
export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer'
|
||||
export {
|
||||
FileViewer,
|
||||
isCsvStreamOnly,
|
||||
isMarkdownFile,
|
||||
isPreviewable,
|
||||
isTextEditable,
|
||||
} from './file-viewer'
|
||||
export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel'
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { looksLikeMermaid } from './mermaid-diagram'
|
||||
|
||||
describe('looksLikeMermaid', () => {
|
||||
it('detects blocks whose first line opens with a diagram keyword', () => {
|
||||
expect(looksLikeMermaid('flowchart TD\n A --> B')).toBe(true)
|
||||
expect(looksLikeMermaid('graph LR\n A --> B')).toBe(true)
|
||||
expect(looksLikeMermaid('sequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
|
||||
expect(looksLikeMermaid('pie title NETFLIX\n "A" : 90')).toBe(true)
|
||||
expect(looksLikeMermaid('stateDiagram-v2\n [*] --> S')).toBe(true)
|
||||
expect(looksLikeMermaid('gantt\n title A')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the remaining diagram openers', () => {
|
||||
expect(looksLikeMermaid('classDiagram\n Animal <|-- Duck')).toBe(true)
|
||||
expect(looksLikeMermaid('stateDiagram\n [*] --> S')).toBe(true)
|
||||
expect(looksLikeMermaid('erDiagram\n A ||--o{ B : has')).toBe(true)
|
||||
expect(looksLikeMermaid('journey\n title My day')).toBe(true)
|
||||
expect(looksLikeMermaid('quadrantChart\n title Reach')).toBe(true)
|
||||
expect(looksLikeMermaid('requirementDiagram')).toBe(true)
|
||||
expect(looksLikeMermaid('gitGraph')).toBe(true)
|
||||
expect(looksLikeMermaid('mindmap\n root')).toBe(true)
|
||||
expect(looksLikeMermaid('timeline\n title History')).toBe(true)
|
||||
expect(looksLikeMermaid('sankey-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('xychart-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('block-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('packet-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('kanban')).toBe(true)
|
||||
expect(looksLikeMermaid('architecture-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('zenuml')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Context\n title System')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Container')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Component')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Dynamic')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Deployment')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips leading blank lines before the opener', () => {
|
||||
expect(looksLikeMermaid('\n\n flowchart TD\n A --> B')).toBe(true)
|
||||
expect(looksLikeMermaid('\n \n\t\nsequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects ordinary code that merely contains a keyword later', () => {
|
||||
expect(looksLikeMermaid('const graph = makeGraph()\nreturn graph')).toBe(false)
|
||||
expect(looksLikeMermaid('print("pie")')).toBe(false)
|
||||
expect(looksLikeMermaid('SELECT * FROM pies')).toBe(false)
|
||||
expect(looksLikeMermaid('')).toBe(false)
|
||||
expect(looksLikeMermaid('\n\n \n')).toBe(false)
|
||||
expect(looksLikeMermaid('# flowchart of the system')).toBe(false)
|
||||
expect(looksLikeMermaid(' // graph helpers')).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a word boundary after the keyword', () => {
|
||||
expect(looksLikeMermaid('graphql query { user }')).toBe(false)
|
||||
expect(looksLikeMermaid('pieChart()')).toBe(false)
|
||||
expect(looksLikeMermaid('flowcharting()')).toBe(false)
|
||||
expect(looksLikeMermaid('ganttify')).toBe(false)
|
||||
expect(looksLikeMermaid('journeyman')).toBe(false)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
+2
-922
@@ -1,51 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
memo,
|
||||
use,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import matter from 'gray-matter'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { Checkbox, highlight, languages } from '@/components/emcn'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
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 { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractTextContent } from '@/lib/core/utils/react-node-text'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { useScrollAnchor } from '@/hooks/use-scroll-anchor'
|
||||
import { RESUME_SKIP_THRESHOLD, useSmoothText } from '@/hooks/use-smooth-text'
|
||||
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
|
||||
import { DataTable } from './data-table'
|
||||
import { PreviewLoadingFrame } from './preview-shared'
|
||||
import { MermaidDiagram } from './mermaid-diagram'
|
||||
import { ZoomablePreview } from './zoomable-preview'
|
||||
|
||||
interface HastNode {
|
||||
position?: { start?: { offset?: number } }
|
||||
}
|
||||
|
||||
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null
|
||||
|
||||
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
|
||||
@@ -81,8 +44,6 @@ interface PreviewPanelProps {
|
||||
workspaceId: string
|
||||
fileKey: string
|
||||
isStreaming?: boolean
|
||||
disableAutoScroll?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
/**
|
||||
* Read-only surface (e.g. the public share page) — disables interactive
|
||||
* affordances such as the CSV "Import as a table" action, which needs an
|
||||
@@ -98,21 +59,10 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
workspaceId,
|
||||
fileKey,
|
||||
isStreaming,
|
||||
disableAutoScroll,
|
||||
onCheckboxToggle,
|
||||
readOnly,
|
||||
}: PreviewPanelProps) {
|
||||
const previewType = resolvePreviewType(mimeType, filename)
|
||||
|
||||
if (previewType === 'markdown')
|
||||
return (
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
isStreaming={isStreaming}
|
||||
disableAutoScroll={disableAutoScroll}
|
||||
onCheckboxToggle={onCheckboxToggle}
|
||||
/>
|
||||
)
|
||||
if (previewType === 'html') return <HtmlPreview content={content} />
|
||||
if (previewType === 'csv')
|
||||
return (
|
||||
@@ -130,876 +80,6 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
return null
|
||||
})
|
||||
|
||||
const CALLOUT_TYPES = new Set(['NOTE', 'TIP', 'WARNING', 'IMPORTANT', 'CAUTION'])
|
||||
|
||||
function remarkMermaid() {
|
||||
return (tree: { type: string; children?: unknown[] }) => {
|
||||
function processNode(node: {
|
||||
type: string
|
||||
children?: unknown[]
|
||||
lang?: string
|
||||
value?: string
|
||||
data?: Record<string, unknown>
|
||||
}) {
|
||||
if (!node.children) return
|
||||
for (const child of node.children) {
|
||||
const c = child as typeof node
|
||||
if (c.type === 'code' && c.lang === 'mermaid') {
|
||||
c.data = {
|
||||
hName: 'mermaid-diagram',
|
||||
hProperties: { definition: c.value ?? '' },
|
||||
hChildren: [],
|
||||
}
|
||||
} else {
|
||||
processNode(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
processNode(tree)
|
||||
}
|
||||
}
|
||||
|
||||
function remarkCallouts() {
|
||||
return (tree: { type: string; children?: unknown[] }) => {
|
||||
function processNode(node: { type: string; children?: unknown[] }) {
|
||||
if (!node.children) return
|
||||
for (const child of node.children) {
|
||||
processNode(child as { type: string; children?: unknown[] })
|
||||
const c = child as {
|
||||
type: string
|
||||
children?: unknown[]
|
||||
data?: { hName?: string; hProperties?: Record<string, string> }
|
||||
}
|
||||
if (c.type !== 'blockquote') continue
|
||||
const first = (c.children?.[0] ?? null) as {
|
||||
type: string
|
||||
children?: unknown[]
|
||||
} | null
|
||||
if (!first || first.type !== 'paragraph') continue
|
||||
const firstText = (first.children?.[0] ?? null) as {
|
||||
type: string
|
||||
value?: string
|
||||
} | null
|
||||
if (!firstText || firstText.type !== 'text' || !firstText.value) continue
|
||||
const match = firstText.value.match(/^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\s?/i)
|
||||
if (!match) continue
|
||||
const calloutType = match[1].toUpperCase()
|
||||
if (!CALLOUT_TYPES.has(calloutType)) continue
|
||||
|
||||
c.data ??= {}
|
||||
c.data.hProperties = { ...(c.data.hProperties ?? {}), 'data-callout': calloutType }
|
||||
|
||||
const remainder = firstText.value.slice(match[0].length)
|
||||
if (remainder) {
|
||||
firstText.value = remainder
|
||||
} else if (first.children && first.children.length === 1) {
|
||||
c.children?.shift()
|
||||
} else {
|
||||
first.children?.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
processNode(tree)
|
||||
}
|
||||
}
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkMermaid, remarkCallouts]
|
||||
const REHYPE_PLUGINS = [rehypeSlug]
|
||||
|
||||
const STREAMDOWN_ALLOWED_TAGS: Record<string, string[]> = {
|
||||
'mermaid-diagram': ['definition'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft per-character fade for newly revealed markdown while streaming. Mirrors
|
||||
* the chat surface so a streamed file preview reveals with the same cadence;
|
||||
* paired with {@link useSmoothText}, which paces the reveal itself.
|
||||
*/
|
||||
const STREAM_ANIMATION = {
|
||||
animation: 'fadeIn',
|
||||
duration: 220,
|
||||
stagger: 0,
|
||||
sep: 'char',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Gates the per-character fade to streams that build the document from
|
||||
* scratch. Enabling the fade over an already-rendered document, or during
|
||||
* in-place rewrites (patch snapshots), replays it on text that is already
|
||||
* visible, so the gate latches off for those sessions.
|
||||
*/
|
||||
function useStreamAnimationGate(content: string, isStreaming: boolean): boolean {
|
||||
const prevIsStreamingRef = useRef(false)
|
||||
const prevContentRef = useRef(content)
|
||||
const animateRef = useRef(false)
|
||||
|
||||
if (isStreaming !== prevIsStreamingRef.current) {
|
||||
animateRef.current = isStreaming && content.length <= RESUME_SKIP_THRESHOLD
|
||||
} else if (
|
||||
isStreaming &&
|
||||
animateRef.current &&
|
||||
content !== prevContentRef.current &&
|
||||
!content.startsWith(prevContentRef.current)
|
||||
) {
|
||||
animateRef.current = false
|
||||
}
|
||||
prevIsStreamingRef.current = isStreaming
|
||||
prevContentRef.current = content
|
||||
|
||||
return isStreaming && animateRef.current
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the contentRef and toggle handler from MarkdownPreview down to the
|
||||
* task-list renderers. Only present when the preview is interactive.
|
||||
*/
|
||||
const MarkdownCheckboxCtx = createContext<{
|
||||
contentRef: React.MutableRefObject<string>
|
||||
onToggle: (index: number, checked: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
|
||||
const CheckboxIndexCtx = createContext(-1)
|
||||
|
||||
const NavigateCtx = createContext<((path: string) => void) | null>(null)
|
||||
|
||||
const CALLOUT_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; borderColor: string; bgColor: string; textColor: string; iconColor: string }
|
||||
> = {
|
||||
NOTE: {
|
||||
label: 'Note',
|
||||
borderColor: 'border-blue-400/60',
|
||||
bgColor: 'bg-blue-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
TIP: {
|
||||
label: 'Tip',
|
||||
borderColor: 'border-emerald-400/60',
|
||||
bgColor: 'bg-emerald-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-emerald-500',
|
||||
},
|
||||
WARNING: {
|
||||
label: 'Warning',
|
||||
borderColor: 'border-amber-400/60',
|
||||
bgColor: 'bg-amber-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-amber-500',
|
||||
},
|
||||
IMPORTANT: {
|
||||
label: 'Important',
|
||||
borderColor: 'border-violet-400/60',
|
||||
bgColor: 'bg-violet-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-violet-500',
|
||||
},
|
||||
CAUTION: {
|
||||
label: 'Caution',
|
||||
borderColor: 'border-red-400/60',
|
||||
bgColor: 'bg-red-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
}
|
||||
|
||||
const CALLOUT_ICONS: Record<string, string> = {
|
||||
NOTE: 'ℹ',
|
||||
TIP: '💡',
|
||||
WARNING: '⚠',
|
||||
IMPORTANT: '❕',
|
||||
CAUTION: '🛑',
|
||||
}
|
||||
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
jsx: 'javascript',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
html: 'markup',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
py: 'python',
|
||||
}
|
||||
|
||||
function CalloutBlock({ type, children }: { type: string; children?: React.ReactNode }) {
|
||||
const config = CALLOUT_CONFIG[type]
|
||||
if (!config) {
|
||||
return (
|
||||
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-4 rounded-lg border-l-4 px-4 py-3 text-[14px]',
|
||||
config.borderColor,
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('mb-1 flex items-center gap-1.5 font-semibold text-[13px]', config.iconColor)}
|
||||
>
|
||||
<span>{CALLOUT_ICONS[type]}</span>
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
<div className={cn('break-words leading-[1.6]', config.textColor)}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MermaidSourcePreview({
|
||||
definition,
|
||||
isRendering,
|
||||
status,
|
||||
}: {
|
||||
definition: string
|
||||
isRendering: boolean
|
||||
status?: string
|
||||
}) {
|
||||
return (
|
||||
<div className='my-4 overflow-hidden rounded-lg border border-[var(--border)]'>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>mermaid</span>
|
||||
{(isRendering || status) && (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
{isRendering ? 'Rendering…' : status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='code-editor-theme bg-[var(--surface-5)]'>
|
||||
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
|
||||
<code>{definition}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MermaidCodeBlockFallback() {
|
||||
return (
|
||||
<div className='my-4 overflow-hidden rounded-lg border border-[var(--border)]'>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>mermaid</span>
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>Rendering…</span>
|
||||
</div>
|
||||
<div className='code-editor-theme min-h-[96px] bg-[var(--surface-5)]'>
|
||||
<div className='p-4' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MermaidDiagram = memo(function MermaidDiagram({
|
||||
definition,
|
||||
isStreaming = false,
|
||||
zoomable = false,
|
||||
zoomClassName,
|
||||
}: {
|
||||
definition: string
|
||||
isStreaming?: boolean
|
||||
zoomable?: boolean
|
||||
zoomClassName?: string
|
||||
}) {
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isRendering, setIsRendering] = useState(false)
|
||||
const [renderedDefinition, setRenderedDefinition] = useState<string | null>(null)
|
||||
const trimmedDefinition = definition.trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!trimmedDefinition) {
|
||||
setSvg(null)
|
||||
setError(null)
|
||||
setIsRendering(false)
|
||||
setRenderedDefinition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const renderDelay = isStreaming ? 150 : 0
|
||||
|
||||
async function render() {
|
||||
try {
|
||||
setIsRendering(true)
|
||||
const { default: mermaid } = await import('mermaid')
|
||||
if (cancelled) return
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'default',
|
||||
})
|
||||
mermaid.setParseErrorHandler?.(() => undefined)
|
||||
|
||||
if (isStreaming) {
|
||||
const parsed = await mermaid.parse(trimmedDefinition, { suppressErrors: true })
|
||||
if (!parsed) {
|
||||
if (!cancelled) {
|
||||
setError(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
await mermaid.parse(trimmedDefinition)
|
||||
}
|
||||
|
||||
const { svg: rendered } = await mermaid.render(
|
||||
`mermaid-${generateShortId(8)}`,
|
||||
trimmedDefinition
|
||||
)
|
||||
if (!cancelled) {
|
||||
setSvg(rendered)
|
||||
setRenderedDefinition(trimmedDefinition)
|
||||
setError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (isStreaming) {
|
||||
setError(null)
|
||||
} else {
|
||||
setError(toError(err).message || 'Failed to render diagram')
|
||||
setSvg(null)
|
||||
setRenderedDefinition(null)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsRendering(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setError(null)
|
||||
const timer = window.setTimeout(() => {
|
||||
render()
|
||||
}, renderDelay)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [trimmedDefinition, isStreaming])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MermaidSourcePreview definition={definition} isRendering={false} status='Invalid diagram' />
|
||||
)
|
||||
}
|
||||
|
||||
if (svg && renderedDefinition === trimmedDefinition) {
|
||||
const diagram = <div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
|
||||
if (zoomable) {
|
||||
return (
|
||||
<ZoomablePreview
|
||||
className={zoomClassName ?? 'my-4 h-[420px] rounded-lg'}
|
||||
initialScale='fit'
|
||||
resetKey={renderedDefinition}
|
||||
>
|
||||
{diagram}
|
||||
</ZoomablePreview>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-4 overflow-auto rounded-lg' dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
)
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
return <MermaidSourcePreview definition={definition} isRendering={isRendering} />
|
||||
}
|
||||
|
||||
if (!trimmedDefinition || !svg || renderedDefinition !== trimmedDefinition) {
|
||||
if (zoomable) {
|
||||
return <PreviewLoadingFrame className='h-full' />
|
||||
}
|
||||
return <MermaidCodeBlockFallback />
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function resolveSimFileUrl(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
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
const STATIC_MARKDOWN_COMPONENTS = {
|
||||
pre: ({ children }: { children?: React.ReactNode }) => (
|
||||
<>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement<Record<string, unknown>>(child)
|
||||
? cloneElement(child, { 'data-block': 'true' })
|
||||
: child
|
||||
) ?? children}
|
||||
</>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h1
|
||||
id={id}
|
||||
className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h2
|
||||
id={id}
|
||||
className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h3
|
||||
id={id}
|
||||
className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h4
|
||||
id={id}
|
||||
className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h5
|
||||
id={id}
|
||||
className='mt-3 mb-1 break-words font-semibold text-[13px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h6
|
||||
id={id}
|
||||
className='mt-3 mb-1 break-words font-medium text-[12px] text-[var(--text-secondary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
inlineCode: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)] not-italic'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
|
||||
const langMatch = className?.match(/language-(\w+)/)
|
||||
const langRaw = langMatch?.[1] ?? ''
|
||||
const codeString = extractTextContent(children)
|
||||
|
||||
if (!codeString) {
|
||||
return (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)]'>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
const resolved = LANG_ALIASES[langRaw] || langRaw || 'javascript'
|
||||
const grammar = languages[resolved] || languages.javascript
|
||||
const html = grammar ? highlight(codeString.trimEnd(), grammar, resolved) : null
|
||||
|
||||
return (
|
||||
<div className='my-4 overflow-hidden rounded-lg border border-[var(--border)]'>
|
||||
<div className='border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>{langRaw || 'code'}</span>
|
||||
</div>
|
||||
<div className='code-editor-theme bg-[var(--surface-5)]'>
|
||||
{html ? (
|
||||
<pre
|
||||
className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] not-italic leading-[1.6]'
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] not-italic leading-[1.6]'>
|
||||
<code className='not-italic'>{codeString.trimEnd()}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className='break-words font-semibold'>{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em className='break-words'>{children}</em>,
|
||||
del: ({ children }: { children?: React.ReactNode }) => (
|
||||
<del className='line-through opacity-50'>{children}</del>
|
||||
),
|
||||
blockquote: ({
|
||||
children,
|
||||
'data-callout': calloutType,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
'data-callout'?: string
|
||||
}) => {
|
||||
if (calloutType && CALLOUT_TYPES.has(calloutType)) {
|
||||
return <CalloutBlock type={calloutType}>{children}</CalloutBlock>
|
||||
}
|
||||
return (
|
||||
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
hr: () => <hr className='my-6 border-[var(--border)]' />,
|
||||
img: ({ src, alt }: React.ImgHTMLAttributes<HTMLImageElement>) => {
|
||||
const resolvedSrc = resolveSimFileUrl(typeof src === 'string' ? src : undefined)
|
||||
return (
|
||||
<ZoomablePreview className='my-3 h-[360px] rounded-md' initialScale='actual'>
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={alt ?? ''}
|
||||
className='max-h-full max-w-full select-none object-contain'
|
||||
draggable={false}
|
||||
loading='lazy'
|
||||
/>
|
||||
</ZoomablePreview>
|
||||
)
|
||||
},
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className='my-4 max-w-full overflow-x-auto'>
|
||||
<table className='w-full border-collapse text-[13px]'>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: React.ReactNode }) => (
|
||||
<thead className='bg-[var(--surface-3)]'>{children}</thead>
|
||||
),
|
||||
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }: { children?: React.ReactNode }) => (
|
||||
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className='px-3 py-2 text-left font-semibold text-[13px] text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>
|
||||
),
|
||||
}
|
||||
|
||||
function UlRenderer({ className, children }: { className?: string; children?: React.ReactNode }) {
|
||||
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
|
||||
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function OlRenderer({ className, children }: { className?: string; children?: React.ReactNode }) {
|
||||
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
|
||||
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function LiRenderer({
|
||||
className,
|
||||
children,
|
||||
node,
|
||||
}: {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
node?: HastNode
|
||||
}) {
|
||||
const ctx = use(MarkdownCheckboxCtx)
|
||||
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
|
||||
|
||||
if (isTaskItem) {
|
||||
const [checkboxChild, ...contentChildren] = Children.toArray(children)
|
||||
const content = <span className='min-w-0 flex-1'>{contentChildren}</span>
|
||||
|
||||
if (ctx) {
|
||||
const offset = node?.position?.start?.offset
|
||||
if (offset === undefined) {
|
||||
return (
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>
|
||||
{checkboxChild}
|
||||
{content}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const before = ctx.contentRef.current.slice(0, offset)
|
||||
const prior = before.match(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm)
|
||||
return (
|
||||
<CheckboxIndexCtx.Provider value={prior ? prior.length : 0}>
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>
|
||||
{checkboxChild}
|
||||
{content}
|
||||
</li>
|
||||
</CheckboxIndexCtx.Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>
|
||||
{checkboxChild}
|
||||
{content}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return <li className='break-words leading-[1.6]'>{children}</li>
|
||||
}
|
||||
|
||||
function InputRenderer({ type, checked, ...props }: React.ComponentPropsWithoutRef<'input'>) {
|
||||
const ctx = use(MarkdownCheckboxCtx)
|
||||
const index = use(CheckboxIndexCtx)
|
||||
|
||||
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
|
||||
|
||||
const isInteractive = ctx !== null && index >= 0
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked ?? false}
|
||||
onCheckedChange={
|
||||
isInteractive ? (newChecked) => ctx.onToggle(index, Boolean(newChecked)) : undefined
|
||||
}
|
||||
disabled={!isInteractive}
|
||||
size='sm'
|
||||
className='mt-1 shrink-0'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function isInternalHref(
|
||||
href: string,
|
||||
origin = window.location.origin
|
||||
): { pathname: string; hash: string } | null {
|
||||
if (href.startsWith('#')) return { pathname: '', hash: href }
|
||||
try {
|
||||
const url = new URL(href, origin)
|
||||
if (url.origin === origin && url.pathname.startsWith('/workspace/')) {
|
||||
return { pathname: url.pathname, hash: url.hash }
|
||||
}
|
||||
} catch {
|
||||
if (href.startsWith('/workspace/')) {
|
||||
const hashIdx = href.indexOf('#')
|
||||
if (hashIdx === -1) return { pathname: href, hash: '' }
|
||||
return { pathname: href.slice(0, hashIdx), hash: href.slice(hashIdx) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
const navigate = use(NavigateCtx)
|
||||
const parsed = href ? isInternalHref(href) : null
|
||||
|
||||
const handleMarkdownLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (parsed.pathname === '' && parsed.hash) {
|
||||
const el = document.getElementById(parsed.hash.slice(1))
|
||||
if (el) {
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const destination = parsed.pathname + parsed.hash
|
||||
if (navigate) {
|
||||
navigate(destination)
|
||||
} else {
|
||||
window.location.assign(destination)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={parsed ? undefined : '_blank'}
|
||||
rel={parsed ? undefined : 'noopener noreferrer'}
|
||||
onClick={handleMarkdownLinkClick}
|
||||
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
...STATIC_MARKDOWN_COMPONENTS,
|
||||
a: AnchorRenderer,
|
||||
ul: UlRenderer,
|
||||
ol: OlRenderer,
|
||||
li: LiRenderer,
|
||||
input: InputRenderer,
|
||||
}
|
||||
|
||||
function createMarkdownComponents(isStreaming: boolean) {
|
||||
return {
|
||||
...MARKDOWN_COMPONENTS,
|
||||
'mermaid-diagram': ({ definition }: { definition?: string }) => (
|
||||
<MermaidDiagram
|
||||
definition={definition ?? ''}
|
||||
isStreaming={isStreaming}
|
||||
zoomable
|
||||
zoomClassName='my-4 h-[420px] rounded-lg'
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function FrontMatterCard({ data }: { data: Record<string, unknown> }) {
|
||||
const entries = Object.entries(data)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='mb-6 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3 text-[13px]'>
|
||||
<dl className='flex flex-col gap-1.5'>
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-2 break-words'>
|
||||
<dt className='shrink-0 font-medium text-[var(--text-secondary)]'>{key}:</dt>
|
||||
<dd className='text-[var(--text-primary)]'>
|
||||
{Array.isArray(value)
|
||||
? value.join(', ')
|
||||
: value instanceof Date
|
||||
? value.toISOString().split('T')[0]
|
||||
: String(value ?? '')}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkdownPreview = memo(function MarkdownPreview({
|
||||
content,
|
||||
isStreaming = false,
|
||||
disableAutoScroll = false,
|
||||
onCheckboxToggle,
|
||||
}: {
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
disableAutoScroll?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
}) {
|
||||
const { push: navigate } = useRouter()
|
||||
// Pace the reveal so streamed markdown builds at a steady cadence instead of
|
||||
// jumping per server chunk. `snapOnNonAppend` shows in-place rewrites (patch)
|
||||
// in full immediately so a diff never appears to retype from the top.
|
||||
const revealedContent = useSmoothText(content, isStreaming, { snapOnNonAppend: true })
|
||||
const shouldAnimateStream = useStreamAnimationGate(content, isStreaming)
|
||||
const { ref: autoScrollRef, spacerRef } = useScrollAnchor(
|
||||
isStreaming && !disableAutoScroll,
|
||||
revealedContent
|
||||
)
|
||||
|
||||
const contentRef = useRef(content)
|
||||
contentRef.current = content
|
||||
|
||||
const { frontMatterData, markdownContent } = useMemo(() => {
|
||||
if (isStreaming) return { frontMatterData: null, markdownContent: revealedContent }
|
||||
try {
|
||||
const parsed = matter(content)
|
||||
const hasFrontMatter = Object.keys(parsed.data).length > 0
|
||||
return {
|
||||
frontMatterData: hasFrontMatter ? parsed.data : null,
|
||||
markdownContent: hasFrontMatter ? parsed.content : content,
|
||||
}
|
||||
} catch {
|
||||
return { frontMatterData: null, markdownContent: content }
|
||||
}
|
||||
}, [content, revealedContent, isStreaming])
|
||||
|
||||
const ctxValue = useMemo(
|
||||
() => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null),
|
||||
[onCheckboxToggle]
|
||||
)
|
||||
|
||||
const hasScrolledToHash = useRef(false)
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
if (!hash || hasScrolledToHash.current) return
|
||||
const id = hash.slice(1)
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
hasScrolledToHash.current = true
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const streamdownMode = isStreaming ? undefined : 'static'
|
||||
const markdownComponents = useMemo(() => createMarkdownComponents(isStreaming), [isStreaming])
|
||||
|
||||
const body = (
|
||||
<div ref={autoScrollRef} className='h-full overflow-auto p-6'>
|
||||
{frontMatterData && <FrontMatterCard data={frontMatterData} />}
|
||||
<Streamdown
|
||||
mode={streamdownMode}
|
||||
animated={shouldAnimateStream ? STREAM_ANIMATION : false}
|
||||
isAnimating={isStreaming}
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={markdownComponents}
|
||||
allowedTags={STREAMDOWN_ALLOWED_TAGS}
|
||||
>
|
||||
{markdownContent}
|
||||
</Streamdown>
|
||||
<div ref={spacerRef} aria-hidden />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<NavigateCtx.Provider value={navigate}>
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>{body}</MarkdownCheckboxCtx.Provider>
|
||||
</NavigateCtx.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
const HTML_PREVIEW_BASE_URL = 'about:srcdoc'
|
||||
|
||||
const HTML_PREVIEW_CSP = [
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import { CodeBlock } from '@tiptap/extension-code-block'
|
||||
import type { ReactNodeViewProps } from '@tiptap/react'
|
||||
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
|
||||
import {
|
||||
chipVariants,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
|
||||
import { detectLanguage } from './detect-language'
|
||||
|
||||
const PLAIN = 'plain'
|
||||
const MERMAID = 'mermaid'
|
||||
|
||||
/** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). Every non-plain
|
||||
* value MUST have a grammar registered in {@link CodeBlockHighlight} — enforced by a unit test. */
|
||||
export const LANGUAGE_OPTIONS = [
|
||||
{ value: PLAIN, label: 'Plain text' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'c', label: 'C' },
|
||||
{ value: 'cpp', label: 'C++' },
|
||||
{ value: 'csharp', label: 'C#' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'markup', label: 'HTML' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'ruby', label: 'Ruby' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
] as const
|
||||
|
||||
const CONTROL_CLASS =
|
||||
'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-none transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]'
|
||||
|
||||
/**
|
||||
* Code block view with hover controls (language picker, line-wrap, copy). When the block holds
|
||||
* Mermaid — tagged ```mermaid or {@link looksLikeMermaid auto-detected} — it renders as a diagram
|
||||
* whenever the cursor is outside it (and always in read-only), and as editable source while the
|
||||
* cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
|
||||
* (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
|
||||
* ordinary code block, so markdown round-trips unchanged.
|
||||
*/
|
||||
function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
|
||||
const [wrap, setWrap] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [editingInline, setEditingInline] = useState(false)
|
||||
const [peekSource, setPeekSource] = useState(false)
|
||||
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
|
||||
|
||||
const explicitLanguage = node.attrs.language as string | null
|
||||
const text = node.textContent
|
||||
const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
|
||||
|
||||
// Editable Mermaid shows source while the caret is focused inside the block and re-renders the
|
||||
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
|
||||
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
|
||||
useEffect(() => {
|
||||
if (!isMermaid || !editor.isEditable) {
|
||||
setEditingInline(false)
|
||||
return
|
||||
}
|
||||
const sync = () => {
|
||||
const pos = typeof getPos === 'function' ? getPos() : null
|
||||
if (typeof pos !== 'number') {
|
||||
setEditingInline(false)
|
||||
return
|
||||
}
|
||||
const size = editor.state.doc.nodeAt(pos)?.nodeSize ?? 0
|
||||
const { from } = editor.state.selection
|
||||
setEditingInline(editor.isFocused && from > pos && from < pos + size)
|
||||
}
|
||||
sync()
|
||||
editor.on('selectionUpdate', sync)
|
||||
editor.on('focus', sync)
|
||||
editor.on('blur', sync)
|
||||
return () => {
|
||||
editor.off('selectionUpdate', sync)
|
||||
editor.off('focus', sync)
|
||||
editor.off('blur', sync)
|
||||
}
|
||||
}, [editor, getPos, isMermaid])
|
||||
|
||||
const showSource = editor.isEditable ? editingInline : peekSource
|
||||
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
|
||||
|
||||
// Skip language detection on the mermaid path — the picker/label never render there.
|
||||
const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
|
||||
const label =
|
||||
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
|
||||
explicitLanguage ??
|
||||
'Plain text'
|
||||
|
||||
const toggleSource = () => {
|
||||
if (!editor.isEditable) {
|
||||
setPeekSource((value) => !value)
|
||||
return
|
||||
}
|
||||
const pos = typeof getPos === 'function' ? getPos() : null
|
||||
if (typeof pos !== 'number') return
|
||||
if (editingInline) {
|
||||
// Back to the diagram: select the whole node (reliable, and shows the same ring) rather than
|
||||
// relying on a blur event to fire.
|
||||
editor.commands.setNodeSelection(pos)
|
||||
return
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(pos + 1)
|
||||
.run()
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className='group relative'>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1.5 right-2 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100',
|
||||
menuOpen && 'opacity-100'
|
||||
)}
|
||||
contentEditable={false}
|
||||
>
|
||||
{isMermaid && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label={showSource ? 'Show diagram' : 'Show source'}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={toggleSource}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
{showSource ? <Eye /> : <Code />}
|
||||
</button>
|
||||
)}
|
||||
{!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.
|
||||
<DropdownMenu onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Code language'
|
||||
className={cn(
|
||||
chipVariants({ variant: 'default', flush: true }),
|
||||
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() =>
|
||||
updateAttributes({ language: option.value === PLAIN ? null : option.value })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className='flex h-[24px] items-center px-1.5 text-[var(--text-muted)] text-caption'>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{!isMermaid && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Toggle line wrap'
|
||||
aria-pressed={wrap}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => setWrap((value) => !value)}
|
||||
className={cn(
|
||||
CONTROL_CLASS,
|
||||
wrap && 'bg-[var(--surface-active)] text-[var(--text-body)]'
|
||||
)}
|
||||
>
|
||||
<WrapText />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Copy code'
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => copy(text)}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className={cn('code-editor-theme pr-20', showDiagram && 'hidden')} data-wrap={wrap}>
|
||||
<NodeViewContent<'code'> as='code' />
|
||||
</pre>
|
||||
{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.
|
||||
<div
|
||||
contentEditable={false}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
const pos = typeof getPos === 'function' ? getPos() : null
|
||||
if (typeof pos === 'number') editor.commands.setNodeSelection(pos)
|
||||
}}
|
||||
>
|
||||
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
})
|
||||
+81
@@ -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)
|
||||
})
|
||||
})
|
||||
+133
@@ -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)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+21
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
+38
@@ -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('<div class="x">hi</div>')).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<String> items; }')).toBe('java')
|
||||
expect(detectLanguage('let v: Vec<String> = Vec::new();\nfn f() {}')).toBe('rust')
|
||||
expect(detectLanguage('func Map[T any](s []T) {}\npackage x')).toBe('go')
|
||||
})
|
||||
})
|
||||
+63
@@ -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 `<Word>` so generics (`List<String>`, `Vec<T>`) aren't misread as markup.
|
||||
{ language: 'markup', test: /<\/[a-z][\w-]*\s*>|<[a-z][\w-]*\s+[\w:-]+=|<!(?:doctype\b|--)/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: 'go',
|
||||
test: /^\s*package\s+\w+|\bfunc\s+(\(\w[^)]*\)\s+)?\w+\s*\(|\btype\s+\w+\s+(struct|interface)\b|\bfmt\.\w|:=/m,
|
||||
},
|
||||
{
|
||||
language: 'rust',
|
||||
test: /\bfn\s+\w+\s*[(<]|\blet\s+mut\b|\bimpl\b|\bpub\s+(fn|struct|enum|mod)\b|println!/,
|
||||
},
|
||||
{
|
||||
language: 'java',
|
||||
test: /\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|void|int|String|boolean)\b|System\.out\.print/,
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
+55
@@ -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)
|
||||
})
|
||||
})
|
||||
+113
@@ -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 }),
|
||||
]
|
||||
}
|
||||
+57
@@ -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)
|
||||
})
|
||||
})
|
||||
+36
@@ -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<string, number>()
|
||||
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
|
||||
}
|
||||
+56
@@ -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([])
|
||||
})
|
||||
})
|
||||
+14
@@ -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)
|
||||
}
|
||||
+27
@@ -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/')
|
||||
})
|
||||
})
|
||||
+283
@@ -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 `[](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, '<')
|
||||
.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 `<img>` 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 ``. An image with an `href` is
|
||||
* wrapped in a markdown link so a linked badge round-trips as `[](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 = `<img ${parts.join(' ')}>`
|
||||
} 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 `` 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<string, unknown>) =>
|
||||
attributes.width ? { width: String(attributes.width) } : {},
|
||||
}
|
||||
|
||||
const heightAttr = {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('height'),
|
||||
renderHTML: (attributes: Record<string, unknown>) =>
|
||||
attributes.height ? { height: String(attributes.height) } : {},
|
||||
}
|
||||
|
||||
/** Link target of a linked image — markdown-only state, never emitted as an HTML `<img>` attribute. */
|
||||
const hrefAttr = { default: null, rendered: false }
|
||||
const hrefTitleAttr = { default: null, rendered: false }
|
||||
|
||||
/**
|
||||
* Image node that carries optional `width`/`height` (serialized as an HTML `<img>` 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 `<img width>`.
|
||||
*/
|
||||
function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) {
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
const dragAbortRef = useRef<AbortController | null>(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 = (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={resolveDisplaySrc(attrs.src)}
|
||||
alt={attrs.alt ?? ''}
|
||||
title={attrs.title ?? undefined}
|
||||
// When editable, the image itself is the drag handle — grab anywhere on it to reorder. (The node
|
||||
// view's wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
|
||||
// the resize button sits outside this element, so it keeps its own pointer behavior.)
|
||||
draggable={editable}
|
||||
data-drag-handle={editable ? '' : undefined}
|
||||
style={widthStyle}
|
||||
className={`block max-w-full rounded-lg border border-[var(--border)]${editable ? ' cursor-grab' : ''}`}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className='relative my-4 inline-block leading-none'>
|
||||
{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.
|
||||
<a
|
||||
href={safeHref}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='block'
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
{image}
|
||||
</a>
|
||||
) : (
|
||||
image
|
||||
)}
|
||||
{editable && (selected || dragging) && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Resize image'
|
||||
onPointerDown={startResize}
|
||||
className='absolute right-1 bottom-1 size-3 cursor-nwse-resize rounded-[3px] border border-[var(--bg)] bg-[var(--brand-secondary)]'
|
||||
/>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live image node with the drag-to-resize view; same schema + markdown output as the headless one. */
|
||||
export const ResizableImage = MarkdownImage.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ResizableImageView)
|
||||
},
|
||||
})
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
|
||||
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
|
||||
|
||||
/**
|
||||
* Arrowing off the edge of a textblock toward an adjacent divider or image selects that node
|
||||
* (a NodeSelection), giving keyboard parity with clicking it. Without this the gap cursor swallows
|
||||
* the arrow and the node can never be selected — or deleted — from the keyboard.
|
||||
*/
|
||||
function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
|
||||
const { selection } = editor.state
|
||||
if (!selection.empty || !editor.view.endOfTextblock(direction)) return false
|
||||
const { $from } = selection
|
||||
const boundary = direction === 'up' ? $from.before($from.depth) : $from.after($from.depth)
|
||||
const resolved = editor.state.doc.resolve(boundary)
|
||||
const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter
|
||||
if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false
|
||||
return editor.commands.setNodeSelection(
|
||||
direction === 'up' ? boundary - adjacent.nodeSize : boundary
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
|
||||
*
|
||||
* - **Backspace** at the start of a heading reverts it to a paragraph; at the start of a block whose
|
||||
* previous sibling is a horizontal rule it deletes the rule (ProseMirror's default `joinBackward`
|
||||
* can't cross a leaf node, so without this pressing Backspace below a divider is a confusing no-op).
|
||||
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
|
||||
* block is already fully selected) falls through to the default whole-document select-all, the
|
||||
* same scoped behavior as a code editor.
|
||||
* - **ArrowUp/ArrowDown** select an adjacent divider or image (see {@link selectAdjacentLeaf}).
|
||||
*/
|
||||
export const RichMarkdownKeymap = Extension.create({
|
||||
name: 'richMarkdownKeymap',
|
||||
priority: 1000,
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { selection, doc } = editor.state
|
||||
if (!selection.empty || selection.$from.parentOffset !== 0) return false
|
||||
// At the start of a heading, revert it to a paragraph (ProseMirror's default joins or
|
||||
// no-ops, stranding the heading style); a second Backspace then merges as usual.
|
||||
if (selection.$from.parent.type.name === 'heading') {
|
||||
return editor.commands.setParagraph()
|
||||
}
|
||||
const blockStart = selection.$from.before(selection.$from.depth)
|
||||
const nodeBefore = doc.resolve(blockStart).nodeBefore
|
||||
if (nodeBefore?.type.name !== 'horizontalRule') return false
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.delete(blockStart - nodeBefore.nodeSize, blockStart)
|
||||
return true
|
||||
})
|
||||
},
|
||||
'Mod-a': ({ editor }) => {
|
||||
const { $from } = editor.state.selection
|
||||
if ($from.parent.type.name !== 'codeBlock') return false
|
||||
const from = $from.start($from.depth)
|
||||
const to = $from.end($from.depth)
|
||||
if (editor.state.selection.from === from && editor.state.selection.to === to) return false
|
||||
return editor.commands.setTextSelection({ from, to })
|
||||
},
|
||||
ArrowUp: ({ editor }) => selectAdjacentLeaf(editor, 'up'),
|
||||
ArrowDown: ({ editor }) => selectAdjacentLeaf(editor, 'down'),
|
||||
}
|
||||
},
|
||||
})
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Typing a markdown link `[text](url)` should convert to a real link mark on the closing `)`.
|
||||
* Input rules only fire on real text input, so these drive the editor's `handleTextInput` path
|
||||
* (NOT `insertContent`, which bypasses input rules).
|
||||
*/
|
||||
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(): Editor {
|
||||
return new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
}
|
||||
|
||||
/** Type `prefix` (no input rules), then simulate typing the final char so the input rule fires. */
|
||||
function typeWithFinalChar(ed: Editor, prefix: string, finalChar: string): boolean {
|
||||
ed.commands.setContent('', { contentType: 'markdown' })
|
||||
ed.commands.insertContent(prefix)
|
||||
const pos = ed.state.selection.from
|
||||
return ed.view.someProp('handleTextInput', (fn) => fn(ed.view, pos, pos, finalChar)) === true
|
||||
}
|
||||
|
||||
describe('typed markdown link input rule', () => {
|
||||
it('converts [text](url) to a link mark on the closing paren', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[hi](https://example.com', ')')
|
||||
const json = JSON.stringify(editor.getJSON())
|
||||
expect(json).toContain('"type":"link"')
|
||||
expect(json).toContain('"href":"https://example.com"')
|
||||
expect(editor.getText()).toBe('hi')
|
||||
})
|
||||
|
||||
it('normalizes a bare domain to https (parity with paste)', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[site](www.example.com', ')')
|
||||
expect(JSON.stringify(editor.getJSON())).toContain('"href":"https://www.example.com"')
|
||||
})
|
||||
|
||||
it('preserves a link title', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[t](https://e.com "the title"', ')')
|
||||
const json = JSON.stringify(editor.getJSON())
|
||||
expect(json).toContain('"href":"https://e.com"')
|
||||
expect(json).toContain('"title":"the title"')
|
||||
})
|
||||
|
||||
it('refuses an unsafe scheme (leaves it literal)', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[x](javascript:alert(1)', ')')
|
||||
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
|
||||
})
|
||||
|
||||
it('does not fire inside a code block', () => {
|
||||
editor = mount()
|
||||
editor.commands.setContent('```\n\n```', { contentType: 'markdown' })
|
||||
editor.commands.setTextSelection(2)
|
||||
const pos = editor.state.selection.from
|
||||
editor.commands.insertContent('[x](https://e.com')
|
||||
editor.view.someProp('handleTextInput', (fn) => fn(editor!.view, pos, pos, ')'))
|
||||
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
|
||||
})
|
||||
})
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Extension, InputRule } from '@tiptap/core'
|
||||
import { normalizeLinkHref } from './markdown-fidelity'
|
||||
|
||||
/**
|
||||
* Typed markdown link: `[text](url)` or `[text](url "title")`, completed by the closing `)`. The URL
|
||||
* is space-free (markdown requires `<url>` for spaces, which this intentionally skips). StarterKit's
|
||||
* Link ships no input rule — only paste/autolink — so without this, typed link syntax stays literal.
|
||||
*/
|
||||
const LINK_INPUT_RULE = /\[([^\]]+)]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/
|
||||
|
||||
/**
|
||||
* Converts a typed markdown link into a real link mark on the closing `)`. The visible text is the
|
||||
* first capture group (so `markInputRule`, which keeps the *last* group, can't express this); the
|
||||
* href comes from the second group, normalized through {@link normalizeLinkHref} so a bare domain
|
||||
* gets `https://` and an unsafe scheme (`javascript:`) is refused (left as literal text). Skipped
|
||||
* inside code blocks, where the brackets are literal source.
|
||||
*/
|
||||
export const MarkdownLinkInputRule = Extension.create({
|
||||
name: 'markdownLinkInputRule',
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: LINK_INPUT_RULE,
|
||||
handler: ({ state, range, match }) => {
|
||||
if (state.selection.$from.parent.type.spec.code) return null
|
||||
const linkType = state.schema.marks.link
|
||||
if (!linkType) return null
|
||||
const [fullMatch, text, rawHref, title] = match
|
||||
const href = normalizeLinkHref(rawHref ?? '')
|
||||
if (!href || !text) return null
|
||||
|
||||
const { tr } = state
|
||||
const textStart = range.from + fullMatch.indexOf(text)
|
||||
const textEnd = textStart + text.length
|
||||
if (textEnd < range.to) tr.delete(textEnd, range.to)
|
||||
if (textStart > range.from) tr.delete(range.from, textStart)
|
||||
const markEnd = range.from + text.length
|
||||
tr.addMark(range.from, markEnd, linkType.create({ href, title: title || null }))
|
||||
tr.removeStoredMark(linkType)
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Fidelity helpers that keep markdown TipTap can't model losslessly intact across an edit
|
||||
* cycle. YAML frontmatter is held out of the editor entirely (TipTap parses `---` as a
|
||||
* thematic break and corrupts it), and a couple of serializer quirks are smoothed over.
|
||||
*/
|
||||
|
||||
const BOM = '\uFEFF'
|
||||
const FRONTMATTER_REGEX = /^---\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n)*/
|
||||
const ESCAPED_CALLOUT_REGEX = /^(\s*>(?:\s*>)*\s*)\\\[!([A-Za-z]+)\\\]/gm
|
||||
|
||||
export interface SplitMarkdown {
|
||||
/** Out-of-band leading prefix (a BOM and/or the frontmatter block), byte-exact, or `''`. */
|
||||
frontmatter: string
|
||||
body: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the leading out-of-band prefix — an optional UTF-8 BOM and YAML frontmatter — from
|
||||
* the body. `frontmatter + body` reconstructs the input exactly, so {@link applyFrontmatter}
|
||||
* can re-attach it without rewriting any whitespace, and the body never reaches TipTap with a
|
||||
* BOM (which would defeat the frontmatter anchor and corrupt it).
|
||||
*/
|
||||
export function splitFrontmatter(markdown: string): SplitMarkdown {
|
||||
const bom = markdown.startsWith(BOM) ? BOM : ''
|
||||
const rest = bom ? markdown.slice(1) : markdown
|
||||
const match = rest.match(FRONTMATTER_REGEX)
|
||||
if (!match) return { frontmatter: bom, body: rest }
|
||||
return { frontmatter: bom + match[0], body: rest.slice(match[0].length) }
|
||||
}
|
||||
|
||||
export function applyFrontmatter(frontmatter: string, body: string): string {
|
||||
return frontmatter + body
|
||||
}
|
||||
|
||||
/** A leading `scheme://` URL (network protocol). */
|
||||
const SCHEME_URL = /^([a-z][a-z0-9+.-]*):\/\//i
|
||||
/** A leading `scheme:` token (per the URL grammar). */
|
||||
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
|
||||
/** A bare `host:port` (digits after the colon) — looks scheme-like but is really a domain. */
|
||||
const HOST_PORT = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i
|
||||
|
||||
/**
|
||||
* Normalize a user-entered link target: prefix a bare domain with `https://` so it doesn't resolve
|
||||
* as an in-app relative URL, while leaving already-qualified, relative, and protocol-relative URLs
|
||||
* intact. Dangerous schemes are rejected outright rather than trusted or mangled: any `scheme:`
|
||||
* without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`, `blob:`, …), and
|
||||
* `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …) pass through.
|
||||
*/
|
||||
export function normalizeLinkHref(href: string): string {
|
||||
const trimmed = href.trim()
|
||||
if (!trimmed) return ''
|
||||
if (/^[#?]/.test(trimmed)) return trimmed
|
||||
if (trimmed.startsWith('//')) return `https:${trimmed}`
|
||||
if (trimmed.startsWith('/')) return trimmed
|
||||
// Relative paths (`./other.md`, `../doc.md`) stay relative — never prefixed into `https://./…`.
|
||||
if (trimmed.startsWith('./') || trimmed.startsWith('../')) return trimmed
|
||||
if (/^(?:mailto|tel):/i.test(trimmed)) return trimmed
|
||||
const schemed = trimmed.match(SCHEME_URL)
|
||||
if (schemed) return /^file$/i.test(schemed[1]) ? '' : trimmed
|
||||
// A `scheme:` without `//` (and not mailto/tel) is a script/data scheme — reject it. A bare
|
||||
// host:port (digits after the colon) is a domain, not a scheme, so it falls through to https.
|
||||
if (HAS_SCHEME.test(trimmed) && !HOST_PORT.test(trimmed)) return ''
|
||||
return `https://${trimmed}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up serializer output: restores callout markers the serializer backslash-escapes
|
||||
* (`> \[!NOTE\]` → `> [!NOTE]`) and collapses trailing blank lines to a single newline. The
|
||||
* table serializer's spurious surrounding blank lines are trimmed at the source (PipeSafeTable),
|
||||
* so no global leading-newline strip is needed here — avoiding clobbering content that legitimately
|
||||
* begins with whitespace.
|
||||
*/
|
||||
export function postProcessSerializedMarkdown(markdown: string): string {
|
||||
return markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]').replace(/\n+$/, '\n')
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
import { parseMarkdownToDoc, serializeMarkdownBody, splitMarkdownBlocks } from './markdown-parse'
|
||||
import { isRoundTripSafe } from './round-trip-safety'
|
||||
|
||||
let editor: Editor | null = null
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
/** The current whole-document path: parse markdown in one shot, serialize back. */
|
||||
function oneShot(body: string): string {
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent(body, { contentType: 'markdown' })
|
||||
const out = editor.getMarkdown()
|
||||
editor.destroy()
|
||||
editor = null
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunked parsing must be byte-identical to the one-shot path — these are the structures a naive
|
||||
* blank-line split would shatter (loose lists span blank lines, list items hold multiple paragraphs,
|
||||
* blockquotes and fenced code contain blank lines), so they're the real fidelity test.
|
||||
*/
|
||||
const CASES: Array<[string, string]> = [
|
||||
[
|
||||
'heading + inline marks',
|
||||
'# Heading\n\nA paragraph with **bold**, *italic*, `code`, and a [link](https://x.com).',
|
||||
],
|
||||
['tight list', '- tight a\n- tight b\n- tight c'],
|
||||
['loose list (blank lines between items)', '- loose a\n\n- loose b\n\n- loose c'],
|
||||
['multi-paragraph list item', '1. first\n\n second paragraph in item one\n\n2. second item'],
|
||||
['nested list', '- outer\n - nested one\n - nested two\n - deeper\n- outer two'],
|
||||
[
|
||||
'nested blockquote with blank lines',
|
||||
'> a blockquote\n>\n> with two paragraphs\n>\n> > and a nested quote',
|
||||
],
|
||||
['gfm table', '| col a | col b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |'],
|
||||
[
|
||||
'fenced code with internal blank line',
|
||||
'```ts\nconst x = 1\n\nfunction f() {\n return x\n}\n```',
|
||||
],
|
||||
['task list', '- [ ] task one\n- [x] task two done\n - [ ] subtask'],
|
||||
['thematic break between paragraphs', 'Para before.\n\n---\n\nPara after a divider.'],
|
||||
[
|
||||
'image + linked-image badge',
|
||||
'\n\n[](https://link.example)',
|
||||
],
|
||||
// The editor serializes nested lists with sub-3-space indentation, which a strict external lexer
|
||||
// would mis-nest — this is the exact case that must survive re-parsing (idempotency).
|
||||
[
|
||||
'reduced-indent nested list (editor output shape)',
|
||||
'1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second',
|
||||
],
|
||||
['heading-separated sections', '# A\n\nalpha\n\n## B\n\nbeta\n\n## C\n\ngamma'],
|
||||
]
|
||||
|
||||
describe('parseMarkdownToDoc (chunked)', () => {
|
||||
it('produces a doc node', () => {
|
||||
const doc = parseMarkdownToDoc('# Hi\n\nbody')
|
||||
expect(doc.type).toBe('doc')
|
||||
expect(Array.isArray(doc.content)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(CASES)('chunked parse round-trips identically to one-shot: %s', (_label, body) => {
|
||||
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
|
||||
})
|
||||
|
||||
// The editor re-parses its own output on every settle/repeat-stream, so a second pass must not
|
||||
// drift — otherwise editing + saving + reopening would slowly corrupt structure (this is the bug
|
||||
// that an external lexer introduced for sub-3-space nested lists).
|
||||
it.each(CASES)('is idempotent (a second pass changes nothing): %s', (_label, body) => {
|
||||
const once = serializeMarkdownBody(body)
|
||||
expect(serializeMarkdownBody(once)).toBe(once)
|
||||
})
|
||||
|
||||
it('empty and whitespace-only input produce an empty doc', () => {
|
||||
expect(parseMarkdownToDoc('').type).toBe('doc')
|
||||
expect(parseMarkdownToDoc(' \n\n ').type).toBe('doc')
|
||||
expect(splitMarkdownBlocks('')).toEqual([])
|
||||
expect(splitMarkdownBlocks('\n\n \n')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses reference-style links whole (non-chunkable) without dropping the definition', () => {
|
||||
const body = 'See [the docs][ref] for details.\n\n[ref]: https://example.com/docs'
|
||||
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
|
||||
})
|
||||
|
||||
// Block-level HTML can wrap blank lines; it routes to the whole-document fallback so chunked output
|
||||
// still matches one-shot exactly. (Such docs open read-only via the round-trip-safety probe, so
|
||||
// they're never re-serialized — the editor itself isn't idempotent on raw HTML.)
|
||||
it.each([
|
||||
['html block', '<div class="x">\n\ncontent\n\n</div>'],
|
||||
['html comment', 'before\n\n<!-- a note -->\n\nafter'],
|
||||
['html table', '<table>\n\n<tr><td>a</td></tr>\n\n</table>'],
|
||||
])(
|
||||
'block HTML renders via the whole-document fallback, matching one-shot: %s',
|
||||
(_label, body) => {
|
||||
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
|
||||
}
|
||||
)
|
||||
|
||||
describe('splitMarkdownBlocks keeps ambiguous structures atomic', () => {
|
||||
it('a loose list (blank lines between items) stays one block', () => {
|
||||
expect(splitMarkdownBlocks('- a\n\n- b\n\n- c')).toEqual(['- a\n\n- b\n\n- c'])
|
||||
})
|
||||
it('a nested list (no blank lines) stays one block', () => {
|
||||
expect(splitMarkdownBlocks('1. First\n - sub\n - two\n2. Second')).toHaveLength(1)
|
||||
})
|
||||
it('independent paragraphs split into separate blocks', () => {
|
||||
expect(splitMarkdownBlocks('para one\n\npara two\n\npara three')).toHaveLength(3)
|
||||
})
|
||||
it('headings and paragraphs split; fenced code with blank lines stays one block', () => {
|
||||
expect(splitMarkdownBlocks('# H\n\ntext\n\n```\na\n\nb\n```')).toEqual([
|
||||
'# H',
|
||||
'text',
|
||||
'```\na\n\nb\n```',
|
||||
])
|
||||
})
|
||||
it('a multi-paragraph list item (indented continuation) stays one block', () => {
|
||||
expect(splitMarkdownBlocks('1. first\n\n second para\n\n2. next')).toHaveLength(1)
|
||||
})
|
||||
it('CRLF line endings still split (a closing fence ending in \\r must close)', () => {
|
||||
// A Windows-authored file with fenced code must not collapse to one block (which would defeat
|
||||
// the chunker); the closer ending in `\r` has to match. Assert block COUNT, not just fidelity.
|
||||
const crlf = '```ts\r\nx\r\n```\r\n\r\npara1\r\n\r\npara2\r\n\r\npara3'
|
||||
expect(splitMarkdownBlocks(crlf)).toEqual(['```ts\nx\n```', 'para1', 'para2', 'para3'])
|
||||
})
|
||||
})
|
||||
|
||||
it('matches one-shot on a large mixed document (the case the chunker exists for)', () => {
|
||||
const blocks: string[] = ['# Big Doc']
|
||||
for (let i = 0; i < 300; i++) {
|
||||
blocks.push(
|
||||
`## Section ${i}\n\nProse with **bold** and a [link](https://x.com/${i}) and \`code\`.`
|
||||
)
|
||||
if (i % 10 === 0) blocks.push(`\`\`\`ts\nconst x = ${i}\n\`\`\``)
|
||||
if (i % 9 === 0) blocks.push('- item a\n\n- item b\n\n- item c')
|
||||
if (i % 7 === 0) blocks.push('| a | b |\n| --- | --- |\n| 1 | 2 |')
|
||||
}
|
||||
const body = blocks.join('\n\n')
|
||||
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
|
||||
})
|
||||
})
|
||||
|
||||
/** Deterministic PRNG (mulberry32) so a failure is always reproducible from its seed. */
|
||||
function rng(seed: number) {
|
||||
return () => {
|
||||
seed |= 0
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const FUZZ_BLOCKS: Array<(r: () => number) => string> = [
|
||||
() => '# Heading one',
|
||||
() => '### Heading three',
|
||||
(r) =>
|
||||
`A paragraph with **bold**, *italic*, \`code\`, and a [link](https://x.com/${Math.floor(r() * 99)}).`,
|
||||
() => '- tight a\n- tight b\n- tight c',
|
||||
() => '- loose a\n\n- loose b\n\n- loose c',
|
||||
() => '1. ordered one\n2. ordered two\n3. ordered three',
|
||||
() => '1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second',
|
||||
() => '1. item\n\n a second paragraph inside the item\n\n2. next item',
|
||||
() => '> a blockquote\n> spanning lines\n>\n> > and a nested one',
|
||||
() => '| col a | col b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |',
|
||||
() => '```ts\nconst x = 1\n\nfunction f() {\n return x\n}\n```',
|
||||
() => '- [ ] todo one\n- [x] done two\n - [ ] subtask',
|
||||
() => '---',
|
||||
() => '',
|
||||
() => '[](https://link.example)',
|
||||
() => 'Text with ~~strikethrough~~ and a soft \nline break inside it.',
|
||||
// Raw HTML / reference defs route to the whole-document fallback, so fidelity must still hold even
|
||||
// though the doc itself opens read-only (idempotency is only asserted for editable docs).
|
||||
() => '<div class="note">\n\nwrapped content\n\n</div>',
|
||||
() => 'See [the docs][ref].\n\n[ref]: https://example.com/docs',
|
||||
]
|
||||
|
||||
function buildFuzzDoc(seed: number): string {
|
||||
const r = rng(seed)
|
||||
const count = 2 + Math.floor(r() * 8)
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i < count; i++) parts.push(FUZZ_BLOCKS[Math.floor(r() * FUZZ_BLOCKS.length)](r))
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
describe('chunked parse — property test over randomized documents', () => {
|
||||
it('chunked === one-shot for every document, and idempotent for every editable one', () => {
|
||||
const failures: Array<{ seed: number; kind: string }> = []
|
||||
for (let seed = 1; seed <= 400; seed++) {
|
||||
const body = buildFuzzDoc(seed)
|
||||
const chunked = serializeMarkdownBody(body)
|
||||
// Fidelity is the load-bearing invariant — chunked must never diverge from the whole-document
|
||||
// parse, for ANY input; idempotency only needs to hold where the doc is editable (raw HTML is
|
||||
// non-idempotent in the underlying editor regardless of chunking, which is why it opens read-only).
|
||||
if (chunked !== oneShot(body)) failures.push({ seed, kind: 'fidelity' })
|
||||
else if (isRoundTripSafe(body) && serializeMarkdownBody(chunked) !== chunked) {
|
||||
failures.push({ seed, kind: 'idempotency' })
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([])
|
||||
// 400 docs each parsed+serialized twice — generous timeout so it can't flake under parallel load.
|
||||
}, 30000)
|
||||
})
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { Editor, type JSONContent } from '@tiptap/core'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
|
||||
/**
|
||||
* A single reused editor for chunked markdown parse/serialize, created lazily so importing this
|
||||
* module — including during SSR — never constructs it. `MarkdownManager.parse` is pure and re-entrant
|
||||
* (it builds its own lexer and never reads the editor's document), so sharing one instance is safe;
|
||||
* `serializeMarkdownBody` additionally reuses it as a scratchpad, overwriting its document via
|
||||
* `setContent`. Both are safe because all access is synchronous and single-threaded — each call fully
|
||||
* completes before the next — so no call ever observes another's partial state. One bounded instance
|
||||
* for the session, not a per-call allocation.
|
||||
*/
|
||||
let parser: Editor | null = null
|
||||
|
||||
function parserEditor(): Editor {
|
||||
if (!parser) parser = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
return parser
|
||||
}
|
||||
|
||||
function markdownManager() {
|
||||
const manager = parserEditor().markdown
|
||||
if (!manager) throw new Error('Markdown extension is not installed on the parser editor')
|
||||
return manager
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs whose meaning spans blank-line boundaries, so the document can't be split into blocks
|
||||
* without changing how they parse — these documents parse whole (correct, if slower; they're
|
||||
* uncommon and almost always round-trip-unsafe and read-only anyway):
|
||||
* - A link/image *reference definition* (`[id]: url`) or footnote definition can sit far from its
|
||||
* `[text][id]` / `[^id]` use; splitting them apart would drop the reference. The editor never
|
||||
* *emits* reference-style links, so this only matters on the first open of such a file.
|
||||
* - A block-level HTML element (`<div>…</div>`, `<table>…`) or HTML comment can wrap blank lines; the
|
||||
* splitter would shatter it (matched here by a line that opens an HTML tag/comment, not inline
|
||||
* `<https://…>` autolinks).
|
||||
*/
|
||||
const NON_CHUNKABLE =
|
||||
/^[ ]{0,3}(?:\[(?:\^[^\]]+|[^\]^][^\]]*)\]:\s|<(?:!--|\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]))/m
|
||||
|
||||
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/
|
||||
const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
|
||||
const LIST_MARKER = /^[ ]{0,3}(?:[-*+]|\d+[.)])\s/
|
||||
const BLOCKQUOTE = /^[ ]{0,3}>/
|
||||
|
||||
/**
|
||||
* Split a markdown body into top-level blocks that can each be parsed independently and reassembled
|
||||
* without changing meaning. Blank lines separate candidate groups (fenced code blocks stay atomic),
|
||||
* then adjacent groups are merged back together whenever they could form one logical block: any
|
||||
* indented (continuation) group, and consecutive list/blockquote groups (which would otherwise be a
|
||||
* single loose list/quote). Merging is intentionally conservative — over-merging only yields a larger
|
||||
* chunk, whereas under-merging would shatter a structure — and every block is parsed by
|
||||
* `@tiptap/markdown`'s own lexer, so block boundaries always match the parser.
|
||||
*
|
||||
* The indent-merge rule is load-bearing for fenced code indented past 3 spaces (e.g. inside a list
|
||||
* item): {@link FENCE_OPEN} only tracks fences at the document margin, so a nested fence's interior
|
||||
* blank lines are held together by the indent merge, not the fence tracker. Weakening that merge
|
||||
* would silently shatter nested fences.
|
||||
*/
|
||||
export function splitMarkdownBlocks(body: string): string[] {
|
||||
// Normalize CRLF/CR first: the fence/list/blockquote line tests anchor on `$`, so a trailing `\r`
|
||||
// would stop a closing fence matching and swallow the rest of a Windows-authored file into one
|
||||
// block (defeating the chunker). The editor normalizes `\r` on parse anyway, so meaning is unchanged.
|
||||
const lines = body.replace(/\r\n?/g, '\n').split('\n')
|
||||
const groups: string[] = []
|
||||
let current: string[] = []
|
||||
let fence: string | null = null
|
||||
const flush = () => {
|
||||
if (current.length > 0) groups.push(current.join('\n'))
|
||||
current = []
|
||||
}
|
||||
for (const line of lines) {
|
||||
if (fence) {
|
||||
current.push(line)
|
||||
const closer = line.match(FENCE_CLOSE)
|
||||
if (closer && closer[1][0] === fence[0] && closer[1].length >= fence.length) fence = null
|
||||
continue
|
||||
}
|
||||
const open = line.match(FENCE_OPEN)
|
||||
if (open) {
|
||||
current.push(line)
|
||||
fence = open[1]
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
current.push(line)
|
||||
}
|
||||
flush()
|
||||
|
||||
// Build continuation runs and join each once — concatenating onto the growing block per group would be
|
||||
// O(n²) for one long loose list. A group continues the run when indented, or when its first line and the
|
||||
// group open the same marker kind (list or blockquote) — i.e. they form one loose list/quote.
|
||||
const runs: string[][] = []
|
||||
for (const group of groups) {
|
||||
const head = runs.length > 0 ? runs[runs.length - 1][0] : null
|
||||
const continues =
|
||||
head !== null &&
|
||||
(/^\s/.test(group) ||
|
||||
(LIST_MARKER.test(head) && LIST_MARKER.test(group)) ||
|
||||
(BLOCKQUOTE.test(head) && BLOCKQUOTE.test(group)))
|
||||
if (continues) runs[runs.length - 1].push(group)
|
||||
else runs.push([group])
|
||||
}
|
||||
return runs.map((run) => run.join('\n\n'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown body into a ProseMirror doc by splitting it into top-level blocks and parsing each
|
||||
* independently, then assembling the results.
|
||||
*
|
||||
* `@tiptap/markdown`'s `setContent(md, 'markdown')` is superlinear (~O(n²)) in document size, which
|
||||
* freezes the main thread at mount for large files. Parsing block-by-block is linear — measured ~22ms
|
||||
* vs ~1270ms at 61KB — and byte-identical, because each block is parsed with the same tokenizers.
|
||||
* Documents whose constructs span blocks ({@link NON_CHUNKABLE}) parse whole, and any failure falls
|
||||
* back to a single whole-document parse, so correctness never depends on the splitter.
|
||||
*/
|
||||
export function parseMarkdownToDoc(body: string): JSONContent {
|
||||
const manager = markdownManager()
|
||||
if (NON_CHUNKABLE.test(body)) return manager.parse(body)
|
||||
try {
|
||||
const content: JSONContent[] = []
|
||||
for (const block of splitMarkdownBlocks(body)) {
|
||||
// `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks.
|
||||
content.push(...(manager.parse(block).content ?? []))
|
||||
}
|
||||
return { type: 'doc', content }
|
||||
} catch {
|
||||
return manager.parse(body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip a markdown body through the editor pipeline (chunked parse → serialize), linearly. The
|
||||
* doc is loaded via `setContent` (not serialized directly) so it passes through the same schema
|
||||
* normalization the live editor applies, keeping the output identical to `editor.getMarkdown()`.
|
||||
*/
|
||||
export function serializeMarkdownBody(body: string): string {
|
||||
const editor = parserEditor()
|
||||
editor.commands.setContent(parseMarkdownToDoc(body), { contentType: 'json' })
|
||||
return editor.getMarkdown()
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Pasting markdown source should render as rich content (links, images, badges) rather than literal
|
||||
* `[text](url)` text — except inside a code block, where it must stay literal.
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
import { MarkdownPaste } from './markdown-paste'
|
||||
|
||||
let editor: Editor | null = null
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
function mount(): Editor {
|
||||
return new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste] })
|
||||
}
|
||||
|
||||
/** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */
|
||||
function paste(ed: Editor, text: string, html = ''): boolean {
|
||||
const event = {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === 'text/plain' ? text : type === 'text/html' ? html : ''),
|
||||
},
|
||||
} as unknown as ClipboardEvent
|
||||
for (const plugin of ed.view.state.plugins) {
|
||||
if (plugin.props?.handlePaste?.(ed.view, event, ed.view.state.selection.content())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
describe('markdown paste', () => {
|
||||
it('renders a pasted inline link as a link mark', () => {
|
||||
editor = mount()
|
||||
expect(paste(editor, '[inline link](https://example.com)')).toBe(true)
|
||||
const json = JSON.stringify(editor.getJSON())
|
||||
expect(json).toContain('"type":"link"')
|
||||
expect(json).toContain('https://example.com')
|
||||
expect(json).not.toContain('[inline link]')
|
||||
})
|
||||
|
||||
it('renders a pasted badge as a linked image', () => {
|
||||
editor = mount()
|
||||
expect(paste(editor, '[](https://ci.example.com)')).toBe(true)
|
||||
const json = JSON.stringify(editor.getJSON())
|
||||
expect(json).toContain('"type":"image"')
|
||||
expect(json).toContain('"href":"https://ci.example.com"')
|
||||
})
|
||||
|
||||
it('leaves plain (non-markdown) text to the default handler', () => {
|
||||
editor = mount()
|
||||
expect(paste(editor, 'just a normal sentence with no syntax')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not markdown-parse a paste that carries richer HTML', () => {
|
||||
editor = mount()
|
||||
expect(paste(editor, '# heading', '<h1>heading</h1>')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps pasted markdown literal inside a code block', () => {
|
||||
editor = mount()
|
||||
editor.commands.setContent('```js\ncode here\n```', { contentType: 'markdown' })
|
||||
editor.commands.setTextSelection(5)
|
||||
expect(editor.isActive('codeBlock')).toBe(true)
|
||||
expect(paste(editor, '[link](https://example.com)')).toBe(false)
|
||||
})
|
||||
})
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
import { parseMarkdownToDoc } from './markdown-parse'
|
||||
|
||||
/**
|
||||
* Markdown syntax hints. If pasted plain text matches any of these, it's parsed as markdown rather
|
||||
* than inserted literally — so a pasted link, image, badge, list, or heading renders as rich content
|
||||
* instead of showing its raw `[text](url)` / `# ` source.
|
||||
*/
|
||||
const MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
|
||||
/^#{1,6}\s/m,
|
||||
/\*\*[^*]+\*\*/,
|
||||
/\[[^\]]*]\([^)]+\)/,
|
||||
/^\s*[-*+]\s/m,
|
||||
/^\s*\d+\.\s/m,
|
||||
/^>\s/m,
|
||||
/```/,
|
||||
/^\|.*\|.*\|/m,
|
||||
]
|
||||
|
||||
function looksLikeMarkdown(text: string): boolean {
|
||||
return MARKDOWN_HINTS.some((hint) => hint.test(text))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block
|
||||
* are left untouched (code is meant to stay literal), as are pastes that carry richer HTML — those
|
||||
* are handled by ProseMirror's own clipboard parsing.
|
||||
*/
|
||||
export const MarkdownPaste = Extension.create({
|
||||
name: 'markdownPaste',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handlePaste: (_view, event) => {
|
||||
if (!editor.isEditable) return false
|
||||
if (editor.isActive('codeBlock')) return false
|
||||
const html = event.clipboardData?.getData('text/html')
|
||||
if (html) return false
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (!text || !looksLikeMarkdown(text)) return false
|
||||
// Parse through the chunker (linear) so pasting a large markdown blob can't freeze the
|
||||
// main thread the way the underlying superlinear parse would.
|
||||
const doc = parseMarkdownToDoc(text)
|
||||
if (!doc.content?.length) return false
|
||||
return editor.commands.insertContent(doc)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
import { BubbleMenu } from '@tiptap/react/menus'
|
||||
import {
|
||||
Bold,
|
||||
Check,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
Strikethrough,
|
||||
TextQuote,
|
||||
Unlink,
|
||||
} from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { normalizeLinkHref } from '../markdown-fidelity'
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
shortcut?: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ToolbarButton({ icon: Icon, label, shortcut, isActive, onClick }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={label}
|
||||
aria-pressed={isActive}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex size-[28px] items-center justify-center rounded-md text-[var(--text-icon)] outline-none transition-colors focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]',
|
||||
isActive
|
||||
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
|
||||
: 'hover-hover:bg-[var(--surface-hover)]'
|
||||
)}
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : label}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
const linkInputRef = useRef<HTMLInputElement>(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 (!editor.isEditable) return
|
||||
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<Editor['chain']>) => {
|
||||
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 (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
role='toolbar'
|
||||
aria-label='Text formatting'
|
||||
updateDelay={0}
|
||||
shouldShow={({ editor: e, from, to }) => {
|
||||
// Read-only never shows the menu — even mid-link-edit (e.g. a stream starting) — so a link
|
||||
// can't be applied to a doc that must not mutate.
|
||||
if (!e.isEditable) return false
|
||||
if (isEditingLink) return true
|
||||
if (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 ? (
|
||||
<>
|
||||
<input
|
||||
ref={linkInputRef}
|
||||
aria-label='Link URL'
|
||||
type='text'
|
||||
inputMode='url'
|
||||
value={linkValue}
|
||||
onChange={(event) => 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 && (
|
||||
<ToolbarButton
|
||||
icon={Unlink}
|
||||
label='Remove link'
|
||||
isActive={false}
|
||||
onClick={removeLink}
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton icon={Check} label='Apply link' isActive={false} onClick={commitLink} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToolbarButton
|
||||
icon={Bold}
|
||||
label='Bold'
|
||||
shortcut='⌘B'
|
||||
isActive={active.bold}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Italic}
|
||||
label='Italic'
|
||||
shortcut='⌘I'
|
||||
isActive={active.italic}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Strikethrough}
|
||||
label='Strikethrough'
|
||||
shortcut='⌘⇧S'
|
||||
isActive={active.strike}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Code}
|
||||
label='Code'
|
||||
shortcut='⌘E'
|
||||
isActive={active.code}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={LinkIcon}
|
||||
label='Link'
|
||||
shortcut='⌘K'
|
||||
isActive={active.link}
|
||||
onClick={openLinkEditor}
|
||||
/>
|
||||
<ToolbarDivider />
|
||||
<ToolbarButton
|
||||
icon={Heading1}
|
||||
label='Heading 1'
|
||||
shortcut='⌘⌥1'
|
||||
isActive={active.heading1}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Heading2}
|
||||
label='Heading 2'
|
||||
shortcut='⌘⌥2'
|
||||
isActive={active.heading2}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
/>
|
||||
<ToolbarDivider />
|
||||
<ToolbarButton
|
||||
icon={List}
|
||||
label='Bulleted list'
|
||||
shortcut='⌘⇧8'
|
||||
isActive={active.bulletList}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={ListOrdered}
|
||||
label='Numbered list'
|
||||
shortcut='⌘⇧7'
|
||||
isActive={active.orderedList}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={ListChecks}
|
||||
label='Checklist'
|
||||
shortcut='⌘⇧9'
|
||||
isActive={active.taskList}
|
||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={TextQuote}
|
||||
label='Quote'
|
||||
shortcut='⌘⇧B'
|
||||
isActive={active.blockquote}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</BubbleMenu>
|
||||
)
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
.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,
|
||||
.rich-markdown-prose h5,
|
||||
.rich-markdown-prose h6 {
|
||||
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 h5 {
|
||||
font-size: 0.875em;
|
||||
margin-top: 1.1em;
|
||||
}
|
||||
|
||||
.rich-markdown-prose h6 {
|
||||
font-size: 0.8em;
|
||||
margin-top: 1.1em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.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,
|
||||
.rich-markdown-prose .mermaid-diagram-frame {
|
||||
background: var(--surface-5);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* A rendered Mermaid diagram wears the same frame as a code block (single source of truth above),
|
||||
centered within it. */
|
||||
.rich-markdown-prose .mermaid-diagram-frame {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 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,
|
||||
.dark .rich-markdown-prose .mermaid-diagram-frame {
|
||||
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;
|
||||
}
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
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 { PreviewLoadingFrame } from '../preview-shared'
|
||||
import { useEditableFileContent } from '../use-editable-file-content'
|
||||
import { createMarkdownEditorExtensions } from './extensions'
|
||||
import { findHeadingPos } from './heading-anchors'
|
||||
import { extractImageFiles } from './image-paste'
|
||||
import {
|
||||
applyFrontmatter,
|
||||
normalizeLinkHref,
|
||||
postProcessSerializedMarkdown,
|
||||
splitFrontmatter,
|
||||
} from './markdown-fidelity'
|
||||
import { parseMarkdownToDoc } from './markdown-parse'
|
||||
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<void>) | null>
|
||||
streamingContent?: string
|
||||
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 and no separate streaming preview.
|
||||
* Owns the file lifecycle through a single {@link useEditableFileContent} engine, and the TipTap
|
||||
* editor is the ONLY thing the user ever sees: while agent output streams in it renders that content
|
||||
* read-only (synced per chunk), then the same editor instance becomes editable once the stream
|
||||
* settles — so the stream→edit transition has no renderer swap or flash.
|
||||
*
|
||||
* The editor is keyed by file id (+ streaming context). A file opened outside a stream uses the plain
|
||||
* create-time initial-content model (no sync). See {@link LoadedRichMarkdownEditor} for the
|
||||
* read-only-stream → editable hand-off.
|
||||
*/
|
||||
export const RichMarkdownEditor = memo(function RichMarkdownEditor({
|
||||
file,
|
||||
workspaceId,
|
||||
canEdit,
|
||||
autoFocus,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
streamingContent,
|
||||
disableStreamingAutoScroll = false,
|
||||
previewContextKey,
|
||||
}: RichMarkdownEditorProps) {
|
||||
const {
|
||||
content,
|
||||
setDraftContent,
|
||||
isStreamInteractionLocked,
|
||||
isContentLoading,
|
||||
hasContentError,
|
||||
saveImmediately,
|
||||
} = useEditableFileContent({
|
||||
file,
|
||||
workspaceId,
|
||||
canEdit,
|
||||
streamingContent,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
})
|
||||
|
||||
if (isContentLoading) return <PreviewLoadingFrame className='flex flex-1 flex-col' />
|
||||
|
||||
if (hasContentError) {
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<p className='text-[var(--text-muted)] text-small'>Failed to load file content</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadedRichMarkdownEditor
|
||||
// Remount on a new streaming context so the stream/settle state is re-established fresh.
|
||||
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
content={content}
|
||||
isStreaming={isStreamInteractionLocked}
|
||||
canEdit={canEdit}
|
||||
autoFocus={autoFocus}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
onChange={setDraftContent}
|
||||
onSaveShortcut={saveImmediately}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
interface LoadedRichMarkdownEditorProps {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
/** The live content from the engine — grows as the agent streams, then settles to the saved doc. */
|
||||
content: string
|
||||
/** True while agent output is streaming in: the editor renders it read-only and syncs each chunk. */
|
||||
isStreaming: boolean
|
||||
canEdit: boolean
|
||||
autoFocus?: boolean
|
||||
disableStreamingAutoScroll?: boolean
|
||||
onChange: (markdown: string) => void
|
||||
onSaveShortcut: () => Promise<void>
|
||||
}
|
||||
|
||||
interface SettledContent {
|
||||
frontmatter: string
|
||||
verdict: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the round-trip verdict + frontmatter on the content the editor "opens" with — once, at mount
|
||||
* for a settled file or at the moment a stream settles. A round-trip-unsafe document (raw HTML,
|
||||
* footnotes, >128KB, …) opens read-only so an edit can't corrupt it; a safe one stays editable. Never
|
||||
* re-derived: a dirty document is safe by construction (the editor only emits safe markdown), so
|
||||
* flipping editability off mid-edit would only strand edits.
|
||||
*/
|
||||
function lockSettled(content: string): SettledContent {
|
||||
return { frontmatter: splitFrontmatter(content).frontmatter, verdict: isRoundTripSafe(content) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The single TipTap editor for a markdown file — the only surface the user ever sees. While agent
|
||||
* output streams in ({@link isStreaming}) it renders that content read-only and re-syncs each chunk;
|
||||
* when the stream settles it locks the round-trip verdict + frontmatter on the final content and
|
||||
* hands control to the user. A file opened outside a stream skips straight to that editable state via
|
||||
* the initial-content model (no imperative sync). Frontmatter is held aside and re-applied on every
|
||||
* change, so the editor only ever round-trips the body.
|
||||
*/
|
||||
export function LoadedRichMarkdownEditor({
|
||||
file,
|
||||
workspaceId,
|
||||
content,
|
||||
isStreaming,
|
||||
canEdit,
|
||||
autoFocus,
|
||||
disableStreamingAutoScroll,
|
||||
onChange,
|
||||
onSaveShortcut,
|
||||
}: LoadedRichMarkdownEditorProps) {
|
||||
// Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle.
|
||||
const streamingAtMountRef = useRef(isStreaming)
|
||||
|
||||
// Verdict + frontmatter locked once via {@link lockSettled} (at mount when settled, else when the
|
||||
// stream settles below); null until then reads as read-only.
|
||||
const settledRef = useRef<SettledContent | null>(null)
|
||||
if (!streamingAtMountRef.current && settledRef.current === null) {
|
||||
settledRef.current = lockSettled(content)
|
||||
}
|
||||
const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false)
|
||||
|
||||
// Seed the editor with the chunked-parsed doc (linear vs the editor's ~O(n²) markdown parse), computed
|
||||
// once via lazy state init — `useRef(parseMarkdownToDoc(...))` would re-parse the whole body every render.
|
||||
const [initialContent] = useState<JSONContent | string>(() =>
|
||||
streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body)
|
||||
)
|
||||
// Frontmatter held aside and re-attached on every change (the editor never shows it); re-derived per
|
||||
// stream→settle in the settle effect, so a repeat stream uses the new doc's frontmatter, not a stale one.
|
||||
const frontmatterRef = useRef(settledRef.current?.frontmatter ?? '')
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
const onSaveShortcutRef = useRef(onSaveShortcut)
|
||||
onSaveShortcutRef.current = onSaveShortcut
|
||||
const router = useRouter()
|
||||
const routerRef = useRef(router)
|
||||
routerRef.current = router
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const uploadFile = useUploadWorkspaceFile()
|
||||
const editorInstanceRef = useRef<Editor | null>(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<void>>(() =>
|
||||
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: streamingAtMountRef.current ? false : autoFocus ? 'end' : false,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
content: initialContent,
|
||||
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) => {
|
||||
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href')
|
||||
if (!href) return false
|
||||
// Editing: require a modifier so a plain click can place the cursor. Read-only (a reader, e.g.
|
||||
// the public share page): a plain click follows the link.
|
||||
if (view.editable && !(event.metaKey || event.ctrlKey)) return false
|
||||
// Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab,
|
||||
// restoring the table-of-contents links that worked via rehype-slug in the old preview.
|
||||
if (href.startsWith('#')) {
|
||||
const pos = findHeadingPos(view.state.doc, href.slice(1))
|
||||
if (pos < 0) return false
|
||||
;(view.nodeDOM(pos) as HTMLElement | null)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
return true
|
||||
}
|
||||
const normalized = normalizeLinkHref(href)
|
||||
if (!normalized) return false
|
||||
// A same-origin in-app path navigates within the SPA (same tab) — unless the reader
|
||||
// modifier-clicked for a new tab. External URLs always open a new tab.
|
||||
if (
|
||||
!(event.metaKey || event.ctrlKey) &&
|
||||
normalized.startsWith('/') &&
|
||||
!normalized.startsWith('//')
|
||||
) {
|
||||
routerRef.current.push(normalized)
|
||||
return true
|
||||
}
|
||||
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(frontmatterRef.current, md))
|
||||
},
|
||||
})
|
||||
editorInstanceRef.current = editor
|
||||
|
||||
// Stream content in read-only until it settles, then lock the verdict + frontmatter and hand off; after
|
||||
// that only `canEdit` touches the editor (it owns the content, so no sync can clobber a user edit).
|
||||
const lastSyncedBodyRef = useRef<string | null>(null)
|
||||
// Tracks whether the previous run was streaming so the settle branch re-locks on every stream→settle:
|
||||
// one instance can receive several agent edits in a chat (kept mounted by `previewContextKey`), so the
|
||||
// verdict/frontmatter must follow the latest stream, not the first settled snapshot.
|
||||
const wasStreamingRef = useRef(streamingAtMountRef.current)
|
||||
// Coalesce streamed chunks to one re-parse per animation frame — a fast agent emits many per frame and
|
||||
// each would re-parse the whole accumulating body. Read-only while streaming, so only the latest renders.
|
||||
const pendingStreamBodyRef = useRef<string | null>(null)
|
||||
const streamRafRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (isStreaming) {
|
||||
wasStreamingRef.current = true
|
||||
const body = splitFrontmatter(content).body
|
||||
if (body === lastSyncedBodyRef.current) return
|
||||
pendingStreamBodyRef.current = body
|
||||
if (streamRafRef.current !== null) return
|
||||
streamRafRef.current = requestAnimationFrame(() => {
|
||||
streamRafRef.current = null
|
||||
const pending = pendingStreamBodyRef.current
|
||||
if (pending === null || pending === lastSyncedBodyRef.current) return
|
||||
lastSyncedBodyRef.current = pending
|
||||
const el = containerRef.current
|
||||
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
|
||||
editor.setEditable(false)
|
||||
editor.commands.setContent(parseMarkdownToDoc(pending), {
|
||||
contentType: 'json',
|
||||
emitUpdate: false,
|
||||
})
|
||||
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
return
|
||||
}
|
||||
// Drop a frame scheduled just before settle so it can't land afterward and clobber the final content.
|
||||
if (streamRafRef.current !== null) {
|
||||
cancelAnimationFrame(streamRafRef.current)
|
||||
streamRafRef.current = null
|
||||
}
|
||||
// Settle: re-lock the verdict + frontmatter on the freshly-settled content — on the first settle and
|
||||
// every later stream→settle, so a repeat agent edit gates on the NEW content, not a stale snapshot.
|
||||
// User edits never reach here (`isStreaming`/`wasStreamingRef` stay false), preserving don't-strand-edits.
|
||||
const isInitialSettle = settledRef.current === null
|
||||
if (isInitialSettle || wasStreamingRef.current) {
|
||||
wasStreamingRef.current = false
|
||||
settledRef.current = lockSettled(content)
|
||||
frontmatterRef.current = settledRef.current.frontmatter
|
||||
// Re-seed only if the settled body differs from the last streamed chunk — it usually doesn't,
|
||||
// and an extra setContent would needlessly rebuild the doc and drop selection/scroll.
|
||||
const body = splitFrontmatter(content).body
|
||||
if (body !== lastSyncedBodyRef.current) {
|
||||
lastSyncedBodyRef.current = body
|
||||
editor.commands.setContent(parseMarkdownToDoc(body), {
|
||||
contentType: 'json',
|
||||
emitUpdate: false,
|
||||
})
|
||||
}
|
||||
editor.setEditable(canEdit && settledRef.current.verdict)
|
||||
if (isInitialSettle && autoFocus) editor.commands.focus('end')
|
||||
return
|
||||
}
|
||||
if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict)
|
||||
}, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (streamRafRef.current !== null) cancelAnimationFrame(streamRafRef.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
|
||||
>
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className='mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @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('[](https://e.com)')).toBe(true)
|
||||
expect(
|
||||
isRoundTripSafe('[](https://ci.example.com)')
|
||||
).toBe(true)
|
||||
expect(isRoundTripSafe('[](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')
|
||||
).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('<!-- a note -->\n\ntext')).toBe(false)
|
||||
expect(isRoundTripSafe('<details><summary>x</summary>body</details>')).toBe(false)
|
||||
expect(isRoundTripSafe('a <sub>b</sub> 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<!-- c -->\n~~~')).toBe(true)
|
||||
expect(isRoundTripSafe('````md\n```\n<div>x</div>\n```\n````')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-idempotent churn', () => {
|
||||
expect(isRoundTripSafe('render `` a`b `` inline')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag <br> outside a table (converts losslessly to a hard break)', () => {
|
||||
expect(isRoundTripSafe('a<br>b')).toBe(true)
|
||||
expect(isRoundTripSafe('a line\n\nwith | a pipe but no break')).toBe(true)
|
||||
expect(isRoundTripSafe('Use a<br>break or the pipe | operator.')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects <br> inside a table cell (flattened to a space)', () => {
|
||||
expect(isRoundTripSafe('| a | b |\n| --- | --- |\n| one<br>two | x |')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows <img> (a supported, resizable image node)', () => {
|
||||
expect(isRoundTripSafe('<img src="https://e.com/i.png" width="320">')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag a fenced block that merely contains html or backticks', () => {
|
||||
expect(isRoundTripSafe('```html\n<div>hi</div>\n```')).toBe(true)
|
||||
expect(isRoundTripSafe('````md\n```\ncode\n```\n````')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag markdown autolinks as raw html', () => {
|
||||
expect(isRoundTripSafe('see <https://sim.ai> for more')).toBe(true)
|
||||
})
|
||||
|
||||
it('probes documents up to the size cap but falls back (read-only) above it', () => {
|
||||
// ~100KB of simple safe prose is under the 256KB cap → probed and editable.
|
||||
expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(20000)}`)).toBe(true)
|
||||
// ~300KB is over the cap → opens read-only (too many DOM nodes to edit comfortably).
|
||||
expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(60000)}`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const README = `# Acme CLI
|
||||
|
||||
[](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: <https://sim.ai>
|
||||
|
||||
A normal link to [the site](https://sim.ai "title") and an image:
|
||||
|
||||

|
||||
|
||||
Use \`a & b\` and \`x < y\` in code freely.
|
||||
`
|
||||
|
||||
const EDITABLE_CORPUS: Record<string, string> = {
|
||||
README,
|
||||
MEETING_NOTES,
|
||||
CHANGELOG,
|
||||
NESTED_AND_QUOTES,
|
||||
TABLES_AND_LINKS,
|
||||
}
|
||||
|
||||
// Certainty corpus for the editability gate: realistic, full-length markdown (READMEs, notes,
|
||||
// changelogs, nested lists, tables, task lists, blockquotes, fenced code) must ALL stay editable —
|
||||
// the probe may only ever refuse genuinely lossy constructs, never ordinary prose.
|
||||
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(5000)}`
|
||||
expect(big.length).toBeLessThan(256 * 1024)
|
||||
expect(big.length).toBeGreaterThan(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 and exact boundary of the gate: constructs the WYSIWYG schema genuinely cannot
|
||||
// represent open read-only so an edit can't silently corrupt them.
|
||||
describe('editability gate — genuinely lossy constructs open read-only', () => {
|
||||
it('raw HTML blocks (<details>, <div align>) open read-only', () => {
|
||||
expect(isRoundTripSafe('<details><summary>More</summary>\n\nbody\n\n</details>')).toBe(false)
|
||||
expect(isRoundTripSafe('<div align="center">\n\ncentered\n\n</div>')).toBe(false)
|
||||
})
|
||||
|
||||
it('HTML comments and footnotes open read-only', () => {
|
||||
expect(isRoundTripSafe('<!-- TODO: revise -->\n\ntext')).toBe(false)
|
||||
expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(false)
|
||||
})
|
||||
})
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
applyFrontmatter,
|
||||
postProcessSerializedMarkdown,
|
||||
splitFrontmatter,
|
||||
} from './markdown-fidelity'
|
||||
import { serializeMarkdownBody } from './markdown-parse'
|
||||
|
||||
/**
|
||||
* Above this size the file opens read-only. Parsing is chunked and linear now (see
|
||||
* {@link serializeMarkdownBody}), so this is no longer about parse cost — it guards ProseMirror's
|
||||
* whole-document-in-DOM rendering, which has no virtualization and gets sluggish to edit for very
|
||||
* large documents. 256KB sits past the p99 of real markdown files while keeping a giant outlier from
|
||||
* mounting thousands of editable DOM nodes.
|
||||
*/
|
||||
const PROBE_SIZE_LIMIT = 256 * 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 `[](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** `<div>`, `<details>`, `<kbd>`, … — StarterKit has no HTML node, so the tag
|
||||
* is stripped (content kept, structure lost). `<br>` and `<img>` are excluded: `<br>` outside a
|
||||
* table converts to a hard break, and `<img>` is a first-class (resizable) image node.
|
||||
* - **`<br>` inside a table cell** — a GFM cell can't hold a real line break, so the serializer
|
||||
* flattens `one<br>two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `<br>`.
|
||||
* - **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<RegExp> = [
|
||||
/\[\^[^\]]+]/,
|
||||
/<!--/,
|
||||
/<\/?(?!(?:br|img)\b)[a-z][a-z0-9-]*(\s[^>]*)?\/?>/i,
|
||||
/^(?=(?:[^\n]*\|){2})[^\n]*<br\s*\/?>/im,
|
||||
/^#{1,6}\s.*(?: {2,}|\\)$/m,
|
||||
/&(?!(?:amp|lt|gt);)(?:#x?[0-9a-f]+|[a-z][a-z0-9]*);/i,
|
||||
]
|
||||
|
||||
/**
|
||||
* Strip code regions so the patterns above don't fire on code samples: fenced blocks (backtick
|
||||
* or tilde, length-matched on the closer so nested fences strip as one unit) and inline code.
|
||||
* Indented (4-space) code is deliberately NOT stripped — list/paragraph continuation lines are
|
||||
* also indented, and over-stripping would risk missing a real unsafe construct (a false negative,
|
||||
* which is worse than the rare false positive of an indented code block opening read-only).
|
||||
*/
|
||||
function stripCode(content: string): string {
|
||||
return content
|
||||
.replace(/^([`~]{3,})[^\n]*\n[\s\S]*?^\1[`~]*[ \t]*$/gm, '')
|
||||
.replace(/`+[^`\n]*`+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked images `[](href)`. The image node round-trips the common forms (clean URLs,
|
||||
* optional titles) via its `href` attribute, but an exotic one it can't tokenize falls back to the
|
||||
* stock parser, which drops the wrapping link — an invisible, stable loss. So instead of matching a
|
||||
* fixed pattern, {@link isRoundTripSafe} counts these before and after one serialization and rejects
|
||||
* if any disappeared.
|
||||
*/
|
||||
const LINKED_IMAGE_PATTERN = /\[\s*!\[[^\]]*]\([^)]*\)\s*]\([^)]*\)/g
|
||||
|
||||
function linkedImageCount(content: string): number {
|
||||
return content.match(LINKED_IMAGE_PATTERN)?.length ?? 0
|
||||
}
|
||||
|
||||
/** Serialize markdown through the exact editor pipeline (frontmatter held aside), chunked so the
|
||||
* probe stays linear in document size. */
|
||||
function serialize(content: string): string {
|
||||
const { frontmatter, body } = splitFrontmatter(content)
|
||||
return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(serializeMarkdownBody(body)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `content` survives the editor's markdown round-trip without data loss or autosave
|
||||
* churn. The editor opens the content read-only when this is false, so the probe is deliberately
|
||||
* conservative: it rejects on any doubt rather than risk an edit silently corrupting a file.
|
||||
*
|
||||
* Two complementary checks: known stable-loss constructs are matched directly (the idempotency
|
||||
* probe is blind to them), and everything else must reach a fixpoint — `serialize(x)` twice in a
|
||||
* row must be byte-identical, so the first edit can't churn the file. Lossless normalizations
|
||||
* (`_`→`*`, setext→ATX, autolink→inline, loose→tight lists) reach a fixpoint after one pass and
|
||||
* are allowed through; genuine churn (a blockquote wrapping a code fence keeps growing) is not.
|
||||
*/
|
||||
export function isRoundTripSafe(content: string): boolean {
|
||||
if (content.length > PROBE_SIZE_LIMIT) return false
|
||||
const stripped = stripCode(content)
|
||||
if (STABLE_LOSS_PATTERNS.some((pattern) => pattern.test(stripped))) return false
|
||||
try {
|
||||
const once = serialize(content)
|
||||
if (linkedImageCount(stripped) !== linkedImageCount(stripCode(once))) return false
|
||||
return serialize(once) === once
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Round-trip fidelity: markdown → editor → markdown must preserve meaning and, critically,
|
||||
* be idempotent (a second pass changes nothing) so autosave never churns. Mirrors the exact
|
||||
* pipeline the editor uses: split frontmatter out, serialize the body, re-attach + clean up.
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
import {
|
||||
applyFrontmatter,
|
||||
normalizeLinkHref,
|
||||
postProcessSerializedMarkdown,
|
||||
splitFrontmatter,
|
||||
} from './markdown-fidelity'
|
||||
|
||||
let editor: Editor | null = null
|
||||
|
||||
function roundTrip(input: string): string {
|
||||
const { frontmatter, body } = splitFrontmatter(input)
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent(body, { contentType: 'markdown' })
|
||||
const out = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown()))
|
||||
editor.destroy()
|
||||
editor = null
|
||||
return out
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
describe('markdown-fidelity utils', () => {
|
||||
it('splits a frontmatter block and its trailing whitespace from the body', () => {
|
||||
const fm = '---\ntitle: Hello\ntags: [a, b]\n---'
|
||||
const { frontmatter, body } = splitFrontmatter(`${fm}\n\n# Body`)
|
||||
expect(frontmatter).toBe(`${fm}\n\n`)
|
||||
expect(body).toBe('# Body')
|
||||
expect(applyFrontmatter(frontmatter, body)).toBe(`${fm}\n\n# Body`)
|
||||
})
|
||||
|
||||
it('preserves the exact frontmatter/body separator (no whitespace churn)', () => {
|
||||
for (const original of [
|
||||
'---\na: 1\n---\nbody',
|
||||
'---\na: 1\n---\n\nbody',
|
||||
'---\na: 1\n---\n\n\n\nbody',
|
||||
'---\na: 1\n---\r\n\r\nbody',
|
||||
]) {
|
||||
const { frontmatter, body } = splitFrontmatter(original)
|
||||
expect(frontmatter + body).toBe(original)
|
||||
}
|
||||
})
|
||||
|
||||
it('recognizes empty and minimal frontmatter blocks', () => {
|
||||
const empty = splitFrontmatter('---\n---\n\n# Title')
|
||||
expect(empty.frontmatter).toBe('---\n---\n\n')
|
||||
expect(empty.body).toBe('# Title')
|
||||
|
||||
const onlyFm = splitFrontmatter('---\ntitle: x\n---')
|
||||
expect(onlyFm.frontmatter).toBe('---\ntitle: x\n---')
|
||||
expect(onlyFm.body).toBe('')
|
||||
|
||||
const crlf = splitFrontmatter('---\r\n---\r\nbody')
|
||||
expect(crlf.frontmatter + crlf.body).toBe('---\r\n---\r\nbody')
|
||||
expect(crlf.body).toBe('body')
|
||||
})
|
||||
|
||||
it('treats content with no frontmatter as all body', () => {
|
||||
expect(splitFrontmatter('# Just a heading')).toEqual({
|
||||
frontmatter: '',
|
||||
body: '# Just a heading',
|
||||
})
|
||||
expect(applyFrontmatter('', '# Body')).toBe('# Body')
|
||||
})
|
||||
|
||||
it('does not treat a horizontal rule as frontmatter', () => {
|
||||
const md = 'above\n\n---\n\nbelow'
|
||||
expect(splitFrontmatter(md)).toEqual({ frontmatter: '', body: md })
|
||||
})
|
||||
|
||||
it('holds a UTF-8 BOM out of band so frontmatter survives', () => {
|
||||
const input = '\uFEFF---\ntitle: x\n---\n\nbody'
|
||||
const { frontmatter, body } = splitFrontmatter(input)
|
||||
expect(frontmatter.startsWith('\uFEFF')).toBe(true)
|
||||
expect(body).toBe('body')
|
||||
expect(applyFrontmatter(frontmatter, body)).toBe(input)
|
||||
})
|
||||
|
||||
it('restores escaped callout markers', () => {
|
||||
expect(postProcessSerializedMarkdown('> \\[!NOTE\\]\n> hi')).toBe('> [!NOTE]\n> hi')
|
||||
})
|
||||
|
||||
it('restores escaped callout markers in nested blockquotes', () => {
|
||||
expect(postProcessSerializedMarkdown('> > \\[!WARNING\\]\n> > hi')).toBe(
|
||||
'> > [!WARNING]\n> > hi'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes link hrefs', () => {
|
||||
expect(normalizeLinkHref('')).toBe('')
|
||||
expect(normalizeLinkHref('sim.ai')).toBe('https://sim.ai')
|
||||
expect(normalizeLinkHref('example.com/path')).toBe('https://example.com/path')
|
||||
expect(normalizeLinkHref('https://x.com')).toBe('https://x.com')
|
||||
expect(normalizeLinkHref('HTTP://x.com')).toBe('HTTP://x.com')
|
||||
expect(normalizeLinkHref('mailto:a@b.com')).toBe('mailto:a@b.com')
|
||||
expect(normalizeLinkHref('#anchor')).toBe('#anchor')
|
||||
expect(normalizeLinkHref('/relative')).toBe('/relative')
|
||||
// Relative paths stay relative (not prefixed into `https://./…`).
|
||||
expect(normalizeLinkHref('./other.md')).toBe('./other.md')
|
||||
expect(normalizeLinkHref('../doc.md')).toBe('../doc.md')
|
||||
expect(normalizeLinkHref(' https://x.com ')).toBe('https://x.com')
|
||||
expect(normalizeLinkHref('javascript:alert(1)')).toBe('')
|
||||
expect(normalizeLinkHref('data:text/html,<script>')).toBe('')
|
||||
expect(normalizeLinkHref('//cdn.example.com/a.js')).toBe('https://cdn.example.com/a.js')
|
||||
expect(normalizeLinkHref('ftp://host/file')).toBe('ftp://host/file')
|
||||
// Dangerous schemes rejected; a bare host:port is still treated as a domain.
|
||||
expect(normalizeLinkHref('file:///etc/passwd')).toBe('')
|
||||
expect(normalizeLinkHref('blob:https://x.com/uuid')).toBe('')
|
||||
expect(normalizeLinkHref('vbscript:msgbox(1)')).toBe('')
|
||||
expect(normalizeLinkHref('localhost:3000/path')).toBe('https://localhost:3000/path')
|
||||
})
|
||||
|
||||
it('collapses trailing blank lines and preserves leading whitespace', () => {
|
||||
expect(postProcessSerializedMarkdown('| a |\n| --- |\n\n\n')).toBe('| a |\n| --- |\n')
|
||||
// No global leading-newline strip (the table trims its own at the source), so content that
|
||||
// legitimately begins with a blank line is no longer clobbered on save.
|
||||
expect(postProcessSerializedMarkdown('\nbody\n')).toBe('\nbody\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor markdown round-trip', () => {
|
||||
const cases: Record<string, string> = {
|
||||
headings: '# H1\n\n## H2\n\n### H3',
|
||||
bold: 'a **bold** word',
|
||||
link: 'see [Sim](https://sim.ai)',
|
||||
'nested bullets': '- one\n- two\n - nested',
|
||||
ordered: '1. one\n2. two',
|
||||
'task list': '- [ ] todo\n- [x] done',
|
||||
quote: '> a quote',
|
||||
'code block': '```js\nconst x = 1\n```',
|
||||
'code block then paragraph': '```\ncode\n```\n\ntext after',
|
||||
'code block then image':
|
||||
'```markdown\n\n```\n\n',
|
||||
'paragraph then code block': 'text before\n\n```\ncode\n```',
|
||||
'two code blocks': '```\na\n```\n\n```\nb\n```',
|
||||
mermaid: '```mermaid\ngraph TD\n A --> 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('')
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
it('round-trips an image whose alt/title contain delimiter characters (idempotent)', () => {
|
||||
const input = '![a [b] c](https://example.com/i.png "ti\\"tle")'
|
||||
const out = roundTrip(input)
|
||||
expect(roundTrip(out)).toBe(out)
|
||||
expect(out).toContain('https://example.com/i.png')
|
||||
})
|
||||
|
||||
it('round-trips a linked image / badge (keeps the wrapping link)', () => {
|
||||
const out = roundTrip(
|
||||
'[](https://ci.example.com)'
|
||||
)
|
||||
expect(out).toContain(
|
||||
'[](https://ci.example.com)'
|
||||
)
|
||||
expect(roundTrip(out)).toBe(out)
|
||||
})
|
||||
|
||||
it('keeps a plain image plain (no spurious link wrapper)', () => {
|
||||
const out = roundTrip('')
|
||||
expect(out).not.toContain('](https://example.com/i.png)](')
|
||||
expect(out.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('round-trips a sized image as an HTML <img>, plain images as markdown', () => {
|
||||
const sized = roundTrip('<img src="https://e.com/i.png" alt="d" width="320">')
|
||||
expect(sized).toContain('<img src="https://e.com/i.png" alt="d" width="320">')
|
||||
expect(roundTrip(sized)).toBe(sized)
|
||||
expect(roundTrip('')).toContain('')
|
||||
})
|
||||
|
||||
it('preserves a sized base64 image and escapes quotes in attributes', () => {
|
||||
const dataUrl = '<img src="data:image/png;base64,iVBORw0KGgo=" width="200">'
|
||||
expect(roundTrip(dataUrl)).toContain('data:image/png;base64,iVBORw0KGgo=')
|
||||
expect(roundTrip(dataUrl)).toBe(roundTrip(roundTrip(dataUrl)))
|
||||
const quoted = roundTrip('<img src="/x.png" alt=\'a"b\' width="320">')
|
||||
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(/(?<!\\)\|/g) ?? []).length).toBe(3)
|
||||
}
|
||||
expect(roundTrip(out)).toBe(out)
|
||||
})
|
||||
|
||||
it('combines strikethrough with inline code (relaxed code mark)', () => {
|
||||
expect(roundTrip('~~`x`~~')).toContain('~~`x`~~')
|
||||
expect(roundTrip('# ~~`x`~~')).toContain('# ~~`x`~~')
|
||||
})
|
||||
|
||||
it('escapes interior pipes in table cells (no phantom column split)', () => {
|
||||
const out = roundTrip('| x \\| y | 2 |\n| --- | --- |\n| a | b |')
|
||||
expect(out).toContain('x \\| y')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Links come from arbitrary file content (a README the editor opens, agent-written markdown), not
|
||||
* just user-typed text — so the rendered anchor must never carry a dangerous scheme. This locks the
|
||||
* guarantee in our own test rather than trusting a transitive TipTap default to keep neutralizing
|
||||
* `javascript:`/`data:`/`vbscript:` across version bumps.
|
||||
*/
|
||||
describe('link href sanitization — dangerous schemes from file content are neutralized', () => {
|
||||
function renderedHrefs(markdown: string): Array<string | null> {
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent(markdown, { contentType: 'markdown' })
|
||||
const html = editor.getHTML()
|
||||
editor.destroy()
|
||||
editor = null
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
return Array.from(doc.querySelectorAll('a')).map((a) => a.getAttribute('href'))
|
||||
}
|
||||
|
||||
it.each([
|
||||
'javascript:alert(document.cookie)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
])('does not render %s as a clickable href', (scheme) => {
|
||||
for (const href of renderedHrefs(`[click me](${scheme})`)) {
|
||||
expect((href ?? '').replace(/\s/g, '')).not.toMatch(/^(javascript|data|vbscript):/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves safe http/https/mailto links', () => {
|
||||
const hrefs = renderedHrefs('[a](https://ok.example.com)\n\n[b](mailto:x@y.com)')
|
||||
expect(hrefs).toContain('https://ok.example.com')
|
||||
expect(hrefs).toContain('mailto:x@y.com')
|
||||
})
|
||||
})
|
||||
+51
@@ -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)
|
||||
})
|
||||
})
|
||||
+147
@@ -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))
|
||||
)
|
||||
}
|
||||
+129
@@ -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<SlashCommandListHandle, SlashCommandListProps>(
|
||||
function SlashCommandList({ items, command }, ref) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
containerRef.current
|
||||
?.querySelector<HTMLElement>(`[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 (
|
||||
<div className={SURFACE_CLASS}>
|
||||
<p className='px-2 py-1.5 text-[var(--text-tertiary)] text-caption'>No results</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role='listbox'
|
||||
aria-label='Commands'
|
||||
className={cn(
|
||||
SURFACE_CLASS,
|
||||
'max-h-[330px] scroll-py-1.5 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<div key={group.group} role='group' aria-label={group.group}>
|
||||
<p
|
||||
aria-hidden='true'
|
||||
className='px-2 pt-1.5 pb-1 font-medium text-[var(--text-muted)] text-micro uppercase tracking-wide'
|
||||
>
|
||||
{group.group}
|
||||
</p>
|
||||
{group.items.map(({ item, index }) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.title}
|
||||
type='button'
|
||||
role='option'
|
||||
id={`slash-command-${index}`}
|
||||
aria-selected={index === activeIndex}
|
||||
data-index={index}
|
||||
className={cn(ITEM_CLASS, index === activeIndex && 'bg-[var(--surface-active)]')}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
command(item)
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<span className='ml-auto shrink-0 pl-4 text-[var(--text-subtle)] text-micro'>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
+111
@@ -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<SlashCommandItem, SlashCommandItem>
|
||||
|
||||
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<NonNullable<SuggestionOptions['render']>> {
|
||||
let component: ReactRenderer<SlashCommandListHandle> | 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<SlashCommandItem, SlashCommandItem>({
|
||||
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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+64
-66
@@ -4,7 +4,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INITIAL_TEXT_EDITOR_CONTENT_STATE,
|
||||
resolveStreamingEditorContent,
|
||||
syncTextEditorContentState,
|
||||
type TextEditorContentState,
|
||||
textEditorContentReducer,
|
||||
@@ -26,36 +25,6 @@ function reconciling(content: string, savedContent = ''): TextEditorContentState
|
||||
return { phase: 'reconciling', content, savedContent, lastStreamedContent: null }
|
||||
}
|
||||
|
||||
describe('resolveStreamingEditorContent', () => {
|
||||
it('returns streamingContent when mode is replace', () => {
|
||||
expect(resolveStreamingEditorContent('existing', 'new chunk', 'replace')).toBe('new chunk')
|
||||
})
|
||||
|
||||
it('returns streamingContent when fetchedContent is undefined', () => {
|
||||
expect(resolveStreamingEditorContent(undefined, 'chunk', 'append')).toBe('chunk')
|
||||
})
|
||||
|
||||
it('returns fetchedContent when it already ends with streamingContent', () => {
|
||||
expect(resolveStreamingEditorContent('base\nchunk', 'chunk', 'append')).toBe('base\nchunk')
|
||||
})
|
||||
|
||||
it('returns fetchedContent when it ends with newline + streamingContent', () => {
|
||||
expect(resolveStreamingEditorContent('base\nchunk', 'chunk', 'append')).toBe('base\nchunk')
|
||||
})
|
||||
|
||||
it('appends with newline separator when fetched does not end with chunk', () => {
|
||||
expect(resolveStreamingEditorContent('base', 'new stuff', 'append')).toBe('base\nnew stuff')
|
||||
})
|
||||
|
||||
it('handles empty streamingContent in append mode', () => {
|
||||
expect(resolveStreamingEditorContent('base', '', 'append')).toBe('base')
|
||||
})
|
||||
|
||||
it('handles empty fetchedContent in append mode (prepends newline separator)', () => {
|
||||
expect(resolveStreamingEditorContent('', 'chunk', 'append')).toBe('\nchunk')
|
||||
})
|
||||
})
|
||||
|
||||
describe("reducer 'edit' action", () => {
|
||||
it('updates content when phase is ready and content differs', () => {
|
||||
const state = ready('old')
|
||||
@@ -114,6 +83,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', () => {
|
||||
@@ -122,7 +99,6 @@ describe('syncTextEditorContentState — initialization', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: 'loaded',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('loaded')
|
||||
@@ -134,7 +110,6 @@ describe('syncTextEditorContentState — initialization', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next).toBe(INITIAL_TEXT_EDITOR_CONTENT_STATE)
|
||||
})
|
||||
@@ -147,7 +122,6 @@ describe('syncTextEditorContentState — static fetch updates', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: 'v1',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
@@ -158,7 +132,6 @@ describe('syncTextEditorContentState — static fetch updates', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: 'v2',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.content).toBe('v2')
|
||||
expect(next.savedContent).toBe('v2')
|
||||
@@ -176,7 +149,6 @@ describe('syncTextEditorContentState — static fetch updates', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: 'v2',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
// Local edits take precedence — content should remain 'user edit'
|
||||
expect(next.content).toBe('user edit')
|
||||
@@ -191,31 +163,18 @@ describe('syncTextEditorContentState — streaming', () => {
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: 'existing',
|
||||
streamingContent: 'streamed chunk',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(next.phase).toBe('streaming')
|
||||
expect(next.content).toBe('streamed chunk')
|
||||
expect(next.lastStreamedContent).toBe('streamed chunk')
|
||||
})
|
||||
|
||||
it('appends streaming content to fetched in append mode', () => {
|
||||
const next = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: 'base',
|
||||
streamingContent: 'addition',
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('streaming')
|
||||
expect(next.content).toBe('base\naddition')
|
||||
})
|
||||
|
||||
it('returns same reference when streaming state is already current', () => {
|
||||
const state = streaming('base\naddition', 'base\naddition', 'base')
|
||||
const state = streaming('addition', 'addition', 'base')
|
||||
const next = syncTextEditorContentState(state, {
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: 'base',
|
||||
streamingContent: 'addition',
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
@@ -226,7 +185,6 @@ describe('syncTextEditorContentState — streaming', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: 'base\nchunk',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('base\nchunk')
|
||||
@@ -240,7 +198,6 @@ describe('syncTextEditorContentState — streaming', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('reconciling')
|
||||
})
|
||||
@@ -251,7 +208,6 @@ describe('syncTextEditorContentState — streaming', () => {
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('streamed')
|
||||
@@ -265,7 +221,6 @@ describe('syncTextEditorContentState — reconciling', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: '',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('reconciling')
|
||||
})
|
||||
@@ -276,7 +231,6 @@ describe('syncTextEditorContentState — reconciling', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
@@ -292,7 +246,6 @@ describe('syncTextEditorContentState — reconciling', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: 'v2',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('v2')
|
||||
@@ -304,7 +257,6 @@ describe('syncTextEditorContentState — reconciling', () => {
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: 'v99',
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('streamed')
|
||||
@@ -319,7 +271,6 @@ describe('syncTextEditorContentState — streaming finalize shortcuts', () => {
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: 'v2',
|
||||
streamingContent: 'v2',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('v2')
|
||||
@@ -331,7 +282,6 @@ describe('syncTextEditorContentState — streaming finalize shortcuts', () => {
|
||||
canReconcileToFetchedContent: true,
|
||||
fetchedContent: 'v2',
|
||||
streamingContent: 'chunk',
|
||||
streamingMode: 'append',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('v2')
|
||||
@@ -349,7 +299,6 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: 'short',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(next.phase).toBe('streaming')
|
||||
expect(next.content).toBe('short')
|
||||
@@ -366,7 +315,6 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: '#',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(next.phase).toBe('streaming')
|
||||
expect(next.content).toBe('#')
|
||||
@@ -378,7 +326,6 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: '',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(next.phase).toBe('streaming')
|
||||
expect(next.content).toBe('')
|
||||
@@ -391,7 +338,6 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: '# New',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(chunk1.phase).toBe('streaming')
|
||||
expect(chunk1.content).toBe('# New')
|
||||
@@ -400,7 +346,6 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: '# New Section\n\nSome text',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(chunk2.phase).toBe('streaming')
|
||||
expect(chunk2.content).toBe('# New Section\n\nSome text')
|
||||
@@ -409,7 +354,6 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: '# New Section\n\nSome text that is now longer than the original',
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(chunk3.phase).toBe('streaming')
|
||||
expect(chunk3.content).toBe('# New Section\n\nSome text that is now longer than the original')
|
||||
@@ -425,7 +369,6 @@ describe('syncTextEditorContentState — inter-session content shrink (replace m
|
||||
canReconcileToFetchedContent: false,
|
||||
fetchedContent: undefined,
|
||||
streamingContent: undefined,
|
||||
streamingMode: 'replace',
|
||||
})
|
||||
expect(next.phase).toBe('ready')
|
||||
expect(next.content).toBe('# Complete Document\n\nAll done.')
|
||||
@@ -433,3 +376,58 @@ 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,
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
+6
-30
@@ -1,5 +1,3 @@
|
||||
export type StreamingMode = 'append' | 'replace'
|
||||
|
||||
export type TextEditorContentPhase = 'uninitialized' | 'ready' | 'streaming' | 'reconciling'
|
||||
|
||||
export interface TextEditorContentState {
|
||||
@@ -13,7 +11,6 @@ export interface SyncTextEditorContentStateOptions {
|
||||
canReconcileToFetchedContent: boolean
|
||||
fetchedContent?: string
|
||||
streamingContent?: string
|
||||
streamingMode: StreamingMode
|
||||
}
|
||||
|
||||
export type TextEditorContentAction =
|
||||
@@ -28,25 +25,6 @@ export const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = {
|
||||
lastStreamedContent: null,
|
||||
}
|
||||
|
||||
export function resolveStreamingEditorContent(
|
||||
fetchedContent: string | undefined,
|
||||
streamingContent: string,
|
||||
streamingMode: StreamingMode
|
||||
): string {
|
||||
if (streamingMode === 'replace' || fetchedContent === undefined) {
|
||||
return streamingContent
|
||||
}
|
||||
|
||||
if (
|
||||
fetchedContent.endsWith(streamingContent) ||
|
||||
fetchedContent.endsWith(`\n${streamingContent}`)
|
||||
) {
|
||||
return fetchedContent
|
||||
}
|
||||
|
||||
return `${fetchedContent}\n${streamingContent}`
|
||||
}
|
||||
|
||||
function finalizeTextEditorContentState(
|
||||
state: TextEditorContentState,
|
||||
nextContent: string
|
||||
@@ -105,14 +83,10 @@ export function syncTextEditorContentState(
|
||||
state: TextEditorContentState,
|
||||
options: SyncTextEditorContentStateOptions
|
||||
): TextEditorContentState {
|
||||
const { canReconcileToFetchedContent, fetchedContent, streamingContent, streamingMode } = options
|
||||
const { canReconcileToFetchedContent, fetchedContent, streamingContent } = options
|
||||
|
||||
if (streamingContent !== undefined) {
|
||||
const nextContent = resolveStreamingEditorContent(
|
||||
fetchedContent,
|
||||
streamingContent,
|
||||
streamingMode
|
||||
)
|
||||
const nextContent = streamingContent
|
||||
const fetchedMatchesNextContent = fetchedContent !== undefined && fetchedContent === nextContent
|
||||
const fetchedMatchesLastStreamedContent =
|
||||
fetchedContent !== undefined &&
|
||||
@@ -192,9 +166,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 +180,6 @@ export function textEditorContentReducer(
|
||||
return {
|
||||
...state,
|
||||
phase: 'ready',
|
||||
content: action.content,
|
||||
savedContent: action.content,
|
||||
lastStreamedContent: null,
|
||||
}
|
||||
|
||||
+20
-150
@@ -1,27 +1,17 @@
|
||||
'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 { useEditableFileContent } from './use-editable-file-content'
|
||||
|
||||
const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [
|
||||
{ token: 'comment', foreground: '606060', fontStyle: 'italic' },
|
||||
@@ -316,40 +306,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<SyncTextEditorContentStateOptions | null>(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')
|
||||
@@ -365,14 +321,6 @@ function useMonacoTheme(): string {
|
||||
return isDark ? 'sim-dark' : 'sim-light'
|
||||
}
|
||||
|
||||
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
|
||||
let currentIndex = 0
|
||||
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
|
||||
if (currentIndex++ !== targetIndex) return match
|
||||
return `${prefix}[${checked ? 'x' : ' '}]`
|
||||
})
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
@@ -383,7 +331,6 @@ interface TextEditorProps {
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
streamingContent?: string
|
||||
streamingMode?: StreamingMode
|
||||
disableStreamingAutoScroll: boolean
|
||||
previewContextKey?: string
|
||||
}
|
||||
@@ -398,7 +345,6 @@ export const TextEditor = memo(function TextEditor({
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
streamingContent,
|
||||
streamingMode = 'append',
|
||||
disableStreamingAutoScroll,
|
||||
previewContextKey,
|
||||
}: TextEditorProps) {
|
||||
@@ -418,45 +364,24 @@ 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 +458,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
|
||||
|
||||
@@ -595,21 +484,6 @@ export const TextEditor = memo(function TextEditor({
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
const handleCheckboxToggle = useCallback(
|
||||
(checkboxIndex: number, checked: boolean) => {
|
||||
const toggled = toggleMarkdownCheckbox(content, checkboxIndex, checked)
|
||||
if (toggled !== content) {
|
||||
setDraftContent(toggled)
|
||||
const model = monacoEditorRef.current?.getModel()
|
||||
if (model) {
|
||||
model.setValue(toggled)
|
||||
lastSyncedContentRef.current = toggled
|
||||
}
|
||||
}
|
||||
},
|
||||
[content, setDraftContent]
|
||||
)
|
||||
|
||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||
monacoEditorRef.current = editor
|
||||
|
||||
@@ -657,16 +531,14 @@ export const TextEditor = memo(function TextEditor({
|
||||
const showEditor = effectiveMode !== 'preview'
|
||||
const showPreviewPane = effectiveMode !== 'editor'
|
||||
|
||||
if (streamingContent === undefined) {
|
||||
if (isLoading) return <PreviewLoadingFrame className='flex flex-1 flex-col' />
|
||||
if (isContentLoading) return <PreviewLoadingFrame className='flex flex-1 flex-col' />
|
||||
|
||||
if (error && !isInitialized) {
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>Failed to load file content</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (hasContentError) {
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>Failed to load file content</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const closeContextMenu = () => setContextMenu(null)
|
||||
@@ -758,8 +630,6 @@ export const TextEditor = memo(function TextEditor({
|
||||
workspaceId={workspaceId}
|
||||
fileKey={file.key}
|
||||
isStreaming={isStreaming}
|
||||
disableAutoScroll={disableStreamingAutoScroll}
|
||||
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
'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 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
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: SaveStatus) => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | 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<void>
|
||||
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<SyncTextEditorContentStateOptions | null>(null)
|
||||
const prev = prevOptionsRef.current
|
||||
if (
|
||||
prev === null ||
|
||||
prev.canReconcileToFetchedContent !== options.canReconcileToFetchedContent ||
|
||||
prev.fetchedContent !== options.fetchedContent ||
|
||||
prev.streamingContent !== options.streamingContent
|
||||
) {
|
||||
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,
|
||||
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,
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Resource>
|
||||
<Resource.Header icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
|
||||
<div className='flex flex-1 items-center justify-center bg-[var(--surface-1)]'>
|
||||
<div className='flex flex-1 items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader className='size-[20px] text-[var(--text-secondary)]' animate />
|
||||
</div>
|
||||
</Resource>
|
||||
|
||||
+4
-6
@@ -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
|
||||
@@ -139,7 +143,6 @@ export const ResourceContent = memo(function ResourceContent({
|
||||
canEdit={false}
|
||||
previewMode={previewMode ?? 'preview'}
|
||||
streamingContent={textStreamingContent}
|
||||
streamingMode='replace'
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
/>
|
||||
@@ -162,7 +165,6 @@ export const ResourceContent = memo(function ResourceContent({
|
||||
streamingContent={
|
||||
previewSession?.fileId === resource.id ? textStreamingContent : undefined
|
||||
}
|
||||
streamingMode='replace'
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
/>
|
||||
@@ -262,7 +264,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)
|
||||
@@ -549,7 +550,6 @@ interface EmbeddedFileProps {
|
||||
filePath?: string
|
||||
previewMode?: PreviewMode
|
||||
streamingContent?: string
|
||||
streamingMode?: 'append' | 'replace'
|
||||
disableStreamingAutoScroll?: boolean
|
||||
previewContextKey?: string
|
||||
}
|
||||
@@ -560,7 +560,6 @@ function EmbeddedFile({
|
||||
filePath,
|
||||
previewMode,
|
||||
streamingContent,
|
||||
streamingMode,
|
||||
disableStreamingAutoScroll = false,
|
||||
previewContextKey,
|
||||
}: EmbeddedFileProps) {
|
||||
@@ -600,7 +599,6 @@ function EmbeddedFile({
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
streamingMode={streamingMode}
|
||||
previewMode={previewMode}
|
||||
streamingContent={streamingContent}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
|
||||
+5
-1
@@ -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))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -27,6 +28,7 @@ export default async function WorkspaceLayout({
|
||||
}
|
||||
|
||||
const { workspaceId } = await params
|
||||
const initialSidebarCollapsed = (await cookies()).get('sidebar_collapsed')?.value === '1'
|
||||
const queryClient = getQueryClient()
|
||||
const sidebarPrefetch = prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id)
|
||||
|
||||
@@ -47,7 +49,9 @@ export default async function WorkspaceLayout({
|
||||
<WorkspacePermissionsProvider>
|
||||
<WorkspaceScopeSync />
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<WorkspaceChrome>{children}</WorkspaceChrome>
|
||||
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
|
||||
{children}
|
||||
</WorkspaceChrome>
|
||||
</HydrationBoundary>
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import { useProvidersStore } from '@/stores/providers'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { readCollapsedCookie, useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const logger = createLogger('Sidebar')
|
||||
|
||||
@@ -347,7 +347,18 @@ const HIDDEN_STYLE = { display: 'none' } as const
|
||||
*
|
||||
* @returns Sidebar with workflows panel
|
||||
*/
|
||||
export const Sidebar = memo(function Sidebar() {
|
||||
interface SidebarProps {
|
||||
/**
|
||||
* Collapse state read from the `sidebar_collapsed` cookie in the server
|
||||
* layout. Seeds the first (pre-hydration) render so the server emits the
|
||||
* correct collapsed/expanded structure — without it the server can't read
|
||||
* `localStorage` and always renders the expanded tree, which then paints
|
||||
* skeletons and pinned-chat icons inside the 51px rail until React flips it.
|
||||
*/
|
||||
initialCollapsed?: boolean
|
||||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar({ initialCollapsed = false }: SidebarProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string | undefined
|
||||
@@ -378,10 +389,37 @@ export const Sidebar = memo(function Sidebar() {
|
||||
}, [initializeSearchData, filterBlocks, providerModelSignature])
|
||||
|
||||
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
|
||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const storeIsCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const hasHydrated = useSidebarStore((state) => state._hasHydrated)
|
||||
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
|
||||
const isOnWorkflowPage = !!workflowId
|
||||
|
||||
/**
|
||||
* The server renders from the `sidebar_collapsed` cookie (via `initialCollapsed`)
|
||||
* and the client store seeds from the same cookie, so both agree on the first
|
||||
* paint. The prop is read until the store reports hydration, after which the
|
||||
* store takes over.
|
||||
*
|
||||
* A legacy user whose collapse lived only in `localStorage` has no cookie at SSR
|
||||
* (so `initialCollapsed` is false), but the pre-paint script migrates them to a
|
||||
* cookie. Reconcile to that cookie synchronously before paint — the first render
|
||||
* still matches the server, so there's no hydration mismatch and no narrow-rail flash.
|
||||
*/
|
||||
const [migratedCollapsed, setMigratedCollapsed] = useState<boolean | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const cookieCollapsed = readCollapsedCookie()
|
||||
if (cookieCollapsed !== initialCollapsed) setMigratedCollapsed(cookieCollapsed)
|
||||
}, [initialCollapsed])
|
||||
const isCollapsed = hasHydrated ? storeIsCollapsed : (migratedCollapsed ?? initialCollapsed)
|
||||
|
||||
/**
|
||||
* Hydrates the persisted width before paint (collapse already came from the
|
||||
* cookie) so any width-dependent layout settles in the same commit.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
void useSidebarStore.persist.rehydrate()
|
||||
}, [])
|
||||
|
||||
const isCollapsedRef = useRef(isCollapsed)
|
||||
useLayoutEffect(() => {
|
||||
isCollapsedRef.current = isCollapsed
|
||||
@@ -1273,7 +1311,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
type='button'
|
||||
onClick={toggleCollapsed}
|
||||
className={cn(
|
||||
'sidebar-collapse-btn ml-2 flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-active)]',
|
||||
'ml-2 flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-active)]',
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
|
||||
)}
|
||||
aria-label='Collapse sidebar'
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { type SaveStatus, useAutosave } from '@/hooks/use-autosave'
|
||||
|
||||
interface ProbeProps {
|
||||
content: string
|
||||
savedContent: string
|
||||
onSave: () => Promise<void>
|
||||
delay?: number
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface HookHandle {
|
||||
status: () => SaveStatus
|
||||
isDirty: () => boolean
|
||||
saveImmediately: () => Promise<void>
|
||||
rerender: (next: Partial<ProbeProps>) => void
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal dependency-free hook harness (the repo has no `@testing-library/react`). Mounts the hook
|
||||
* in a real React 19 root under jsdom so effects, refs, and timers run exactly as in the app.
|
||||
*/
|
||||
function renderAutosave(initial: ProbeProps): { handle: HookHandle; props: ProbeProps } {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
const props = { ...initial }
|
||||
let latest = { saveStatus: 'idle' as SaveStatus, isDirty: false, saveImmediately: async () => {} }
|
||||
|
||||
function Probe(p: ProbeProps) {
|
||||
latest = useAutosave(p)
|
||||
return null
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
act(() => {
|
||||
root.render(<Probe {...props} />)
|
||||
})
|
||||
}
|
||||
render()
|
||||
|
||||
const handle: HookHandle = {
|
||||
status: () => latest.saveStatus,
|
||||
isDirty: () => latest.isDirty,
|
||||
saveImmediately: () => latest.saveImmediately(),
|
||||
rerender: (next) => {
|
||||
Object.assign(props, next)
|
||||
render()
|
||||
},
|
||||
unmount: () => act(() => root.unmount()),
|
||||
}
|
||||
return { handle, props }
|
||||
}
|
||||
|
||||
/** Flush pending microtasks (awaited promises) inside an act() boundary. */
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('useAutosave', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('debounces edits into a single save after the delay', async () => {
|
||||
const onSave = vi.fn(async () => {})
|
||||
const { handle, props } = renderAutosave({
|
||||
content: 'a',
|
||||
savedContent: 'a',
|
||||
onSave,
|
||||
delay: 1500,
|
||||
})
|
||||
expect(handle.isDirty()).toBe(false)
|
||||
|
||||
// Three rapid edits within the debounce window.
|
||||
handle.rerender({ content: 'ab' })
|
||||
act(() => void vi.advanceTimersByTime(500))
|
||||
handle.rerender({ content: 'abc' })
|
||||
act(() => void vi.advanceTimersByTime(500))
|
||||
handle.rerender({ content: 'abcd' })
|
||||
expect(handle.isDirty()).toBe(true)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
|
||||
// Only after a full quiet delay does exactly one save fire.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
await flush()
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(props.content).toBe('abcd')
|
||||
})
|
||||
|
||||
it('holds the "saving" status for the minimum display window, then saved → idle', async () => {
|
||||
let resolveSave: (() => void) | undefined
|
||||
const onSave = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave })
|
||||
|
||||
handle.rerender({ content: 'a1' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
await flush()
|
||||
expect(handle.status()).toBe('saving')
|
||||
|
||||
// Resolve the network save quickly — status must still read "saving" until the min window.
|
||||
await act(async () => {
|
||||
resolveSave?.()
|
||||
})
|
||||
await flush()
|
||||
expect(handle.status()).toBe('saving')
|
||||
|
||||
// Simulate the consumer advancing the saved baseline, then cross the min-display floor.
|
||||
handle.rerender({ savedContent: 'a1' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
await flush()
|
||||
expect(handle.status()).toBe('saved')
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
expect(handle.status()).toBe('idle')
|
||||
})
|
||||
|
||||
it('persists the latest content when an edit lands during an in-flight save (no data loss)', async () => {
|
||||
const saved: string[] = []
|
||||
let resolveSave: (() => void) | undefined
|
||||
const { handle, props } = renderAutosave({
|
||||
content: 'a',
|
||||
savedContent: 'a',
|
||||
onSave: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
saved.push(props.content)
|
||||
resolveSave = resolve
|
||||
}),
|
||||
})
|
||||
|
||||
handle.rerender({ content: 'a1' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
await flush()
|
||||
expect(saved).toEqual(['a1'])
|
||||
|
||||
// While the PUT is in flight, the user types more.
|
||||
handle.rerender({ content: 'a12' })
|
||||
|
||||
// The in-flight save resolves; consumer advances baseline to what was written.
|
||||
handle.rerender({ savedContent: 'a1' })
|
||||
await act(async () => {
|
||||
resolveSave?.()
|
||||
})
|
||||
await flush()
|
||||
// The trailing-resave chain must pick up the newer content after the display window.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
await flush()
|
||||
expect(saved).toEqual(['a1', 'a12'])
|
||||
})
|
||||
|
||||
it('stops after an error and does not auto-retry', async () => {
|
||||
const onSave = vi.fn(async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave })
|
||||
|
||||
handle.rerender({ content: 'a1' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
await flush()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
await flush()
|
||||
expect(handle.status()).toBe('error')
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Content is still dirty but the chain must NOT re-fire on its own.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000)
|
||||
})
|
||||
await flush()
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('saveImmediately cancels the debounce and saves now, but no-ops when clean', async () => {
|
||||
const onSave = vi.fn(async () => {})
|
||||
const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave })
|
||||
|
||||
// No-op when nothing is dirty.
|
||||
await act(async () => {
|
||||
await handle.saveImmediately()
|
||||
})
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
|
||||
// Dirty + Cmd+S saves immediately, before the debounce delay elapses.
|
||||
handle.rerender({ content: 'a1' })
|
||||
await act(async () => {
|
||||
await handle.saveImmediately()
|
||||
})
|
||||
await flush()
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not save while disabled (the streaming lock)', async () => {
|
||||
const onSave = vi.fn(async () => {})
|
||||
const { handle } = renderAutosave({
|
||||
content: 'a',
|
||||
savedContent: 'a',
|
||||
onSave,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
handle.rerender({ content: 'a1' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
await flush()
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await handle.saveImmediately()
|
||||
})
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes the latest content on unmount when still dirty', async () => {
|
||||
const onSave = vi.fn(async () => {})
|
||||
const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave })
|
||||
|
||||
handle.rerender({ content: 'a1' })
|
||||
// Unmount before the debounce fires — the cleanup flush must still persist the edit.
|
||||
handle.unmount()
|
||||
await flush()
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not flush on unmount when the document is clean', async () => {
|
||||
const onSave = vi.fn(async () => {})
|
||||
const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave })
|
||||
handle.unmount()
|
||||
await flush()
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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<SaveStatus>('idle')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const displayTimerRef = useRef<ReturnType<typeof setTimeout>>(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<Promise<void> | 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(() => {})
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readCollapsedCookie } from './store'
|
||||
|
||||
function setCookie(value: string) {
|
||||
document.cookie = `sidebar_collapsed=${value}; path=/`
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.cookie = 'sidebar_collapsed=; path=/; max-age=0'
|
||||
})
|
||||
|
||||
describe('readCollapsedCookie', () => {
|
||||
it('is true only for an exact value of 1', () => {
|
||||
setCookie('1')
|
||||
expect(readCollapsedCookie()).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for 0', () => {
|
||||
setCookie('0')
|
||||
expect(readCollapsedCookie()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat a substring value like 10 as collapsed', () => {
|
||||
setCookie('10')
|
||||
expect(readCollapsedCookie()).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when the cookie is absent', () => {
|
||||
expect(readCollapsedCookie()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -24,12 +24,30 @@ function applySidebarWidth(width: number) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${value}px`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `sidebar_collapsed` cookie is the single source of truth for collapse: the
|
||||
* server layout reads it to render the correct structure on the first paint
|
||||
* (it can't read `localStorage`), and the client seeds its initial state from it
|
||||
* below. Width is the only field persisted to `localStorage`.
|
||||
*/
|
||||
function applyCollapsedCookie(collapsed: boolean) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
|
||||
/** Reads the collapse state the server saw, so the client store seeds identically. Matches the
|
||||
* cookie value strictly (`=1`) so `sidebar_collapsed=10` and the like aren't read as collapsed. */
|
||||
export function readCollapsedCookie(): boolean {
|
||||
if (typeof document === 'undefined') return false
|
||||
return document.cookie.match(/(?:^|;\s*)sidebar_collapsed=([^;]*)/)?.[1] === '1'
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
workspaceDropdownOpen: false,
|
||||
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
|
||||
isCollapsed: false,
|
||||
isCollapsed: readCollapsedCookie(),
|
||||
_hasHydrated: false,
|
||||
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
|
||||
setSidebarWidth: (width) => {
|
||||
@@ -42,6 +60,7 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
const { isCollapsed, sidebarWidth } = get()
|
||||
const nextCollapsed = !isCollapsed
|
||||
set({ isCollapsed: nextCollapsed })
|
||||
applyCollapsedCookie(nextCollapsed)
|
||||
applySidebarWidth(nextCollapsed ? SIDEBAR_WIDTH.COLLAPSED : clampSidebarWidth(sidebarWidth))
|
||||
},
|
||||
syncWidth: () => {
|
||||
@@ -58,6 +77,14 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
}),
|
||||
{
|
||||
name: 'sidebar-state',
|
||||
/**
|
||||
* Width is hydrated manually from a client-only effect (see Sidebar) so
|
||||
* `_hasHydrated` is deterministically `false` during SSR and the first
|
||||
* client render — both of which read collapse from the cookie-seeded prop.
|
||||
* This is zustand's documented SSR pattern; it avoids relying on auto
|
||||
* hydration's behavior when `localStorage` is absent on the server.
|
||||
*/
|
||||
skipHydration: true,
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) {
|
||||
state.setHasHydrated(true)
|
||||
@@ -65,14 +92,18 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
? SIDEBAR_WIDTH.COLLAPSED
|
||||
: clampSidebarWidth(state.sidebarWidth)
|
||||
applySidebarWidth(width)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.removeAttribute('data-sidebar-collapsed')
|
||||
}
|
||||
}
|
||||
},
|
||||
partialize: (state) => ({
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
isCollapsed: state.isCollapsed,
|
||||
/** Only width is persisted; collapse lives in the cookie. */
|
||||
partialize: (state) => ({ sidebarWidth: state.sidebarWidth }),
|
||||
/**
|
||||
* Never lets a legacy persisted `isCollapsed` override the cookie-seeded
|
||||
* value — the cookie is the source of truth (handles migration cleanly).
|
||||
*/
|
||||
merge: (persisted, current) => ({
|
||||
...current,
|
||||
...(persisted as Partial<SidebarState>),
|
||||
isCollapsed: current.isCollapsed,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
Reference in New Issue
Block a user