diff --git a/apps/web/src/features/board/boardOwnership.ts b/apps/web/src/features/board/boardOwnership.ts new file mode 100644 index 0000000..197db98 --- /dev/null +++ b/apps/web/src/features/board/boardOwnership.ts @@ -0,0 +1,58 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +// Ensure a `boards` row exists for this id and claim ownership when it is +// unowned and we are signed in. Idempotent. Returns whether the current user +// owns the board, which gates the named-snapshot Save/Restore UI (owner-only per +// the RLS policy in 0001_init.sql). +// +// Safe to call with a null client (local-only mode) or signed out — both yield +// { owned: false } without throwing, so PDF export and undo still work. +export async function ensureBoardOwnership( + client: SupabaseClient | null, + boardId: string, +): Promise<{ owned: boolean }> { + if (!client) return { owned: false }; + + const { data: auth } = await client.auth.getUser(); + const userId = auth.user?.id ?? null; + + // SELECT is allowed for any public board. + const { data: existing } = await client + .from("boards") + .select("id, owner_id") + .eq("id", boardId) + .maybeSingle(); + + if (!userId) return { owned: false }; + + if (!existing) { + // boards_insert_any allows check(true); claim ownership on first load. + const { error } = await client + .from("boards") + .insert({ id: boardId, owner_id: userId }); + if (error) { + // A racing peer may have inserted first → re-read to settle ownership. + const { data: row } = await client + .from("boards") + .select("owner_id") + .eq("id", boardId) + .maybeSingle(); + return { owned: row?.owner_id === userId }; + } + return { owned: true }; + } + + if (existing.owner_id === userId) return { owned: true }; + + if (existing.owner_id === null) { + // boards_update_owner allows update while owner_id is null. + const { error } = await client + .from("boards") + .update({ owner_id: userId }) + .eq("id", boardId) + .is("owner_id", null); + return { owned: !error }; + } + + return { owned: false }; // owned by someone else +} diff --git a/apps/web/src/features/board/snapshotsApi.ts b/apps/web/src/features/board/snapshotsApi.ts new file mode 100644 index 0000000..fbf9244 --- /dev/null +++ b/apps/web/src/features/board/snapshotsApi.ts @@ -0,0 +1,71 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +export interface NamedSnapshot { + id: string; + label: string; + createdAt: string; +} + +// PostgREST reads/writes a `bytea` column as a hex string in the "\x.." format. +// Encoding the Yjs update as hex avoids needing a server-side base64 decode RPC +// and works against the stock schema. +function bytesToHexBytea(bytes: Uint8Array): string { + let hex = "\\x"; + for (const b of bytes) hex += b.toString(16).padStart(2, "0"); + return hex; +} + +function hexByteaToBytes(s: string): Uint8Array { + const hex = s.startsWith("\\x") ? s.slice(2) : s; + const out = new Uint8Array(hex.length >> 1); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} + +export async function saveNamedSnapshot( + client: SupabaseClient, + boardId: string, + label: string, + bytes: Uint8Array, +): Promise { + const { error } = await client.from("snapshots").insert({ + board_id: boardId, + kind: "named", + label, + ydoc: bytesToHexBytea(bytes), + }); + if (error) throw error; +} + +export async function listNamedSnapshots( + client: SupabaseClient, + boardId: string, +): Promise { + const { data, error } = await client + .from("snapshots") + .select("id, label, created_at") + .eq("board_id", boardId) + .eq("kind", "named") + .order("created_at", { ascending: false }); + if (error) throw error; + return (data ?? []).map((r) => ({ + id: r.id as string, + label: (r.label as string | null) ?? "Untitled", + createdAt: r.created_at as string, + })); +} + +export async function fetchSnapshotBytes( + client: SupabaseClient, + id: string, +): Promise { + const { data, error } = await client + .from("snapshots") + .select("ydoc") + .eq("id", id) + .single(); + if (error || !data) throw error ?? new Error("snapshot not found"); + return hexByteaToBytes(data.ydoc as string); +} diff --git a/apps/web/src/features/canvas/AppMenu.tsx b/apps/web/src/features/canvas/AppMenu.tsx index 4721600..068d58e 100644 --- a/apps/web/src/features/canvas/AppMenu.tsx +++ b/apps/web/src/features/canvas/AppMenu.tsx @@ -1,13 +1,22 @@ import { useRef, useState, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { GlassPanel, Icon, Popover, useTheme, type IconName } from "@notux/ui"; import { + exportBoardToPdf, useAssetStore, useCommandStore, usePageStore, useShapeStore, useToolStore, } from "@notux/canvas"; +import { SnapshotsPanel } from "./SnapshotsPanel"; + +interface AppMenuProps { + boardId: string; + client: SupabaseClient | null; + owned: boolean; +} interface MenuItemProps { icon?: IconName; @@ -41,7 +50,7 @@ function MenuSection({ title, children }: { title: string; children: ReactNode } ); } -export function AppMenu() { +export function AppMenu({ boardId, client, owned }: AppMenuProps) { const navigate = useNavigate(); const { theme, toggle: toggleTheme } = useTheme(); @@ -62,6 +71,9 @@ export function AppMenu() { const [menuOpen, setMenuOpen] = useState(false); const [pagesOpen, setPagesOpen] = useState(false); + const [snapshotsOpen, setSnapshotsOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [exporting, setExporting] = useState(false); const [dragIdx, setDragIdx] = useState(null); const [overIdx, setOverIdx] = useState(null); @@ -114,6 +126,21 @@ export function AppMenu() { useToolStore.getState().setSelection(ids); } + async function runExport(scope: "current" | "all") { + setExportOpen(false); + const all = usePageStore.getState().pages; + const active = usePageStore.getState().activePageId; + const pages = scope === "current" ? all.filter((p) => p.id === active) : all; + setExporting(true); + try { + await exportBoardToPdf({ pages, filename: "board.pdf" }); + } catch (e) { + console.error("PDF export failed:", e); + } finally { + setExporting(false); + } + } + function commitDrop() { if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) { reorderPage(dragIdx, overIdx); @@ -189,6 +216,23 @@ export function AppMenu() { disabled={!canImport} onClick={() => run(() => fileRef.current?.click())} /> + { + setMenuOpen(false); + setExportOpen(true); + }} + /> + { + setMenuOpen(false); + setSnapshotsOpen(true); + }} + /> @@ -315,6 +359,38 @@ export function AppMenu() { + {/* Export scope chooser */} + setExportOpen(false)} + anchorRef={menuBtnRef} + placement="bottom" + className="menu-popover" + > +
+
Export as PDF
+ void runExport("current")} + /> + void runExport("all")} + /> +
+
+ + setSnapshotsOpen(false)} + anchorRef={menuBtnRef} + boardId={boardId} + client={client} + owned={owned} + /> + ; + boardId: string; + client: SupabaseClient | null; + owned: boolean; +} + +function formatRelative(iso: string): string { + const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (secs < 60) return "just now"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +// Named-snapshot panel: save the current board as a labelled version, list saved +// versions, and restore one (overwriting the live board for everyone). Owner-only +// per the snapshots RLS; read/list is public. Disabled entirely in local-only mode. +export function SnapshotsPanel({ + open, + onClose, + anchorRef, + boardId, + client, + owned, +}: Props) { + const [label, setLabel] = useState(""); + const [snapshots, setSnapshots] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const labelRef = useRef(null); + + const refresh = useCallback(async () => { + if (!client) return; + try { + setSnapshots(await listNamedSnapshots(client, boardId)); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load snapshots"); + } + }, [client, boardId]); + + useEffect(() => { + if (open) { + setError(null); + void refresh(); + } + }, [open, refresh]); + + async function onSave() { + if (!client || !owned) return; + const doc = useShapeStore.getState()._doc; + if (!doc) return; + const name = label.trim() || `Snapshot ${new Date().toLocaleString()}`; + setBusy(true); + setError(null); + try { + await saveNamedSnapshot(client, boardId, name, encodeSnapshot(doc)); + setLabel(""); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setBusy(false); + } + } + + async function onRestore(snap: NamedSnapshot) { + if (!client || !owned) return; + const doc = useShapeStore.getState()._doc; + if (!doc) return; + const ok = window.confirm( + `Replace the entire board with “${snap.label}”?\n\n` + + "This affects all collaborators and can be undone with Cmd/Ctrl+Z.", + ); + if (!ok) return; + setBusy(true); + setError(null); + try { + const bytes = await fetchSnapshotBytes(client, snap.id); + restoreSnapshot(doc, bytes); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Restore failed"); + } finally { + setBusy(false); + } + } + + return ( + +
+
Snapshots
+ + {!client ? ( +

+ Named snapshots require sign-in and Supabase to be configured. +

+ ) : !owned ? ( +

+ Sign in as the board owner to save or restore snapshots. +

+ ) : ( +
+ setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void onSave(); + }} + /> + +
+ )} + + {error &&

{error}

} + +
+ {client && snapshots.length === 0 && ( +

No saved snapshots yet.

+ )} + {snapshots.map((s) => ( +
+ {s.label} + + {formatRelative(s.createdAt)} + + {owned && ( + + )} +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index 4fd96d3..b59416f 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -12,21 +12,24 @@ import { Dock } from "../features/canvas/Dock"; import { SaveStatus } from "../features/canvas/SaveStatus"; import { SelectionInspector } from "../features/canvas/SelectionInspector"; import { useIdentity } from "../features/canvas/useIdentity"; +import { ensureBoardOwnership } from "../features/board/boardOwnership"; import { getSupabase } from "../lib/supabase"; export default function Board() { const { boardId } = useParams<{ boardId: string }>(); const [ready, setReady] = useState(false); + const [owned, setOwned] = useState(false); const identity = useIdentity(); + const client = getSupabase(); const activePageId = usePageStore((s) => s.activePageId); const { theme } = useTheme(); useEffect(() => { if (!boardId) return; setReady(false); + setOwned(false); // Enable realtime collaboration when Supabase is configured; otherwise the // board runs in local-only mode against IndexedDB. - const client = getSupabase(); useShapeStore .getState() .configureRealtime(client ? { client, identity } : null); @@ -39,8 +42,10 @@ export default function Board() { // Seed/migrate the page list against the IndexedDB-hydrated doc. usePageStore.getState().initPages(boardId); setReady(true); + // Claim board ownership when signed in — gates named snapshots. + void ensureBoardOwnership(client, boardId).then((r) => setOwned(r.owned)); }); - }, [boardId, identity]); + }, [boardId, identity, client]); if (!ready) { return ( @@ -71,7 +76,7 @@ export default function Board() { return (
- + diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4a401c2..edc25c0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -301,3 +301,116 @@ a { height: 100%; color: var(--fg-1); } + +/* Named snapshots panel (anchored off the app menu). */ +.snapshots { + display: flex; + flex-direction: column; + width: 268px; + max-height: 72vh; + overflow-y: auto; + gap: 8px; +} + +.snapshots__head { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--fg-1); + padding: 2px 4px; +} + +.snapshots__note { + font-size: 13px; + color: var(--fg-1); + padding: 4px 4px; + margin: 0; +} + +.snapshots__error { + font-size: 13px; + color: #ff6b6b; + padding: 0 4px; + margin: 0; +} + +.snapshots__save { + display: flex; + gap: 6px; + align-items: center; +} + +.snapshots__input { + flex: 1; + min-width: 0; + font: inherit; + font-size: 13px; + color: var(--fg-0); + background: var(--glass-hover); + border: 1px solid var(--glass-stroke); + border-radius: 8px; + padding: 6px 8px; +} + +.snapshots__save-btn { + flex: none; + width: auto; + gap: 4px; +} + +.snapshots__list { + display: flex; + flex-direction: column; + border-top: 1px solid var(--glass-stroke); + padding-top: 4px; +} + +.snapshots__row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + border-radius: 8px; +} + +.snapshots__row:hover { + background: var(--glass-hover); +} + +.snapshots__row-label { + flex: 1; + font-size: 14px; + color: var(--fg-0); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.snapshots__row-time { + font-size: 12px; + color: var(--fg-1); + flex: none; +} + +.snapshots__restore { + appearance: none; + border: 1px solid var(--glass-stroke); + background: transparent; + color: var(--fg-0); + font: inherit; + font-size: 12px; + padding: 4px 10px; + border-radius: 999px; + cursor: pointer; + flex: none; +} + +.snapshots__restore:hover:not(:disabled) { + background: var(--glass-hover); +} + +.snapshots__restore:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/packages/canvas/package.json b/packages/canvas/package.json index f1923b5..ca68b88 100644 --- a/packages/canvas/package.json +++ b/packages/canvas/package.json @@ -15,6 +15,7 @@ "@notux/sync": "workspace:*", "@notux/types": "workspace:*", "@supabase/supabase-js": "^2.106.2", + "jspdf": "^2.5.2", "konva": "^9.3.16", "nanoid": "^5.0.7", "pdfjs-dist": "^4.7.76", diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index f37d2dc..96280dd 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -69,7 +69,7 @@ export function CanvasStage({ const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 }); const [spaceHeld, setSpaceHeld] = useState(false); - const { undo, redo, canUndo, canRedo } = useUndoManager(pageId); + const { undo, redo, canUndo, canRedo } = useUndoManager(); const awareness = useAwareness(); // Register undo/redo + zoom so the app menu (outside the canvas) can drive diff --git a/packages/canvas/src/export/exportBoardToPdf.ts b/packages/canvas/src/export/exportBoardToPdf.ts new file mode 100644 index 0000000..c955369 --- /dev/null +++ b/packages/canvas/src/export/exportBoardToPdf.ts @@ -0,0 +1,55 @@ +import { useShapeStore } from "../store/shapeStore"; +import { renderPageToCanvas } from "./renderPageToCanvas"; + +export interface ExportPage { + id: string; + title: string; +} + +export interface ExportOptions { + // Ordered pages to export. The caller filters by scope (current/all). + pages: ExportPage[]; + filename?: string; +} + +// Baseline page width in points; each PDF page's height follows the page's +// content aspect ratio so nothing is stretched. +const PAGE_WIDTH_PT = 612; // US-Letter width +const FALLBACK_FORMAT: [number, number] = [612, 792]; // Letter portrait + +// Render each board page to a content-cropped raster and assemble a multi-page +// PDF (one PDF page per non-empty board page). Empty pages are skipped; if every +// page is empty a single blank page is emitted so the download still succeeds. +// Pure client-side (jsPDF) — works in local-only mode; un-downloadable assets +// fall back to their placeholder look. +export async function exportBoardToPdf(opts: ExportOptions): Promise { + const { jsPDF } = await import("jspdf"); + const store = useShapeStore.getState(); + + let pdf: import("jspdf").jsPDF | null = null; + + for (const page of opts.pages) { + const shapes = store.listShapes(page.id); + const raster = await renderPageToCanvas(shapes); + if (!raster) continue; // empty page → no content box + + const orientation = + raster.widthPx >= raster.heightPx ? "landscape" : "portrait"; + const pageHpt = PAGE_WIDTH_PT / raster.aspect; + const format: [number, number] = [PAGE_WIDTH_PT, pageHpt]; + + if (!pdf) { + pdf = new jsPDF({ orientation, unit: "pt", format }); + } else { + pdf.addPage(format, orientation); + } + pdf.addImage(raster.dataUrl, "PNG", 0, 0, PAGE_WIDTH_PT, pageHpt); + } + + if (!pdf) { + // Every page was empty — emit one blank page so save() yields a file. + pdf = new jsPDF({ unit: "pt", format: FALLBACK_FORMAT }); + } + + pdf.save(opts.filename ?? "board.pdf"); +} diff --git a/packages/canvas/src/export/renderPageToCanvas.tsx b/packages/canvas/src/export/renderPageToCanvas.tsx new file mode 100644 index 0000000..1d0dc45 --- /dev/null +++ b/packages/canvas/src/export/renderPageToCanvas.tsx @@ -0,0 +1,131 @@ +import type Konva from "konva"; +import type { YShape } from "@notux/types"; +import { createRoot } from "react-dom/client"; +import { Stage } from "react-konva"; +import { ShapesLayer } from "../layers/ShapesLayer"; +import { shapeBounds, type Bounds } from "../tools/shapeOps"; +import { loadAssetBitmap } from "../assets/assetLoader"; + +const PADDING = 24; // world units of breathing room around the content +const EXPORT_PIXEL_RATIO = 2; // raster DPI multiplier for crisp output +const MAX_DIM = 6000; // clamp the longest raster side so huge pages don't OOM +const ASSET_READY_TIMEOUT_MS = 3000; // give up waiting for assets after this + +const EMPTY_SELECTION: Set = new Set(); + +// Union of every shape's bounds plus padding. Null for an empty page. +export function contentBounds(shapes: YShape[]): Bounds | null { + if (shapes.length === 0) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const s of shapes) { + const b = shapeBounds(s); + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + if (!Number.isFinite(minX)) return null; + return { + x: minX - PADDING, + y: minY - PADDING, + w: maxX - minX + PADDING * 2, + h: maxY - minY + PADDING * 2, + }; +} + +export interface PageRaster { + dataUrl: string; + widthPx: number; + heightPx: number; + aspect: number; // content width / height +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +// AssetRefRenderer paints a Konva.Image once its bitmap resolves (and a dashed +// placeholder until then). With every asset bitmap pre-warmed into the loader +// cache, readiness is just: one Konva Image present per asset shape. Poll the +// detached stage until that holds, with a hard timeout so a missing/failed +// asset can't hang the export (we then capture whatever rendered). +async function waitForAssetsReady( + stage: Konva.Stage, + assetCount: number, +): Promise { + if (assetCount === 0) return; + const deadline = Date.now() + ASSET_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (stage.find("Image").length >= assetCount) break; + await nextFrame(); + } + stage.batchDraw(); +} + +// Rasterize one page's shapes to a PNG data URL, cropped to the content +// bounding box. Reuses ShapesLayer (and thus every existing renderer) by +// mounting a detached react-konva Stage off-DOM — guaranteeing pixel parity +// with the live canvas. Returns null for an empty page. +export async function renderPageToCanvas( + shapes: YShape[], +): Promise { + const bounds = contentBounds(shapes); + if (!bounds) return null; + + // 1. Pre-warm every asset bitmap so the renderer hits the loader cache. + const assetShapes = shapes.filter((s) => s.kind === "asset"); + await Promise.all( + assetShapes.map((s) => + s.kind === "asset" + ? loadAssetBitmap(s.assetId, s.pageIndex).catch(() => undefined) + : undefined, + ), + ); + + // 2. Size the raster to content * ratio, clamped to MAX_DIM. + const ratio = Math.min(EXPORT_PIXEL_RATIO, MAX_DIM / Math.max(bounds.w, bounds.h)); + const widthPx = Math.max(1, Math.ceil(bounds.w * ratio)); + const heightPx = Math.max(1, Math.ceil(bounds.h * ratio)); + + const host = document.createElement("div"); + host.style.cssText = "position:fixed;left:-99999px;top:0;pointer-events:none;"; + document.body.appendChild(host); + const root = createRoot(host); + + try { + let stage: Konva.Stage | null = null; + await new Promise((resolve) => { + root.render( + { + stage = s; + }} + > + + , + ); + // Let react-konva commit and the asset effects fire before we measure. + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + if (!stage) return null; + const konvaStage = stage as Konva.Stage; + await waitForAssetsReady(konvaStage, assetShapes.length); + + const dataUrl = konvaStage.toDataURL({ pixelRatio: 1, mimeType: "image/png" }); + return { dataUrl, widthPx, heightPx, aspect: bounds.w / bounds.h }; + } finally { + root.unmount(); + host.remove(); + } +} diff --git a/packages/canvas/src/hooks/useUndoManager.ts b/packages/canvas/src/hooks/useUndoManager.ts index ddf2fb5..6bece64 100644 --- a/packages/canvas/src/hooks/useUndoManager.ts +++ b/packages/canvas/src/hooks/useUndoManager.ts @@ -1,9 +1,21 @@ import { useCallback, useEffect, useRef, useState } from "react"; import * as Y from "yjs"; -import { getPageMap } from "@notux/sync"; +import { LOCAL_ORIGIN } from "@notux/sync"; import { useShapeStore } from "../store/shapeStore"; -export function useUndoManager(pageId: string): { +// Board-wide, per-user undo. One UndoManager tracks the two top-level board +// containers — the per-page shape maps (getMap("pages")) and the ordered page +// list (getArray("pageList")) — so shape edits on every page AND page-list +// operations share a single history that survives page switches. (A Y.UndoManager +// tracking a parent Y.Map also captures changes to the Y.Maps nested inside it.) +// +// trackedOrigins is exactly { LOCAL_ORIGIN }: every user-initiated mutation +// transacts with that origin (see shapeStore + pageList), while remote edits +// (SupabaseProvider instance), IndexedDB hydration (its provider), and the seed +// page (null) carry other origins and are never captured — making undo strictly +// per-user in multiplayer. captureTimeout coalesces rapid same-origin writes +// (e.g. a drag emitting many updates) into one undo step. +export function useUndoManager(): { undo(): void; redo(): void; canUndo: boolean; @@ -17,8 +29,10 @@ export function useUndoManager(pageId: string): { useEffect(() => { if (!doc) return; - const pageMap = getPageMap(doc, pageId); - const manager = new Y.UndoManager(pageMap); + const manager = new Y.UndoManager( + [doc.getMap("pages"), doc.getArray("pageList")], + { trackedOrigins: new Set([LOCAL_ORIGIN]), captureTimeout: 300 }, + ); managerRef.current = manager; function update() { @@ -39,7 +53,7 @@ export function useUndoManager(pageId: string): { setCanUndo(false); setCanRedo(false); }; - }, [doc, pageId]); + }, [doc]); const undo = useCallback(() => managerRef.current?.undo(), []); const redo = useCallback(() => managerRef.current?.redo(), []); diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index 45549ed..8b6c63d 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -20,3 +20,5 @@ export { } from "./store/dockStore"; export type { InstrumentId, DockPosition } from "./store/dockStore"; export type { ShapeStore } from "./store/shapeStore"; +export { exportBoardToPdf } from "./export/exportBoardToPdf"; +export type { ExportOptions, ExportPage } from "./export/exportBoardToPdf"; diff --git a/packages/canvas/src/store/shapeStore.ts b/packages/canvas/src/store/shapeStore.ts index 2d06e49..4b52fbd 100644 --- a/packages/canvas/src/store/shapeStore.ts +++ b/packages/canvas/src/store/shapeStore.ts @@ -9,6 +9,7 @@ import { getAwareness, findPageMap, getPageMap, + LOCAL_ORIGIN, type Awareness, } from "@notux/sync"; @@ -207,7 +208,7 @@ export const useShapeStore = create((set, get) => ({ const doc = get()._doc; if (!doc) return; const pageMap = getPageMap(doc, pageId); - doc.transact(() => pageMap.set(shape.id, shape)); + doc.transact(() => pageMap.set(shape.id, shape), LOCAL_ORIGIN); }, updateShape(pageId, id, patch) { @@ -219,8 +220,9 @@ export const useShapeStore = create((set, get) => ({ if (!prev) return; // Casting through unknown: Partial across a discriminated union is // wider than any single variant — callers pass kind-compatible patches. - doc.transact(() => - pageMap.set(id, { ...prev, ...patch } as unknown as YShape), + doc.transact( + () => pageMap.set(id, { ...prev, ...patch } as unknown as YShape), + LOCAL_ORIGIN, ); }, @@ -229,7 +231,7 @@ export const useShapeStore = create((set, get) => ({ if (!doc) return; const pageMap = findPageMap(doc, pageId); if (!pageMap) return; - doc.transact(() => pageMap.delete(id)); + doc.transact(() => pageMap.delete(id), LOCAL_ORIGIN); }, deleteShapes(pageId, ids) { @@ -239,7 +241,7 @@ export const useShapeStore = create((set, get) => ({ if (!pageMap) return; doc.transact(() => { for (const id of ids) pageMap.delete(id); - }); + }, LOCAL_ORIGIN); }, transact(fn) { @@ -248,7 +250,7 @@ export const useShapeStore = create((set, get) => ({ fn(); return; } - doc.transact(fn); + doc.transact(fn, LOCAL_ORIGIN); }, setLocked(pageId, id, locked) { diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 2d35809..2170373 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -18,4 +18,6 @@ export { } from "./supabaseProvider"; export type { SupabaseProviderOptions } from "./supabaseProvider"; export { colorForSeed } from "./identity"; +export { LOCAL_ORIGIN } from "./origin"; +export { encodeSnapshot, restoreSnapshot } from "./snapshots"; export { Awareness } from "y-protocols/awareness"; diff --git a/packages/sync/src/origin.ts b/packages/sync/src/origin.ts new file mode 100644 index 0000000..e6a1f29 --- /dev/null +++ b/packages/sync/src/origin.ts @@ -0,0 +1,8 @@ +// A stable, module-level token used as the transaction origin for every +// user-initiated local mutation. The board-wide UndoManager tracks ONLY this +// origin, so remote (SupabaseProvider instance), IndexedDB (its provider), and +// system/migration (null) writes are never captured into a user's undo stack. +// +// A symbol is used rather than a string so it can't collide with a provider +// instance or null, and compares with ===. +export const LOCAL_ORIGIN: symbol = Symbol("notux-local-origin"); diff --git a/packages/sync/src/pageList.ts b/packages/sync/src/pageList.ts index b85e15d..dce2244 100644 --- a/packages/sync/src/pageList.ts +++ b/packages/sync/src/pageList.ts @@ -1,4 +1,5 @@ import * as Y from "yjs"; +import { LOCAL_ORIGIN } from "./origin"; // The ordered list of pages lives in a Y.Array("pageList") of small Y.Maps // ({ id, title }), separate from the per-page shape maps in getMap("pages"). @@ -59,7 +60,7 @@ export function ensureSeedPage( } export function addPageEntry(doc: Y.Doc, id: string, title: string): void { - doc.transact(() => getPageList(doc).push([makeEntry(id, title)])); + doc.transact(() => getPageList(doc).push([makeEntry(id, title)]), LOCAL_ORIGIN); } export function removePageEntry(doc: Y.Doc, id: string): void { @@ -69,7 +70,7 @@ export function removePageEntry(doc: Y.Doc, id: string): void { if (idx >= 0) list.delete(idx, 1); // Drop the page's shapes too. doc.getMap("pages").delete(id); - }); + }, LOCAL_ORIGIN); } export function renamePageEntry(doc: Y.Doc, id: string, title: string): void { @@ -82,7 +83,7 @@ export function renamePageEntry(doc: Y.Doc, id: string, title: string): void { return; } } - }); + }, LOCAL_ORIGIN); } export function movePageEntry(doc: Y.Doc, fromIdx: number, toIdx: number): void { @@ -95,5 +96,5 @@ export function movePageEntry(doc: Y.Doc, fromIdx: number, toIdx: number): void list.delete(fromIdx, 1); const insertAt = Math.max(0, Math.min(toIdx, list.length)); list.insert(insertAt, [makeEntry(id, title)]); - }); + }, LOCAL_ORIGIN); } diff --git a/packages/sync/src/snapshots.ts b/packages/sync/src/snapshots.ts new file mode 100644 index 0000000..250ad36 --- /dev/null +++ b/packages/sync/src/snapshots.ts @@ -0,0 +1,59 @@ +import * as Y from "yjs"; +import type { YShape } from "@notux/types"; +import { LOCAL_ORIGIN } from "./origin"; + +// Encode the full board state (all pages + the page list) as a single Yjs +// update. Stored verbatim as the `ydoc bytea` of a named snapshot row. +export function encodeSnapshot(doc: Y.Doc): Uint8Array { + return Y.encodeStateAsUpdate(doc); +} + +// Overwrite the live doc's board state (pages + pageList) with a snapshot's, in +// ONE LOCAL_ORIGIN transaction so it (a) propagates to peers via the provider's +// update handler — which only suppresses its own origin — and (b) lands as a +// single undoable step. +// +// Shapes live as plain JS objects in the model (pageMap.set(id, shape)), so they +// are read back with a shallow copy and re-set. Page-list entries are rebuilt as +// fresh Y.Maps — a Y type can't be moved between docs. +export function restoreSnapshot(liveDoc: Y.Doc, snapshotBytes: Uint8Array): void { + const temp = new Y.Doc(); + Y.applyUpdate(temp, snapshotBytes); + + const tempPages = temp.getMap>("pages"); + const tempList = temp.getArray>("pageList"); + + const plainPages: Array<[string, YShape[]]> = []; + for (const [pageId, pageMap] of tempPages.entries()) { + plainPages.push([ + pageId, + Array.from(pageMap.values()).map((s) => ({ ...s })), + ]); + } + const plainList = tempList.toArray().map((m) => ({ + id: m.get("id") ?? "", + title: m.get("title") ?? "Page", + })); + temp.destroy(); + + const livePages = liveDoc.getMap>("pages"); + const liveList = liveDoc.getArray>("pageList"); + + liveDoc.transact(() => { + for (const key of Array.from(livePages.keys())) livePages.delete(key); + if (liveList.length > 0) liveList.delete(0, liveList.length); + + for (const [pageId, shapes] of plainPages) { + const pm = new Y.Map(); + for (const s of shapes) pm.set(s.id, s); + livePages.set(pageId, pm); + } + for (const entry of plainList) { + if (!entry.id) continue; + const m = new Y.Map(); + m.set("id", entry.id); + m.set("title", entry.title); + liveList.push([m]); + } + }, LOCAL_ORIGIN); +} diff --git a/packages/ui/src/icons/Icon.tsx b/packages/ui/src/icons/Icon.tsx index 5e53d1f..184e74c 100644 --- a/packages/ui/src/icons/Icon.tsx +++ b/packages/ui/src/icons/Icon.tsx @@ -46,7 +46,9 @@ export type IconName = | "line" | "arrow" | "arrow-curved" - | "arrow-elbow"; + | "arrow-elbow" + | "download" + | "history"; const PATHS: Record = { select: , @@ -160,6 +162,14 @@ const PATHS: Record = { arrow: , "arrow-curved": , "arrow-elbow": , + download: , + history: ( + <> + + + + + ), }; interface Props extends Omit, "name"> { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b044a87..6d1128c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@supabase/supabase-js': specifier: ^2.106.2 version: 2.106.2 + jspdf: + specifier: ^2.5.2 + version: 2.5.2 konva: specifier: ^9.3.16 version: 9.3.22 @@ -239,6 +242,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -670,6 +677,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -689,6 +699,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + atob@2.1.2: + resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} + engines: {node: '>= 4.5.0'} + hasBin: true + + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.10.32: resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} engines: {node: '>=6.0.0'} @@ -699,12 +718,27 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + btoa@1.2.1: + resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} + engines: {node: '>= 0.4.0'} + hasBin: true + caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -717,6 +751,9 @@ packages: supports-color: optional: true + dompurify@2.5.9: + resolution: {integrity: sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==} + electron-to-chromium@1.5.363: resolution: {integrity: sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==} @@ -738,6 +775,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -747,6 +787,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + iceberg-js@0.8.1: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} @@ -772,6 +816,9 @@ packages: engines: {node: '>=6'} hasBin: true + jspdf@2.5.2: + resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==} + konva@9.3.22: resolution: {integrity: sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==} @@ -811,6 +858,9 @@ packages: perfect-freehand@1.2.3: resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -822,6 +872,9 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -861,6 +914,13 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -877,6 +937,17 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -900,6 +971,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1065,6 +1139,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1369,6 +1445,9 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/raf@3.4.3': + optional: true + '@types/react-dom@18.3.7(@types/react@18.3.29)': dependencies: '@types/react': 18.3.29 @@ -1394,6 +1473,11 @@ snapshots: transitivePeerDependencies: - supports-color + atob@2.1.2: {} + + base64-arraybuffer@1.0.2: + optional: true + baseline-browser-mapping@2.10.32: {} browserslist@4.28.2: @@ -1404,16 +1488,41 @@ snapshots: node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) + btoa@1.2.1: {} + caniuse-lite@1.0.30001793: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.7 + '@types/raf': 3.4.3 + core-js: 3.49.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + convert-source-map@2.0.0: {} + core-js@3.49.0: + optional: true + + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + csstype@3.2.3: {} debug@4.4.3: dependencies: ms: 2.1.3 + dompurify@2.5.9: + optional: true + electron-to-chromium@1.5.363: {} esbuild@0.25.12: @@ -1451,11 +1560,19 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + fsevents@2.3.3: optional: true gensync@1.0.0-beta.2: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + iceberg-js@0.8.1: {} isomorphic.js@0.2.5: {} @@ -1473,6 +1590,18 @@ snapshots: json5@2.2.3: {} + jspdf@2.5.2: + dependencies: + '@babel/runtime': 7.29.7 + atob: 2.1.2 + btoa: 1.2.1 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 2.5.9 + html2canvas: 1.4.1 + konva@9.3.22: {} lib0@0.2.117: @@ -1501,6 +1630,9 @@ snapshots: perfect-freehand@1.2.3: {} + performance-now@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -1511,6 +1643,11 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -1553,6 +1690,12 @@ snapshots: dependencies: loose-envify: 1.4.0 + regenerator-runtime@0.13.11: + optional: true + + rgbcolor@1.0.1: + optional: true + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -1592,6 +1735,17 @@ snapshots: source-map-js@1.2.1: {} + stackblur-canvas@2.7.0: + optional: true + + svg-pathdata@6.0.3: + optional: true + + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -1611,6 +1765,11 @@ snapshots: dependencies: react: 18.3.1 + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + vite@6.4.2: dependencies: esbuild: 0.25.12