From 06e4b5f9384ecb0264af1e81803517102987264e Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 00:23:01 +0800 Subject: [PATCH 01/12] docs: record collaboration example library choices --- docs/research/example-libraries.md | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/research/example-libraries.md diff --git a/docs/research/example-libraries.md b/docs/research/example-libraries.md new file mode 100644 index 0000000..60603f1 --- /dev/null +++ b/docs/research/example-libraries.md @@ -0,0 +1,67 @@ +# Libraries for the collaboration examples + +Researched on 2026-09-13 against official documentation and source repositories. Recommendations are judgments about these examples, with Synixir and Yjs retained for synchronization, permissions, and persistence. No packages were installed or application code changed. + +## Kanban + +For a React board, prefer `@dnd-kit/react` with its sortable hooks. The current documentation uses this package and `@dnd-kit/helpers`; it labels `@dnd-kit/core`, `@dnd-kit/sortable`, and `@dnd-kit/utilities` as the legacy API. It supplies sorting across containers, pointer/touch/keyboard input, ARIA defaults, screen-reader instructions, and live regions under the MIT license. These are useful improvements over maintaining native drag handlers as the board grows. Sources: [current React quickstart](https://dndkit.com/react/quickstart/), [migration guide](https://dndkit.com/react/guides/migration/), [official source and features](https://github.com/clauderic/dnd-kit). + +The present board is imperative DOM code, so adopting the React hooks means rewriting its rendering. `@dnd-kit/dom` offers a plain TypeScript alternative. Atlassian's `@atlaskit/pragmatic-drag-and-drop` is another sensible choice when retaining the current DOM structure and browser-native drag behavior. Its core works independently of a UI framework; accessible alternatives and announcements need explicit implementation. It is Apache-2.0 licensed. Sources: [dnd-kit architecture](https://github.com/clauderic/dnd-kit), [Pragmatic source](https://github.com/atlassian/pragmatic-drag-and-drop), [accessibility guidance](https://atlassian.design/components/pragmatic-drag-and-drop/accessibility-guidelines/), [license](https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/LICENSE). + +Integration judgment: keep card IDs, ordering, and column changes in the existing Yjs model; translate completed drags into model actions. Neither package supplies collaboration. dnd-kit's manual state management supports custom data structures, which fits this boundary better than treating its array-moving helper as the shared database. Source: [sortable state management](https://dndkit.com/react/guides/sortable-state-management/). + +## Whiteboard + +Prefer `@excalidraw/excalidraw` if the goal is a complete drawing UI with permissive licensing. Excalidraw is MIT licensed and provides a finished editor. Its embeddable package deliberately leaves collaboration to the host application, with `onChange`, `onPointerUpdate`, and `updateScene` available for integration. Sources: [official repository](https://github.com/excalidraw/excalidraw), [license](https://github.com/excalidraw/excalidraw/blob/master/LICENSE), [collaboration FAQ](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq), [props](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props), [scene API](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api). + +Integration judgment: this saves drawing-interface work but requires a substantial Synixir/Yjs adapter. It must reconcile element changes and deletions, avoid echoing remote changes, coordinate undo, and map presence. Replacing the entire scene with one shared JSON value would lose the benefit of independent concurrent edits. Excalidraw's hosted app having collaboration does not mean installing the package enables it. + +`tldraw` is a strong alternative for a customizable infinite canvas. It supports custom realtime backends through its store and presence APIs. However, production use requires an active trial, commercial, or discretionary non-commercial hobby license; downstream users of an open-source example also need their own production license. Its default sync implementation uses a separate JavaScript/WebSocket backend, so retaining Synixir requires a custom adapter. Sources: [custom backend support](https://tldraw.dev/docs/collaboration), [sync architecture](https://tldraw.dev/docs/sync), [licensing](https://tldraw.dev/community/license). + +Use `konva` with `react-konva` only if keeping a deliberately small custom whiteboard is the priority. Konva provides canvas shapes, events, and dragging; the editor tools and collaboration remain our work. Konva is MIT licensed, and the React wrapper's major version must match React's major version. Sources: [React integration](https://konvajs.org/docs/react/index.html), [license](https://github.com/konvajs/konva/blob/master/LICENSE). + +## Flowchart + +Prefer `@xyflow/react`, React Flow. It is MIT licensed and supplies node dragging, connections, pan/zoom, selection, resizing, custom React nodes, and minimap/controls. This is the clearest replacement for the custom DOM/SVG canvas. Source: [React Flow](https://reactflow.dev/). + +Keep the existing Yjs data model and map node/edge changes into it. React Flow documents Yjs-based collaboration and distinguishes durable graph fields from local selection and transient cursor state. Its downloadable collaborative example uses the separate xyflow Pro license; that does not make the library or an independently written Synixir integration paid. Sources: [multiplayer guide](https://reactflow.dev/learn/advanced-use/multiplayer), [collaborative example license](https://reactflow.dev/examples/interaction/collaborative). + +Add layout only when requested. React Flow has no built-in automatic layout engine. Dagre fits simple directed layouts; `elkjs` supports more complex arrangements and edge routing but adds configuration work. ELK uses EPL-2.0 rather than MIT. Sources: [layout comparison](https://reactflow.dev/learn/layouting/layouting), [ELK license](https://github.com/kieler/elkjs/blob/master/LICENSE.md). + +## Text editor + +Keep CodeMirror 6 with `y-codemirror.next`. The binding already supplies Y.Text synchronization, remote selections, and undo/redo. Its maintainers explicitly recommend the stable `y-codemirror.next` package with Yjs 13 while the separate `@y/codemirror` release for Yjs 14 is unstable. This matches this repo's Yjs 13 dependency. Source: [official binding README](https://github.com/yjs/y-codemirror.next). + +For a future code-oriented editor, `monaco-editor` with `y-monaco` is an alternative. The official binding connects Y.Text, the Monaco model, and awareness. For this existing text demo, switching offers little benefit. Source: [y-monaco](https://github.com/yjs/y-monaco). + +## Rich text + +Keep Tiptap. Its Collaboration extension accepts a Y.Doc or Y.XmlFragment, which fits the existing Synixir room. Source: [Tiptap Collaboration extension](https://tiptap.dev/docs/editor/extensions/functionality/collaboration). + +BlockNote is worth considering if the desired example is a block editor with ready-made menus and toolbars. It supplies React components and several UI adapters, including `@blocknote/shadcn`, and supports a configurable Yjs provider and fragment. Integration with Synixir would still need verification of the provider/awareness interface and a document-schema migration. Sources: [BlockNote setup](https://www.blocknotejs.org/docs/getting-started), [BlockNote collaboration](https://www.blocknotejs.org/docs/features/collaboration). + +## Editable table + +My first candidate for replacing the custom spreadsheet interactions is `react-data-grid`. Its documented features include keyboard navigation, row and column virtualization, editing, resizing, copying/pasting, and custom cell editors. A custom editor is the likely place to preserve our existing CodeMirror/Y.Text binding. This is an integration recommendation, not a tested adapter. Source: [react-data-grid features and API](https://github.com/Comcast/react-data-grid). + +There is a release tradeoff: the npm `latest` version checked on 2026-09-13 is `7.0.0-beta.61`, with React and React DOM peers of `^19.2`. It matches the example's React 19.3 manifest range, but is a prerelease. Source: [published package metadata](https://registry.npmjs.org/react-data-grid/7.0.0-beta.61). + +`@tanstack/react-table` is the alternative if retaining our shadcn styling and custom editors matters more than getting grid interactions. It supplies table state and APIs but leaves markup and styles to the application. Source: [TanStack Table overview](https://tanstack.com/table/v8/docs/overview). + +Other candidates have specific constraints. Glide Data Grid has built-in editing, selection, and resizing, but its current `6.0.3` package declares React 16/17/18 peers, so adopting it here needs a React 19 compatibility check. AG Grid Community is free; its enhanced clipboard and range-selection features are Enterprise features. Sources: [Glide documentation](https://docs.grid.glideapps.com/), [Glide package metadata](https://registry.npmjs.org/@glideapps/glide-data-grid/6.0.3), [AG Grid editions](https://www.ag-grid.com/react-data-grid/community-vs-enterprise/). + +The local table currently binds each cell to its own Y.Text and CodeMirror instance. A grid's ordinary edit/commit callback does not by itself preserve that character-level merge behavior. Retain a Yjs-bound editor, stable row/cell IDs, remote presence, and the existing undo semantics when evaluating a replacement. Local sources: [table model](../../examples/collaboration/table/model.ts), [table UI](../../examples/collaboration/table/table.ts). + +## Multiplayer form + +`react-hook-form`, `@hookform/resolvers`, and `zod` can replace manual validation and form-state handling if this example becomes more complex. The resolver package documents Zod integration and inferred types. Sources: [React Hook Form](https://github.com/react-hook-form/react-hook-form), [Zod resolver](https://github.com/react-hook-form/resolvers#zod). + +For this small form, I would add Zod only when the schema needs to grow, and add React Hook Form when touched/error/submission state warrants it. These packages do not establish the synchronization contract. Keep Yjs as shared state, preserve the Y.Text editor bindings, and bridge local field updates and remote observations without resetting another user's active text. This recommendation follows the local [form model](../../examples/collaboration/multiplayer-form/model.ts) and [form UI](../../examples/collaboration/multiplayer-form/form.ts). + +## Shared settings + +Keep the minimal React controls with `@synixir/client` and Yjs. This route exists to demonstrate the SDK; an additional state-management package would obscure that example. Local sources: [example descriptions](../../examples/collaboration/lib/examples.ts), [workspace implementation](../../examples/collaboration/components/workspace/workspace.tsx). + +## Scope of the findings + +These recommendations combine official documentation, published package metadata, and the current source code. No replacement package was installed or tested in the application. UI packages would still use Synixir for room access, transport, durable saves, and reconnection; integration work must preserve those behaviors. From fcc5025f7cb407c848ae2adfbce5c0e2be9307c7 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 00:30:07 +0800 Subject: [PATCH 02/12] test: isolate browser suites with configurable ports --- config/runtime.exs | 4 ++++ examples/collaboration/README.md | 3 +++ examples/collaboration/playwright.config.ts | 9 +++++---- examples/collaboration/tests/fixtures.ts | 7 ++++--- examples/collaboration/tests/ports.ts | 12 ++++++++++++ examples/collaboration/tests/transport.spec.ts | 9 +++++---- 6 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 examples/collaboration/tests/ports.ts diff --git a/config/runtime.exs b/config/runtime.exs index cc909c0..8c16c65 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -62,6 +62,10 @@ if database = System.get_env("SYNIXIR_OPERATIONS_DATABASE") do end if config_env() == :test and System.get_env("SYNIXIR_BROWSER_TEST") == "true" do + frontend_port = System.get_env("SYNIXIR_TEST_FRONTEND_PORT", "5174") + + config :synixir, SynixirWeb.Endpoint, check_origin: ["http://127.0.0.1:#{frontend_port}"] + # Browser tests use committed data across independent processes. Sandbox # retains connections for long-lived channels and documents, exhausting the # pool before later HTTP requests can run. Keep ExUnit's Sandbox separate. diff --git a/examples/collaboration/README.md b/examples/collaboration/README.md index 40ede8b..4e56b5c 100644 --- a/examples/collaboration/README.md +++ b/examples/collaboration/README.md @@ -39,6 +39,9 @@ preserved in development and production. Navigation uses full page loads to retain the unsaved-edit warning and tear down old room connections. Browser tests run on ports 5174 and 4010 with a separate Phoenix test database. +When testing separate Git worktrees in parallel, set `SYNIXIR_TEST_FRONTEND_PORT` +and `SYNIXIR_TEST_BACKEND_PORT` to a unique pair for each run. The test server's +origin allowlist follows the selected frontend port. They enable a development-only inspection adapter for public SDK and editor APIs; production builds exclude it. The package consumer check also uses Vite to verify that the published SDK works outside Next.js. diff --git a/examples/collaboration/playwright.config.ts b/examples/collaboration/playwright.config.ts index 76b3a6c..e620938 100644 --- a/examples/collaboration/playwright.config.ts +++ b/examples/collaboration/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "@playwright/test"; +import { backendURL, frontendPort, frontendURL } from "./tests/ports.ts"; export default defineConfig({ testDir: "./tests", @@ -6,14 +7,14 @@ export default defineConfig({ retries: 0, forbidOnly: Boolean(process.env.CI), use: { - baseURL: "http://127.0.0.1:5174", + baseURL: frontendURL, trace: "retain-on-failure", }, webServer: [ { - command: "npm run dev -- --port 5174", - env: { NEXT_PUBLIC_SYNIXIR_BROWSER_TEST: "true", SYNIXIR_ENDPOINT: "http://127.0.0.1:4010" }, - url: "http://127.0.0.1:5174", + command: `npm run dev -- --port ${frontendPort}`, + env: { NEXT_PUBLIC_SYNIXIR_BROWSER_TEST: "true", SYNIXIR_ENDPOINT: backendURL }, + url: frontendURL, reuseExistingServer: false, gracefulShutdown: { signal: "SIGTERM", timeout: 5_000 }, }, diff --git a/examples/collaboration/tests/fixtures.ts b/examples/collaboration/tests/fixtures.ts index abedc4d..012c6ac 100644 --- a/examples/collaboration/tests/fixtures.ts +++ b/examples/collaboration/tests/fixtures.ts @@ -2,10 +2,11 @@ import { test as base, expect, type Page } from "@playwright/test"; import { spawn, execFileSync } from "node:child_process"; import { once } from "node:events"; import { fileURLToPath } from "node:url"; +import { backendPort, backendURL } from "./ports.ts"; const cwd = fileURLToPath(new URL("../../..", import.meta.url)); -const env = { ...process.env, MIX_ENV: "test", PHX_SERVER: "true", PORT: "4010", SYNIXIR_BROWSER_TEST: "true" }; -const endpoint = "http://127.0.0.1:4010/robots.txt"; +const env = { ...process.env, MIX_ENV: "test", PHX_SERVER: "true", PORT: String(backendPort), SYNIXIR_BROWSER_TEST: "true" }; +const endpoint = `${backendURL}/robots.txt`; interface Backend { logs(): string; restart(): Promise; compact(room: string): string } export const test = base.extend<{ pageErrors: void }, { backend: Backend }>({ @@ -33,7 +34,7 @@ export const test = base.extend<{ pageErrors: void }, { backend: Backend }>({ } async function start() { - if (await ready()) throw new Error("Port 4010 is already in use"); + if (await ready()) throw new Error(`Port ${backendPort} is already in use`); output = ""; child = spawn("mix", ["phx.server"], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] }); exited = once(child, "exit"); diff --git a/examples/collaboration/tests/ports.ts b/examples/collaboration/tests/ports.ts new file mode 100644 index 0000000..a8f7904 --- /dev/null +++ b/examples/collaboration/tests/ports.ts @@ -0,0 +1,12 @@ +function port(name: string, fallback: number) { + const value = Number(process.env[name] ?? fallback); + if (!Number.isInteger(value) || value < 1 || value > 65535) { + throw new Error(`${name} must be an integer between 1 and 65535`); + } + return value; +} + +export const frontendPort = port("SYNIXIR_TEST_FRONTEND_PORT", 5174); +export const backendPort = port("SYNIXIR_TEST_BACKEND_PORT", 4010); +export const frontendURL = `http://127.0.0.1:${frontendPort}`; +export const backendURL = `http://127.0.0.1:${backendPort}`; diff --git a/examples/collaboration/tests/transport.spec.ts b/examples/collaboration/tests/transport.spec.ts index 5fb15e1..9966b9c 100644 --- a/examples/collaboration/tests/transport.spec.ts +++ b/examples/collaboration/tests/transport.spec.ts @@ -2,10 +2,11 @@ import { createConnection } from "node:net"; import { request } from "node:http"; import { randomBytes } from "node:crypto"; import { test, expect } from "./fixtures.ts"; +import { backendPort, backendURL, frontendURL } from "./ports.ts"; test("the WebSocket handshake rejects an untrusted browser origin", async () => { const status = await new Promise((resolve, reject) => { - const req = request("http://127.0.0.1:4010/socket/websocket?vsn=2.0.0", { + const req = request(`${backendURL}/socket/websocket?vsn=2.0.0`, { headers: { Origin: "https://untrusted.example", Connection: "Upgrade", @@ -42,7 +43,7 @@ function frame(size: number, opcode: number, final: boolean) { for (const fragmented of [false, true]) { test(`transport rejects an oversized ${fragmented ? "fragmented message" : "frame"} before channel processing`, async () => { - const socket = createConnection({ host: "127.0.0.1", port: 4010 }); + const socket = createConnection({ host: "127.0.0.1", port: backendPort }); let timeout; try { const code = await new Promise((resolve, reject) => { @@ -53,8 +54,8 @@ for (const fragmented of [false, true]) { socket.on("close", () => reject(new Error("Connection closed without a WebSocket close frame"))); socket.on("connect", () => socket.write([ "GET /socket/websocket?vsn=2.0.0 HTTP/1.1", - "Host: 127.0.0.1:4010", - "Origin: http://127.0.0.1:5174", + `Host: 127.0.0.1:${backendPort}`, + `Origin: ${frontendURL}`, "Connection: Upgrade", "Upgrade: websocket", "Sec-WebSocket-Version: 13", From 0fcae907f6f83ace41aa2e780f7c04e32f8b8377 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 00:33:40 +0800 Subject: [PATCH 03/12] feat(kanban): add collaborative card sorting with dnd-kit --- examples/collaboration/kanban/README.md | 11 ++ examples/collaboration/kanban/board-view.tsx | 128 +++++++++++++++++++ examples/collaboration/kanban/board.css | 6 +- examples/collaboration/kanban/board.ts | 111 ++++------------ examples/collaboration/kanban/model.test.ts | 63 +++++++++ examples/collaboration/kanban/model.ts | 60 +++++++-- examples/collaboration/package.json | 2 + examples/collaboration/tests/kanban.spec.ts | 82 +++++++++++- package-lock.json | 83 ++++++++++++ 9 files changed, 446 insertions(+), 100 deletions(-) create mode 100644 examples/collaboration/kanban/README.md create mode 100644 examples/collaboration/kanban/board-view.tsx diff --git a/examples/collaboration/kanban/README.md b/examples/collaboration/kanban/README.md new file mode 100644 index 0000000..d87d89c --- /dev/null +++ b/examples/collaboration/kanban/README.md @@ -0,0 +1,11 @@ +# Collaborative kanban + +The board renders React cards with `@dnd-kit/react` 0.5.0. Each card has a dedicated drag handle that supports mouse, touch, and keyboard input. Focus the handle, press Space, move with the arrow keys, and press Space again to drop. Escape cancels. The card's Status control remains available without dragging. + +`board-view.tsx` owns sortable previews and the existing shadcn buttons and badges. `board.ts` connects completed drops to the Synixir room and retains the card-details dialog, active-card presence, caret handling, permission updates, and local undo. The workspace still loads `createBoard(room)` and disposes it through the same cleanup function. + +The shared document keeps one `Y.Map` per card in `kanban:cards:v1`. Column and rank form one atomic placement value, so simultaneous moves resolve to one placement. Cards added by the earlier example still load from their numeric `order`; there is no document-wide migration. New placements add a lexicographic numeric path in `rank`. A stable card ID breaks ties between concurrent equal ranks. Inserting between cards extends that path when floating-point precision is exhausted, without rewriting neighboring cards or their concurrent changes. + +During a drag, dnd-kit owns the visual preview. Remote updates continue to merge into the Yjs document. Dropping translates the preview into a destination column and the next card's ID, then applies one local action against the latest document. Canceling discards the preview and renders the current document. Permission revocation cancels an active drag before any placement is written. Presence and previews never enter undo history. + +Run the model tests with `node --import tsx --test examples/collaboration/kanban/model.test.ts`. The kanban browser suite covers pointer, touch, and keyboard moves, cancellation, peer edits during a drag, local undo, offline reconnection, persistence after a server crash, viewer access, and mobile layout. See the [dnd-kit sortable state guide](https://dndkit.com/react/guides/sortable-state-management/) for the preview and drop APIs. diff --git a/examples/collaboration/kanban/board-view.tsx b/examples/collaboration/kanban/board-view.tsx new file mode 100644 index 0000000..773510a --- /dev/null +++ b/examples/collaboration/kanban/board-view.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { DragDropProvider, useDragDropManager, useDroppable, type DragDropManager } from "@dnd-kit/react"; +import { isSortable, useSortable } from "@dnd-kit/react/sortable"; +import { pointerIntersection } from "@dnd-kit/collision"; +import { GripVertical, Plus } from "lucide-react"; +import { cn } from "cn"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { columns, type Card } from "./model.ts"; + +interface BoardProps { + cards: Card[]; + readOnly: boolean; + onOpen(id: string): void; + onAdd(column: string): void; + onDragStart(id: string): void; + onDragEnd(id?: string, column?: string, beforeId?: string | null): void; + onRender(): void; +} + +function SortableCard({ card, index, readOnly, onOpen }: { + card: Card; index: number; readOnly: boolean; onOpen(id: string): void; +}) { + const { ref, handleRef, isDragging } = useSortable({ + id: card.id, index, group: card.placement.column, + type: "card", accept: "card", disabled: readOnly, + }); + const title = card.title || "Untitled card"; + return ( +
+ + +
+ ); +} + +function Column({ column, cards, readOnly, onOpen, onAdd }: Pick & { + column: typeof columns[number]; +}) { + const { ref, isDropTarget } = useDroppable({ + id: column.id, type: "column", accept: "card", disabled: readOnly, + // A pointer over a column beats a card's shape overlap (2), while a + // pointer over a card still selects its precise insertion target (3). + collisionDetector: pointerIntersection, collisionPriority: 2.5, + }); + return ( +
+

{column.label}

{cards.length}
+
+ {cards.map((card, index) => )} + {cards.length === 0 ?

{column.id === "done" ? "Finished work lands here." : "No cards yet."}

: null} +
+ +
+ ); +} + +function ManagerRef({ onManager }: { onManager(manager: DragDropManager | null): void }) { + const manager = useDragDropManager(); + useLayoutEffect(() => { + onManager(manager); + return () => onManager(null); + }, [manager, onManager]); + return null; +} + +function BoardView({ onManager, ...props }: BoardProps & { onManager(manager: DragDropManager | null): void }) { + useLayoutEffect(props.onRender); + return ( + { if (props.readOnly) event.preventDefault(); }} + onDragStart={event => { + if (event.operation.source) props.onDragStart(String(event.operation.source.id)); + }} + onDragEnd={event => { + const { source, target } = event.operation; + if (props.readOnly || event.canceled || !target || !isSortable(source)) { + props.onDragEnd(); + return; + } + // Sortable properties describe the preview; IDs translate that preview + // into one model action against the latest shared document on drop. + const column = target.type === "column" ? String(target.id) : String(source.group); + const items = props.cards.filter(card => card.placement.column === column && card.id !== source.id); + const beforeId = target.type === "column" ? null : items[source.index]?.id ?? null; + if (column === source.initialGroup && source.index === source.initialIndex && target.type !== "column") { + props.onDragEnd(); + } else { + props.onDragEnd(String(source.id), column, beforeId); + } + }}> + +
+ {columns.map(column => card.placement.column === column.id)} + readOnly={props.readOnly} onOpen={props.onOpen} onAdd={props.onAdd} />)} +
+
+ ); +} + +export function mountBoardView(element: HTMLElement) { + const root = createRoot(element); + let manager: DragDropManager | null = null; + const onManager = (value: DragDropManager | null) => { manager = value; }; + return { + render(props: BoardProps) { root.render(); }, + cancel() { manager?.actions.stop({ canceled: true }); }, + destroy() { root.unmount(); }, + }; +} diff --git a/examples/collaboration/kanban/board.css b/examples/collaboration/kanban/board.css index e8dade7..852e6a3 100644 --- a/examples/collaboration/kanban/board.css +++ b/examples/collaboration/kanban/board.css @@ -21,7 +21,11 @@ .card-list { min-height: 100px; display: grid; gap: 12px; align-content: start; } .kanban-card { position: relative; min-width: 0; border: 1px solid #d4ded7; border-top: 3px solid #91a499; border-radius: 7px; background: white; box-shadow: 0 2px 3px #19342405; } .card-grip { position: absolute; right: 4px; top: 5px; padding: 4px 7px; font-size: 22px; color: #64756c; cursor: grab; user-select: none; } -.kanban-card[draggable="true"] { cursor: grab; } +.kanban-card[data-dragging] { opacity: .65; } +.kanban-board .card-grip { touch-action: none; cursor: grab; } +.kanban-board .card-grip:active { cursor: grabbing; } +.kanban-board .card-open { display: block; height: auto; white-space: normal; padding: 12px 36px 12px 12px; } +.kanban-board .add-card { width: 100%; margin-top: 12px; } .kanban-card.card-blue { border-top-color: #497fc1; } .kanban-card.card-green { border-top-color: #428461; } .kanban-card.card-amber { border-top-color: #b4801e; } diff --git a/examples/collaboration/kanban/board.ts b/examples/collaboration/kanban/board.ts index 9174116..ab4e0cb 100644 --- a/examples/collaboration/kanban/board.ts +++ b/examples/collaboration/kanban/board.ts @@ -1,5 +1,6 @@ import type { SynixirRoom } from "@synixir/client"; import { createBoardModel, columns, colors } from "./model.ts"; +import { mountBoardView } from "./board-view.tsx"; export function createBoard(room: SynixirRoom) { const root = document.querySelector("#editor")!; @@ -12,7 +13,6 @@ export function createBoard(room: SynixirRoom) { let returnFocusId: string|null|undefined = null; let draggedId: string|null = null; let disposed = false; - const nodes = new Map(); const undo = document.querySelector("#undo")!; const redo = document.querySelector("#redo")!; const announce = (text: string|null) => { document.querySelector("#board-announcement")!.textContent = text; }; @@ -23,46 +23,8 @@ export function createBoard(room: SynixirRoom) { if (text !== undefined) node.textContent = text; return node; } - const board = element("div", "kanban-board"); - for (const column of columns) { - const section = element("section", `kanban-column column-${column.id}`); - section.dataset.column = column.id; - section.setAttribute("aria-label", column.label); - const header = element("div", "column-heading"); - const title = element("h2", "", column.label); - const count = element("span", "column-count", "0"); - const add = element("button", "add-card", "+ Add card"); - add.type = "button"; - add.setAttribute("aria-label", `Add card to ${column.label}`); - add.disabled = true; - const list = element("div", "card-list"); - header.append(title, count); - section.append(header, list, add); - board.append(section); - nodes.set(column.id, { section, list, count, add }); - listen(add, "click", () => { - if (readOnly) return; - openCard(model.add(column.id)); - titleInput.select(); - }); - listen(section, "dragover", (event) => { - if (readOnly || !draggedId) return; - event.preventDefault(); - event.dataTransfer!.dropEffect = "move"; - section.classList.add("drop-target"); - }); - listen(section, "dragleave", (event) => { - if (!section.contains(event.relatedTarget as Node | null)) section.classList.remove("drop-target"); - }); - listen(section, "drop", (event) => { - event.preventDefault(); - if (!readOnly && draggedId) { - model.move(draggedId, column.id); - announce(`Card moved to ${column.label}.`); - } - endDrag(); - }); - } + const board = element("div", "kanban-mount"); + const view = mountBoardView(board); const dialog = element("dialog", "card-dialog"); dialog.setAttribute("aria-labelledby", "card-dialog-heading"); @@ -104,9 +66,12 @@ export function createBoard(room: SynixirRoom) { renderPresence(); titleInput.focus(); } - function endDrag() { + function endDrag(id?: string, column?: string, beforeId?: string | null) { + if (!readOnly && id && column) { + model.move(id, column, beforeId); + announce(`Card moved to ${columns.find(item => item.id === column)?.label ?? column}.`); + } draggedId = null; - nodes.forEach(({ section }) => section.classList.remove("drop-target")); publish(); render(); } @@ -204,50 +169,19 @@ export function createBoard(room: SynixirRoom) { redo.disabled = readOnly || !model.history.canRedo(); } function render() { - // Keep the browser's dragged element mounted while remote changes arrive. - // The model still syncs immediately; endDrag renders the latest state. + // dnd-kit owns the preview DOM until drop. Remote updates still apply to + // the model immediately; endDrag renders the latest shared state. if (disposed || draggedId) return; - const focusedId = document.activeElement?.closest(".kanban-card")?.dataset.cardId; - const cards = model.list(); - for (const column of columns) { - const { list, count, add } = nodes.get(column.id); - const items = cards.filter(card => card.placement.column === column.id); - count.textContent = items.length; - add.disabled = readOnly; - list.replaceChildren(...items.map(card => { - const node = element("article", `kanban-card card-${card.color}`); - node.dataset.cardId = card.id; - node.draggable = !readOnly; - const open = element("button", "card-open"); - open.type = "button"; - open.setAttribute("aria-label", `Open card: ${card.title || "Untitled card"}`); - open.append(element("span", "card-title", card.title || "Untitled card")); - if (card.description) open.append(element("span", "card-description", card.description)); - open.append(element("span", "card-presence")); - const grip = element("span", "card-grip", "⠿"); - grip.draggable = !readOnly; - grip.hidden = readOnly; - grip.title = "Drag card between columns"; - grip.setAttribute("aria-hidden", "true"); - node.append(open, grip); - // These listeners belong to these short-lived nodes, not the root's - // abort signal, which would retain detached cards on every update. - open.addEventListener("click", () => openCard(card.id)); - node.addEventListener("dragstart", event => { - if (readOnly) return event.preventDefault(); - draggedId = card.id; - event.dataTransfer!.setData("text/plain", card.id); - event.dataTransfer!.effectAllowed = "move"; - publish("moving", card.id); - }); - node.addEventListener("dragend", endDrag); - return node; - })); - if (!items.length) list.append(element("p", "column-empty", column.id === "done" ? "Finished work lands here." : "No cards yet.")); - } - if (focusedId) { - [...root.querySelectorAll(".kanban-card")].find(node => node.dataset.cardId === focusedId)?.querySelector("button")!.focus(); - } + view.render({ + cards: model.list(), readOnly, onOpen: openCard, + onAdd: column => { + if (readOnly) return; + openCard(model.add(column)); + titleInput.select(); + }, + onDragStart: id => { draggedId = id; publish("moving", id); }, + onDragEnd: endDrag, onRender: renderPresence, + }); renderDialog(); renderPresence(); renderHistory(); @@ -262,14 +196,17 @@ export function createBoard(room: SynixirRoom) { events.abort(); model.cards.unobserveDeep(render); room.awareness.off("change", renderPresence); + view.cancel(); + view.destroy(); model.destroy(); + publish("viewing", null); dialog.close(); root.replaceChildren(); } destroy.setReadOnly = (value: boolean) => { if (readOnly !== value) { readOnly = value; - if (readOnly) endDrag(); + if (readOnly) view.cancel(); render(); } renderPresence(); diff --git a/examples/collaboration/kanban/model.test.ts b/examples/collaboration/kanban/model.test.ts index 0266fa0..16729de 100644 --- a/examples/collaboration/kanban/model.test.ts +++ b/examples/collaboration/kanban/model.test.ts @@ -70,3 +70,66 @@ test("concurrent creation keeps both cards and deletion wins over an offline fie assert.ok(first.list().every(card => card.id !== id)); } finally { close(); } }); + +test("cards reorder within a column and between columns without rewriting neighbors", () => { + const { first, second, sync, close } = clients(); + try { + const a = first.add("backlog")!; + const b = first.add("backlog")!; + const c = first.add("backlog")!; + sync(); + first.move(c, "backlog", a); + second.edit(b, "title", "Concurrent title"); + sync(); + assert.deepEqual(first.list().map(card => card.id), [c, a, b]); + first.history.undo(); + sync(); + assert.deepEqual(first.list().map(card => card.id), [a, b, c]); + assert.equal(first.list().find(card => card.id === b)?.title, "Concurrent title"); + first.move(b, "done", null); + first.move(c, "done", b); + sync(); + assert.deepEqual(first.list().filter(card => card.placement.column === "done").map(card => card.id), [c, b]); + } finally { close(); } +}); + +test("concurrent equal ranks support later insertion without duplicate cards", () => { + const { first, second, sync, close } = clients(); + try { + first.add("backlog"); + second.add("backlog"); + sync(); + const [a, b] = first.list().map(card => card.id); + const c = first.add("done")!; + sync(); + first.move(c, "backlog", b); + second.move(a, "done", null); + sync(); + assert.deepEqual(first.list().filter(card => card.placement.column === "backlog").map(card => card.id), [c, b]); + assert.equal(new Set(first.list().map(card => card.id)).size, 3); + } finally { close(); } +}); + +test("legacy numeric placements load unchanged and remain sortable after repeated insertions", () => { + const doc = new Y.Doc(); + const model = createBoardModel(doc); + try { + const legacy = (title: string) => new Y.Map([ + ["title", title], ["description", ""], ["color", "neutral"], + ["placement", { column: "backlog", order: 1 }], + ]); + model.cards.set("a", legacy("Legacy first")); + model.cards.set("z", legacy("Legacy second")); + assert.deepEqual(model.list().map(card => card.id), ["a", "z"]); + let before = "z"; + for (let i = 0; i < 100; i++) { + const id = model.add("done")!; + model.move(id, "backlog", before); + const ids = model.list().map(card => card.id); + assert.equal(ids.indexOf(id), 1); + before = id; + } + assert.equal(model.list().at(-1)?.id, "z"); + assert.deepEqual((model.cards.get("a") as Y.Map).get("placement"), { column: "backlog", order: 1 }); + } finally { model.destroy(); doc.destroy(); } +}); diff --git a/examples/collaboration/kanban/model.ts b/examples/collaboration/kanban/model.ts index 70c4a67..5bd5e83 100644 --- a/examples/collaboration/kanban/model.ts +++ b/examples/collaboration/kanban/model.ts @@ -7,6 +7,39 @@ export const columns = [ ]; export const colors = ["neutral", "blue", "green", "amber", "rose"]; +export interface Card { + id: string; + title: string; + description: string; + color: string; + placement: { column: string; order: number; rank?: number[] }; +} + +// Lexicographic paths leave room between equal legacy numeric orders without +// rewriting other cards. Include the stable ID so concurrent equal ranks also +// have a deterministic order, with room to insert between them later. +const position = (card: Card) => [ + ...(card.placement.rank ?? [card.placement.order]), + ...Array.from(card.id, char => char.charCodeAt(0)), +]; +function compare(a: number[], b: number[]) { + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + return a.length - b.length; +} +function between(a: number[] | undefined, b: number[] | undefined): number[] { + if (!a) return b ? [b[0] - 1] : [1]; + if (!b) return a[0] + 1 > a[0] && Number.isFinite(a[0] + 1) ? [a[0] + 1] : [...a, 0]; + let index = 0; + while (index < a.length && a[index] === b[index]) index++; + if (index === a.length) return [...a, b[index] - 1]; + const middle = a[index] / 2 + b[index] / 2; + if (a[index] < middle && middle < b[index]) return [...a.slice(0, index), middle]; + // No representable float between these digits. Extend the lower path. + return [...a, 0]; +} + // One map entry per card. Column and order change together, so concurrent // moves choose one placement instead of inserting the card into two lists. export function createBoardModel(doc: Y.Doc) { @@ -14,17 +47,19 @@ export function createBoardModel(doc: Y.Doc) { const origin = {}; const history = new Y.UndoManager(cards, { trackedOrigins: new Set([origin]) }); const validColumn = (id: string) => columns.some(column => column.id === id); - interface Card { id: string; title: string; description: string; color: string; placement: { column: string; order: number } } const list = (): Card[] => [...cards.entries()] .filter((entry): entry is [string, Y.Map] => entry[1] instanceof Y.Map) .map(([id, card]) => ({ id, title: String(card.get("title") ?? ""), description: String(card.get("description") ?? ""), color: colors.includes(String(card.get("color"))) ? String(card.get("color")) : "neutral", placement: card.get("placement") as Card["placement"] | undefined })) - .filter((card): card is Card => !!card.placement && validColumn(card.placement.column) && Number.isFinite(card.placement?.order)) - .sort((a, b) => a.placement.order - b.placement.order || a.id.localeCompare(b.id)); - const nextOrder = (column: string) => Math.max(0, ...list().filter(card => card.placement.column === column) - .map(card => card.placement.order)) + 1; + .filter((card): card is Card => !!card.placement && validColumn(card.placement.column) && Number.isFinite(card.placement?.order) && + (card.placement.rank === undefined || (Array.isArray(card.placement.rank) && card.placement.rank.length > 0 && card.placement.rank.every(Number.isFinite)))) + .sort((a, b) => compare(position(a), position(b))); + function placement(column: string, items: Card[], index = items.length) { + const rank = between(items[index - 1] && position(items[index - 1]), items[index] && position(items[index])); + return { column, order: rank[0], rank }; + } function action(fn: () => void) { history.stopCapturing(); doc.transact(fn, origin); @@ -37,7 +72,7 @@ export function createBoardModel(doc: Y.Doc) { const id = crypto.randomUUID(); action(() => cards.set(id, new Y.Map([ ["title", "Untitled card"], ["description", ""], ["color", "neutral"], - ["placement", { column, order: nextOrder(column) }], + ["placement", placement(column, list().filter(card => card.placement.column === column))], ]))); return id; }, @@ -48,10 +83,17 @@ export function createBoardModel(doc: Y.Doc) { if (card.get(field) === value) return; doc.transact(() => card.set(field, value), origin); }, - move(id: string|undefined, column: string) { + move(id: string|undefined, column: string, beforeId?: string | null) { const card = cards.get(id ?? ""); - if (!(card instanceof Y.Map) || !validColumn(column) || card.get("placement")?.column === column) return; - action(() => card.set("placement", { column, order: nextOrder(column) })); + if (!(card instanceof Y.Map) || !validColumn(column) || beforeId === id) return; + const current = list().filter(item => item.placement.column === column); + const items = current.filter(item => item.id !== id); + const before = beforeId ? items.findIndex(item => item.id === beforeId) : -1; + const index = before < 0 ? items.length : before; + // Status controls keep the order when their selected column is unchanged. + if (card.get("placement")?.column === column && + (beforeId === undefined || current[index]?.id === id)) return; + action(() => card.set("placement", placement(column, items, index))); }, remove(id: string|undefined) { action(() => cards.delete(id ?? "")); }, destroy() { history.destroy(); }, diff --git a/examples/collaboration/package.json b/examples/collaboration/package.json index 56e1b84..9d0616e 100644 --- a/examples/collaboration/package.json +++ b/examples/collaboration/package.json @@ -16,6 +16,8 @@ "@codemirror/commands": "6.11.0", "@codemirror/state": "6.7.4", "@codemirror/view": "6.43.11", + "@dnd-kit/collision": "0.5.0", + "@dnd-kit/react": "0.5.0", "@synixir/client": "0.1.0", "@tailwindcss/postcss": "4.3.3", "@tiptap/core": "3.31.3", diff --git a/examples/collaboration/tests/kanban.spec.ts b/examples/collaboration/tests/kanban.spec.ts index 8a6d7db..bcd63fa 100644 --- a/examples/collaboration/tests/kanban.spec.ts +++ b/examples/collaboration/tests/kanban.spec.ts @@ -37,11 +37,13 @@ test("cards sync, show presence, move by drag, and undo preserves a teammate's e if (!grip) throw new Error("Expected a visible element"); const destination = await page.locator('[data-column="in-progress"] .column-heading').boundingBox(); if (!destination) throw new Error("Expected a visible element"); - // Use a gradual pointer gesture into the visible header. Scrolling the full - // tall column into view mid-gesture can cancel native HTML dragging. + // The dedicated handle uses dnd-kit pointer sensors, including empty columns. await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); await page.mouse.down(); + await page.mouse.move(grip.x + grip.width / 2 + 10, grip.y + grip.height / 2); + await expect(page.locator('[data-dragging="true"]').first()).toBeVisible(); await page.mouse.move(destination.x + destination.width / 2, destination.y + 20, { steps: 12 }); + await expect(page.locator('[data-column="in-progress"]')).toHaveClass(/drop-target/); await page.mouse.up(); await expect(peer.locator('[data-column="in-progress"]')).toContainText("Release checklist"); await page.getByRole("button", { name: "Undo", exact: true }).click(); @@ -116,10 +118,20 @@ test("viewers can inspect cards but cannot edit, and revocation freezes an open await expect(viewer.getByLabel("Title", { exact: true })).toBeEditable(); await setRole("viewer"); await expect(viewer.getByLabel("Title", { exact: true })).not.toBeEditable(); + await setRole("editor"); + await viewer.reload(); + await saved(viewer); + await viewer.getByRole("button", { name: "Move card: Read-only review", exact: true }).focus(); + await viewer.keyboard.press("Space"); + await expect(viewer.locator('[data-dragging="true"]').first()).toBeVisible(); + await setRole("viewer"); + await expect(viewer.locator('[data-dragging="true"]')).toHaveCount(0); + await expect(viewer.locator('[data-column="backlog"]')).toContainText("Read-only review"); + await expect(viewer.locator(".card-grip")).toBeDisabled(); } finally { await viewerContext.close(); } }); -test("board navigation stays on Kanban and the layout fits mobile", async ({ page, baseURL }) => { +test("board navigation stays on Kanban and the layout fits mobile", async ({ page, baseURL }, testInfo) => { await register(page.request, baseURL); await page.goto(`${baseURL}/kanban.html`); const roomId = `navigation-${randomUUID()}`; @@ -128,12 +140,14 @@ test("board navigation stays on Kanban and the layout fits mobile", async ({ pag await expect(page).toHaveURL(`${baseURL}/kanban.html?room=${roomId}`); await saved(page); await addCard(page, "Mobile planning"); + await page.screenshot({ path: testInfo.outputPath("kanban-desktop.png"), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await card(page, "Mobile planning").click(); await page.getByLabel("Status", { exact: true }).selectOption("done"); await page.getByRole("button", { name: "Close card", exact: true }).click(); await expect(page.locator('[data-column="done"]')).toContainText("Mobile planning"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath("kanban-mobile.png"), fullPage: true }); await expect(page.getByRole("link", { name: "Try the text editor" })).toHaveJSProperty("href", `${baseURL}/?room=${roomId}`); await page.getByRole("link", { name: "All rooms" }).click(); await expect(page).toHaveURL(`${baseURL}/kanban.html`); @@ -141,3 +155,65 @@ test("board navigation stays on Kanban and the layout fits mobile", async ({ pag await saved(page); await expect(card(page, "Mobile planning")).toBeVisible(); }); + + +test("keyboard sorting preserves peer edits during a drag and Escape cancels a move", async ({ page, context, baseURL }) => { + const { url } = await setup(page, baseURL); + await page.goto(url); + await saved(page); + await addCard(page, "First task"); + await addCard(page, "Second task"); + const peer = await context.newPage(); + await peer.goto(url); + await saved(peer); + const grip = page.getByRole("button", { name: "Move card: Second task", exact: true }); + await grip.focus(); + await page.keyboard.press("Space"); + await expect(page.locator('[data-dragging="true"]').first()).toBeVisible(); + await card(peer, "Second task").click(); + await peer.getByLabel("Description").fill("Updated while the card is moving."); + await peer.getByRole("button", { name: "Close card", exact: true }).click(); + await saved(peer); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("Space"); + const order = (target: Page) => target.locator('[data-column="backlog"] .card-title'); + await expect(order(peer)).toHaveText(["Second task", "First task"]); + await expect(card(page, "Second task")).toContainText("Updated while the card is moving."); + await grip.focus(); + await page.keyboard.press("Space"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Escape"); + await expect(order(page)).toHaveText(["Second task", "First task"]); + await expect(order(peer)).toHaveText(["Second task", "First task"]); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(order(peer)).toHaveText(["First task", "Second task"]); + await expect(card(page, "Second task")).toContainText("Updated while the card is moving."); +}); + +test("touch handles move cards to an empty column", async ({ page, context, baseURL }) => { + const { url } = await setup(page, baseURL); + await page.goto(url); + await saved(page); + await addCard(page, "Touch planning"); + const grip = await page.getByRole("button", { name: "Move card: Touch planning", exact: true }).boundingBox(); + const target = await page.locator('[data-column="in-progress"] .column-heading').boundingBox(); + if (!grip || !target) throw new Error("Expected a visible touch handle and drop column"); + const session = await context.newCDPSession(page); + const from = { x: grip.x + grip.width / 2, y: grip.y + grip.height / 2 }; + const to = { x: target.x + target.width / 2, y: target.y + target.height / 2 }; + await session.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [from] }); + // The touch sensor waits briefly before lifting a card so ordinary scrolling + // elsewhere in the board stays available. + await expect(page.locator('[data-dragging="true"]').first()).toBeVisible(); + for (let step = 1; step <= 12; step++) { + await session.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ + x: from.x + (to.x - from.x) * step / 12, + y: from.y + (to.y - from.y) * step / 12, + }] }); + } + await expect(page.locator('[data-column="in-progress"]')).toHaveClass(/drop-target/); + await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await expect(page.locator('[data-column="in-progress"]')).toContainText("Touch planning"); + await saved(page); + await session.detach(); +}); diff --git a/package-lock.json b/package-lock.json index 72d1813..28076a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "@codemirror/commands": "6.11.0", "@codemirror/state": "6.7.4", "@codemirror/view": "6.43.11", + "@dnd-kit/collision": "0.5.0", + "@dnd-kit/react": "0.5.0", "@synixir/client": "0.1.0", "@tailwindcss/postcss": "4.3.3", "@tiptap/core": "3.31.3", @@ -551,6 +553,77 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@dnd-kit/abstract": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/abstract/-/abstract-0.5.0.tgz", + "integrity": "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==", + "license": "MIT", + "dependencies": { + "@dnd-kit/geometry": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/collision": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/collision/-/collision-0.5.0.tgz", + "integrity": "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/geometry": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/dom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/dom/-/dom-0.5.0.tgz", + "integrity": "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/collision": "^0.5.0", + "@dnd-kit/geometry": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/geometry": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/geometry/-/geometry-0.5.0.tgz", + "integrity": "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==", + "license": "MIT", + "dependencies": { + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/react": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/react/-/react-0.5.0.tgz", + "integrity": "sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/dom": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@dnd-kit/state": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/state/-/state-0.5.0.tgz", + "integrity": "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.10.0", + "tslib": "^2.6.2" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -2046,6 +2119,16 @@ "node": ">=20" } }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", From 11a953631a944f70b93ae93312a00cc36fa7c1e6 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 00:37:15 +0800 Subject: [PATCH 04/12] feat(examples): build collaborative flowcharts with React Flow --- examples/collaboration/flowchart/README.md | 42 ++ examples/collaboration/flowchart/adapter.ts | 141 +++++ examples/collaboration/flowchart/canvas.css | 74 +-- examples/collaboration/flowchart/canvas.ts | 527 ------------------ examples/collaboration/flowchart/canvas.tsx | 211 +++++++ examples/collaboration/flowchart/geometry.ts | 5 +- .../collaboration/flowchart/model.test.ts | 79 +++ examples/collaboration/flowchart/model.ts | 20 +- examples/collaboration/flowchart/presence.ts | 104 ++++ examples/collaboration/package.json | 1 + .../collaboration/tests/flowchart.spec.ts | 52 +- package-lock.json | 241 ++++++++ 12 files changed, 932 insertions(+), 565 deletions(-) create mode 100644 examples/collaboration/flowchart/README.md create mode 100644 examples/collaboration/flowchart/adapter.ts delete mode 100644 examples/collaboration/flowchart/canvas.ts create mode 100644 examples/collaboration/flowchart/canvas.tsx create mode 100644 examples/collaboration/flowchart/presence.ts diff --git a/examples/collaboration/flowchart/README.md b/examples/collaboration/flowchart/README.md new file mode 100644 index 0000000..ff90992 --- /dev/null +++ b/examples/collaboration/flowchart/README.md @@ -0,0 +1,42 @@ +# Collaborative flowchart + +The canvas uses [`@xyflow/react` 12.11.6](https://reactflow.dev/) with custom +process, decision, and terminal nodes. React Flow supplies dragging, connection +handles, selection, resizing, pan/zoom controls, and a minimap. The inspector uses +the app's shadcn components and also provides keyboard-accessible connection and +size controls. The package and this integration require no React Flow Pro code. + +Add nodes with the toolbar. Drag an outgoing handle to another node's incoming +handle, or choose a destination in the inspector. Select an arrow to edit its +label or delete it. Select a node and drag a resize handle, or enter Width and +Height and press Enter. Arrow keys move a selected node one pixel; Shift moves it +ten. Delete removes the selection. The workspace's Undo/Redo buttons and +Control/Command+Z apply to local graph edits. Drag the background to pan, scroll +to zoom, or use the controls to fit the diagram. + +`model.ts` retains the existing `flowchart:nodes:v1` and `flowchart:edges:v1` Yjs +maps, so saved diagrams load without migration. Node properties are separate map +entries, and each edge has a deterministic source/target key. Concurrent edits to +independent properties merge without replacing the graph. Deleting a node and +its connections remains one undoable action. + +`adapter.ts` converts that model into controlled React Flow nodes and edges. +Measurements and selection stay local. Drag and resize previews do not write to +Yjs; releasing the pointer commits one action. Resizing from the top or left +commits position and dimensions together. A bottom-right resize preserves an +independent remote move. Read-only updates cancel pending gestures and guard all +mutation paths. + +`presence.ts` sends cursor, selected node, and drag previews through Synixir +awareness at most once per 60 ms. Remote drag positions interpolate over 80 ms, +respecting reduced motion. Arrows read the same React Flow node positions as the +canvas so they stay attached during interpolation. Viewports remain local. +Synixir continues to own room access, offline edits, reconnect, and persistence. + +The implementation follows the public [custom-node API](https://reactflow.dev/learn/customization/custom-nodes), +[controlled-flow API](https://reactflow.dev/api-reference/react-flow), and +[NodeResizer API](https://reactflow.dev/api-reference/components/node-resizer). +`model.test.ts` covers adapter persistence boundaries, concurrency, undo, and +permission changes. `tests/flowchart.spec.ts` covers two-browser synchronization, +handle connections, resizing, pointer motion, offline/restart behavior, and +mobile viewer permissions. diff --git a/examples/collaboration/flowchart/adapter.ts b/examples/collaboration/flowchart/adapter.ts new file mode 100644 index 0000000..e63f69b --- /dev/null +++ b/examples/collaboration/flowchart/adapter.ts @@ -0,0 +1,141 @@ +import type { Node, Edge, NodeChange, EdgeChange } from "@xyflow/react"; +import { MarkerType } from "@xyflow/react"; +import type { Point, Size } from "../lib/canvas-data.ts"; +import { createFlowchartModel } from "./model.ts"; + +export type FlowchartModel = ReturnType; +export type FlowNode = Node<{ text: string; kind: string; color: string; readOnly: boolean }, "shape">; +export type FlowEdge = Edge<{ reciprocal: boolean }, "arrow">; +export const kindLabel = (kind: string) => ({ process: "Process", decision: "Decision", terminal: "Start / End" })[kind] ?? "Node"; +type Preview = { position: Point; size: Size }; + +// React Flow owns transient interaction state; only completed gestures enter +// Yjs. This prevents mouse packets, measurements, and selection from becoming +// durable edits or individual undo steps. +export function createFlowchartAdapter(model: FlowchartModel) { + let readOnly = true; + let selectedId: string | null = null; + let selectedEdgeId: string | null = null; + let picking = false; + let gesture: { id: string; initial: Preview; current: Preview; kind: "drag" | "resize" } | undefined; + let remotePositions = new Map(); + const listeners = new Set<() => void>(); + const measured = new Map(); + const read = () => { + const items = model.list(), connections = model.connections(); + const ids = new Set(items.map(item => item.id)); + for (const id of measured.keys()) if (!ids.has(id)) measured.delete(id); + if (selectedId && !ids.has(selectedId)) { selectedId = null; picking = false; } + if (selectedEdgeId && !connections.some(edge => edge.id === selectedEdgeId)) selectedEdgeId = null; + if (gesture && !ids.has(gesture.id)) gesture = undefined; + const nodes: FlowNode[] = items.map(item => { + const preview = gesture?.id === item.id ? gesture.current : undefined; + const position = preview?.position ?? remotePositions.get(item.id) ?? item.position; + const size = preview?.size ?? item.size; + return { id: item.id, type: "shape", position: model.boundedPosition(position, size), + width: size.width, height: size.height, measured: measured.get(item.id), zIndex: item.order, + data: { text: item.text, kind: item.kind, color: item.color, readOnly }, + className: `flow-node object-${item.kind} color-${item.color}`, + ariaLabel: `${kindLabel(item.kind)}: ${item.text || "Untitled"}`, + selected: item.id === selectedId, draggable: !readOnly && !picking, connectable: !readOnly, + }; + }); + const edgeIds = new Set(connections.map(edge => edge.id)); + const edges: FlowEdge[] = connections.map(edge => ({ ...edge, type: "arrow", sourceHandle: "out", targetHandle: "in", + selected: edge.id === selectedEdgeId, + markerEnd: { type: MarkerType.ArrowClosed, width: 24, height: 24 }, + data: { reciprocal: edgeIds.has(JSON.stringify([edge.target, edge.source])) }, + })); + return { nodes, edges, items, connections, selectedId, selectedEdgeId, picking, readOnly, + canUndo: !readOnly && model.history.canUndo(), canRedo: !readOnly && model.history.canRedo() }; + }; + let snapshot = read(); + const refresh = () => { snapshot = read(); for (const listener of listeners) listener(); }; + model.objects.observeDeep(refresh); + model.edges.observeDeep(refresh); + const historyEvents = ["stack-item-added", "stack-item-popped", "stack-cleared", "stack-item-updated"] as const; + for (const event of historyEvents) model.history.on(event, refresh); + const selectEdge = (id: string | null) => { + model.history.stopCapturing(); selectedId = null; selectedEdgeId = id; picking = false; refresh(); + }; + const connect = (source: string, target: string, label = "") => { + if (readOnly) return; + const id = model.connect(source, target, label); + if (id) selectEdge(id); + }; + const selectNode = (id: string | null) => { + if (picking && selectedId && id && selectedId !== id && !readOnly) { connect(selectedId, id); return; } + model.history.stopCapturing(); selectedId = id; selectedEdgeId = null; picking = false; refresh(); + }; + return { + model, + getSnapshot: () => snapshot, + subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, + selectNode, selectEdge, connect, + setReadOnly(value: boolean) { + readOnly = value; + if (value) { gesture = undefined; picking = false; } + refresh(); + }, + setRemotePositions(positions: Map) { remotePositions = positions; refresh(); }, + togglePicking() { if (!readOnly) { picking = !picking; refresh(); } }, + beginGesture(id: string, kind: "drag" | "resize") { + if (readOnly) return; + const item = model.list().find(item => item.id === id); + if (!item) return; + selectedId = id; selectedEdgeId = null; picking = false; + gesture = { id, kind, initial: { position: item.position, size: item.size }, current: { position: item.position, size: item.size } }; + model.history.stopCapturing(); refresh(); + }, + preview() { return gesture; }, + nodeChanges(changes: NodeChange[]) { + for (const change of changes) { + // Keep measurements locally. React Flow resets handle bounds when a + // controlled node loses its measured field, which hides its edges. + if (change.type === "dimensions" && change.dimensions) measured.set(change.id, change.dimensions); + // Click callbacks also handle the canvas target picker. + if (change.type === "select" && change.selected && !picking) { selectedId = change.id; selectedEdgeId = null; } + if (readOnly || !gesture || !("id" in change) || change.id !== gesture.id) continue; + if (change.type === "position" && change.position) gesture.current = { ...gesture.current, position: change.position }; + if (change.type === "dimensions" && change.resizing && change.dimensions) gesture.current = { ...gesture.current, size: change.dimensions }; + } + refresh(); + }, + edgeChanges(changes: EdgeChange[]) { + for (const change of changes) if (change.type === "select" && change.selected) selectEdge(change.id); + }, + finishGesture(id: string, bounds?: Preview) { + if (readOnly || gesture?.id !== id) return; + const completed = gesture; + const next = bounds ?? completed.current; + gesture = undefined; + if (completed.kind === "drag") model.move(id, next.position); + else { + // A bottom/right resize must preserve a concurrent remote move. + const moved = next.position.x !== completed.initial.position.x || next.position.y !== completed.initial.position.y; + model.reshape(id, next.size, moved ? next.position : undefined); + } + refresh(); + }, + cancelGesture() { gesture = undefined; refresh(); }, + add(kind: string, position: Point) { + if (readOnly) return; + const id = model.add(kind, position); if (id) selectNode(id); return id; + }, + edit(id: string, field: string, value: string) { if (!readOnly) model.edit(id, field, value); }, + label(id: string, value: string) { if (!readOnly) model.label(id, value); }, + resize(id: string, size: Size) { if (!readOnly) model.resize(id, size); }, + move(id: string, position: Point) { if (!readOnly) model.move(id, position); }, + front(id: string) { if (!readOnly) model.front(id); }, + remove(id: string) { if (!readOnly) model.remove(id); }, + disconnect(id: string) { if (!readOnly) model.disconnect(id); }, + undo() { if (!readOnly) { gesture = undefined; model.history.undo(); refresh(); } }, + redo() { if (!readOnly) { gesture = undefined; model.history.redo(); refresh(); } }, + destroy() { + model.objects.unobserveDeep(refresh); model.edges.unobserveDeep(refresh); + for (const event of historyEvents) model.history.off(event, refresh); + listeners.clear(); model.destroy(); + }, + }; +} +export type FlowchartAdapter = ReturnType; diff --git a/examples/collaboration/flowchart/canvas.css b/examples/collaboration/flowchart/canvas.css index 5c3b9fd..56f9c91 100644 --- a/examples/collaboration/flowchart/canvas.css +++ b/examples/collaboration/flowchart/canvas.css @@ -1,33 +1,43 @@ -.flowchart-page .flow-node { background: transparent; border: 0; overflow: visible; font-size: 17px; padding: 16px; } -.flowchart-page .flow-node::before { content: ""; position: absolute; inset: 0; background: inherit; border: 1.5px solid #819b8d; border-radius: 10px; z-index: -1; box-shadow: 0 3px 8px #28332c12; } -.flowchart-page .flow-node { isolation: isolate; background: none !important; } -.flowchart-page .flow-node.color-blue::before { background: #d6e7ff; } -.flowchart-page .flow-node.color-yellow::before { background: #fff0a4; } -.flowchart-page .flow-node.color-mint::before { background: #d5eedd; } -.flowchart-page .flow-node.color-rose::before { background: #f9dbe3; } -.flowchart-page .flow-node.color-white::before { background: #fff; } -.flowchart-page .object-terminal::before { border-radius: 999px; } -.flowchart-page .object-decision::before { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); border-radius: 0; } -.flowchart-page .object-decision { padding: 30px 45px; } -.flow-node > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; } -.flowchart-page .object-layer { pointer-events: none; z-index: 1; } -.flowchart-page .flow-node { pointer-events: auto; } -.edge-layer { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } -.flow-edge { fill: none; stroke: #648172; stroke-width: 2; } -.flow-edge-hit { fill: none; stroke: transparent; stroke-width: 18; cursor: pointer; } -.edge-layer .is-selected .flow-edge { stroke: #216640; stroke-width: 3; } -.flow-edge-label { font: 13px system-ui, sans-serif; fill: #375745; text-anchor: middle; paint-order: stroke; stroke: #fafcfb; stroke-width: 6; stroke-linejoin: round; cursor: pointer; } -.flowchart-page .is-connecting .flow-node { cursor: crosshair; } -.flowchart-page .is-connecting .flow-node:hover { outline: 3px dashed #285c43; outline-offset: 4px; } -.connect-fields { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; border-top: 1px solid #d6e0d9; margin-top: 16px; padding-top: 16px; } -.connect-fields > div { flex: 1 1 170px; min-width: 0; } -.connect-fields input, .connect-fields select { width: 100%; } -#edge-label { max-width: 450px; } -.flow-connections { padding: 18px; border-top: 1px solid #d6e0d9; } +/* Shape rendering and the inspector are app UI; React Flow owns the canvas. */ +.flowchart-editor { color: var(--foreground); background: var(--card); min-width: 0; } +.flowchart-editor #whiteboard-viewport { height: 520px; overflow: hidden; background: var(--background); } +.flowchart-editor .react-flow { --xy-background-color: var(--background); --xy-node-color: var(--foreground); --xy-edge-stroke: var(--muted-foreground); --xy-edge-stroke-selected: var(--primary); --xy-controls-button-background-color: var(--card); --xy-controls-button-background-color-hover: var(--muted); --xy-controls-button-color: var(--foreground); --xy-controls-button-border-color: var(--border); --xy-minimap-background-color: var(--card); --xy-minimap-mask-background-color: color-mix(in srgb, var(--background) 65%, transparent); } +.flowchart-editor .flow-node { padding: 0; background: transparent; border: 0; font-size: 17px; color: #223e33; } +.flowchart-editor .flow-node.selected, .flowchart-editor .flow-node:focus-visible { outline: 2px solid var(--ring); outline-offset: 5px; } +.flow-shape { display: grid; place-items: center; width: 100%; height: 100%; padding: 16px; border: 1.5px solid #819b8d; border-radius: 10px; background: var(--flow-node-fill); box-shadow: 0 3px 8px #28332c12; } +.flowchart-editor .color-blue { --flow-node-fill: #d6e7ff; } +.flowchart-editor .color-yellow { --flow-node-fill: #fff0a4; } +.flowchart-editor .color-mint { --flow-node-fill: #d5eedd; } +.flowchart-editor .color-rose { --flow-node-fill: #f9dbe3; } +.flowchart-editor .color-white { --flow-node-fill: #fff; } +.flow-shape-terminal { border-radius: 999px; } +.flow-shape-decision { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); border-radius: 0; padding: 30px 45px; } +.flow-shape > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; text-align: center; line-height: 1.45; } +.flowchart-editor .react-flow__handle { width: 10px; height: 10px; background: var(--primary); border: 2px solid var(--card); } +.flowchart-editor .react-flow__resize-control.handle { width: 9px; height: 9px; border-radius: 2px; } +.flowchart-editor .react-flow__controls-button { display: flex; justify-content: center; align-items: center; width: 30px; height: 30px; padding: 7px; border-radius: 0; color: var(--foreground); background: var(--card); } +.flowchart-editor .react-flow__controls-button:hover { background: var(--muted); } +.flowchart-editor .react-flow__controls-button svg { width: 16px; height: 16px; } +.flowchart-editor .flow-edge { fill: none; stroke: var(--muted-foreground); stroke-width: 2; } +.flowchart-editor .flow-edge.is-selected { stroke: var(--primary); stroke-width: 3; } +.flowchart-editor .flow-edge-label { font: 13px system-ui, sans-serif; fill: var(--foreground); text-anchor: middle; paint-order: stroke; stroke: var(--background); stroke-width: 6; stroke-linejoin: round; cursor: pointer; } +.flowchart-editor .is-connecting .flow-node { cursor: crosshair; } +.flowchart-editor .is-connecting .flow-node:hover { outline: 3px dashed var(--ring); outline-offset: 4px; } +.flow-presence { position: absolute; inset: 0; pointer-events: none; z-index: 1000; } +.flowchart-editor .flow-empty { position: absolute; left: 0; top: 48px; max-width: 360px; pointer-events: none; } +.flowchart-editor .flow-inspector { background: var(--card); } +.flow-inspector .inspector-fields > div, .flow-inspector .connect-fields > div { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +.flow-inspector .inspector-fields textarea { color: var(--foreground); background: var(--background); border-color: var(--input); } +.flow-inspector .inspector-fields textarea:read-only { background: var(--muted); } +.flow-inspector .connect-fields { display: flex; flex-direction: row; flex-wrap: wrap; align-items: end; gap: 12px; border-top: 1px solid var(--border); margin-top: 16px; padding-top: 16px; } +.flow-inspector .connect-fields > div { flex: 1 1 170px; } +.flow-inspector .connect-fields input, .flow-inspector .connect-fields select { width: 100%; } +.flow-inspector #edge-label { max-width: 450px; margin-top: 8px; } +.flow-connections { padding: 18px; border-top: 1px solid var(--border); } .flow-connections h2 { margin: 0 0 8px; } -.flow-connections p { color: #52695b; font-size: 14px; margin: 0; } -#connection-list { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 0; } -#connection-list li { max-width: 100%; } -#connection-list button { text-align: left; overflow-wrap: anywhere; } -#connection-list [aria-pressed="true"] { background: #deeee4; border-color: #285c43; } -.flowchart-page #delete-connection { color: #9d3838; } +.flow-connections p { color: var(--muted-foreground); font-size: 14px; margin: 0; } +.flow-connections #connection-list { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 0; } +.flow-connections #connection-list li { max-width: 100%; } +.flow-connections #connection-list button { text-align: left; overflow-wrap: anywhere; white-space: normal; height: auto; min-height: 32px; } +.flow-connections #connection-list [aria-pressed="true"] { background: var(--muted); border-color: var(--ring); } +@media (max-width: 640px) { .flowchart-editor #whiteboard-viewport { height: 440px; } .flowchart-editor .react-flow__minimap { display: none; } } diff --git a/examples/collaboration/flowchart/canvas.ts b/examples/collaboration/flowchart/canvas.ts deleted file mode 100644 index 28f0254..0000000 --- a/examples/collaboration/flowchart/canvas.ts +++ /dev/null @@ -1,527 +0,0 @@ -import type { SynixirRoom } from "@synixir/client"; -import { createFlowchartModel, canvasSize, colors } from "./model.ts"; -import { connectionPath } from "./geometry.ts"; -import { createMotion } from "../whiteboard/motion.ts"; - -export function createFlowchart(room: SynixirRoom) { - const kindLabel = (kind: string) => ({ process: "Process", decision: "Decision", terminal: "Start / End" } as Record)[kind] ?? "Node"; - const root = document.querySelector("#editor")!; - document.querySelector("#open-peer")!.href = window.location.href; - const model = createFlowchartModel(room.doc); - const painted = new Map(); - const edgeNodes = new Map(); - let edgeFrame: number|undefined; - const motion = createMotion({ onPaint(node: HTMLElement, point: import("../lib/canvas-data.ts").Point) { - if (node.dataset.objectId) { - painted.set(node.dataset.objectId, point); - // Paint in the same frame as the node so arrows stay attached throughout - // interpolation, even when no new presence packet has arrived. - paintEdges(); - } - } }); - let items = model.list(); - let activityFrame: number|undefined; - const events = new AbortController(); - const on = (target: EventTarget, event: K, handler: (event: DocumentEventMap[K]) => void, capture = false) => target.addEventListener(event, handler as EventListener, { signal: events.signal, capture }); - const nodes = new Map(); - const peerNodes = new Map(); - let readOnly = true; - let selectedId: string|null = null; - let selectedEdgeId: string|null = null; - let connecting = false; - let drag: { moved: any; id: any; position: any; pointerId: any; start: any; original: any; size: any; }|null = null; - let cursor: { x: number; y: number; }|null = null; - let zoom = 1; - let presenceTimer: string|number|NodeJS.Timeout|undefined; - let lastPresenceAt = -Infinity; - let disposed = false; - const undo = document.querySelector("#undo")!; - const redo = document.querySelector("#redo")!; - const announce = (text: string|null) => { document.querySelector("#board-announcement")!.textContent = text; }; - root.innerHTML = ` -
-
-
100%
-
-
-
Every process starts somewhere.Add a step, then connect it to the next one.
-
-
0 nodesScroll to explore · Select a node to edit or connect
-
-

Select a node to edit it, or an arrow to label a branch.

- - -
-

Connections

Connect two nodes to show what happens next.

    `; - const viewport = root.querySelector("#whiteboard-viewport")!; - const sizer = root.querySelector(".canvas-sizer")!; - const canvas = root.querySelector(".whiteboard-canvas")!; - const layer = root.querySelector(".object-layer")!; - const presenceLayer = root.querySelector(".presence-layer")!; - const edgeLayer = root.querySelector(".edges")!; - const targetInput = root.querySelector("#connection-target")!; - const branchInput = root.querySelector("#connection-label")!; - const edgeLabel = root.querySelector("#edge-label")!; - const connectionList = root.querySelector("#connection-list")!; - const svg = (tag: string) => document.createElementNS("http://www.w3.org/2000/svg", tag); - const textInput = root.querySelector("#object-text")!; - const colorInput = root.querySelector("#object-color")!; - const widthInput = root.querySelector("#object-width")!; - const heightInput = root.querySelector("#object-height")!; - canvas.style.width = `${canvasSize.width}px`; - canvas.style.height = `${canvasSize.height}px`; - for (const color of colors) { - const option = document.createElement("option"); - option.value = color; - option.textContent = color[0].toUpperCase() + color.slice(1); - colorInput.append(option); - } - const selected = () => items.find(item => item.id === selectedId); - const validPoint = (point: { x: any; y: any; }) => point && Number.isFinite(point.x) && Number.isFinite(point.y) && - point.x >= 0 && point.y >= 0 && point.x <= canvasSize.width && point.y <= canvasSize.height; - const worldPoint = (event: { clientX: number; clientY: number; }) => { - const rect = canvas.getBoundingClientRect(); - return { x: (event.clientX - rect.left) / zoom, y: (event.clientY - rect.top) / zoom }; - }; - function publish() { - clearTimeout(presenceTimer); - presenceTimer = undefined; - if (disposed) return; - lastPresenceAt = performance.now(); - room.awareness.setLocalStateField("flowchart", { cursor, selectedId, - drag: drag?.moved ? { id: drag.id, ...drag.position } : null }); - } - function schedulePresence() { - if (presenceTimer !== undefined) return; - const remaining = 60 - (performance.now() - lastPresenceAt); - if (remaining <= 0) publish(); - else presenceTimer = setTimeout(publish, remaining); - } - function select(id: string|null|undefined) { - connecting = false; - selectedEdgeId = null; - selectedId = id ?? null; - model.history.stopCapturing(); - render(); - publish(); - } - function cancelDrag() { - const previous = drag; - drag = null; - if (previous && viewport.hasPointerCapture(previous.pointerId)) viewport.releasePointerCapture(previous.pointerId); - publish(); - scheduleActivity(); - } - function setZoom(value: number) { - cancelDrag(); - const center = { x: (viewport.scrollLeft + viewport.clientWidth / 2) / zoom, - y: (viewport.scrollTop + viewport.clientHeight / 2) / zoom }; - zoom = Math.min(1.5, Math.max(0.5, value)); - canvas.style.transform = `scale(${zoom})`; - sizer.style.width = `${canvasSize.width * zoom}px`; - sizer.style.height = `${canvasSize.height * zoom}px`; - viewport.scrollLeft = center.x * zoom - viewport.clientWidth / 2; - viewport.scrollTop = center.y * zoom - viewport.clientHeight / 2; - root.querySelector("#zoom-level")!.textContent = `${Math.round(zoom * 100)}%`; - root.querySelector("#zoom-out")!.disabled = zoom <= 0.5; - root.querySelector("#zoom-in")!.disabled = zoom >= 1.5; - cursor = null; - publish(); - } - on(root.querySelector("#zoom-in")!, "click", () => setZoom(zoom + 0.25)); - on(root.querySelector("#zoom-out")!, "click", () => setZoom(zoom - 0.25)); - on(root.querySelector("#zoom-reset")!, "click", () => setZoom(1)); - root.querySelectorAll("[data-add]").forEach(button => on(button, "click", () => { - if (readOnly) return; - cancelDrag(); - const columns = Math.max(1, Math.floor(viewport.clientWidth / zoom / 260)); - const index = model.list().length; - const id = model.add(button.dataset.add, { x: viewport.scrollLeft / zoom + 50 + index % columns * 260, - y: viewport.scrollTop / zoom + 60 + Math.floor(index / columns) % 3 * 230 }); - select(id); - textInput.focus(); - textInput.select(); - announce("Node added."); - })); - on(viewport, "pointerdown", (event) => { - if (event.button !== 0 || !event.isPrimary) return; - const edge = (event.target as Element).closest("[data-edge-id]"); - if (edge) { selectEdge(edge.dataset.edgeId!); return; } - const node = (event.target as Element).closest(".whiteboard-object"); - if (connecting && node && !readOnly) { - event.preventDefault(); - completeConnection(node.dataset.objectId!); - return; - } - if (!node) { select(null); return; } - event.preventDefault(); - node.focus({ preventScroll: true }); - select(node.dataset.objectId!); - const item = selected(); - if (readOnly || !item) return; - const point = worldPoint(event); - drag = { id: item.id, pointerId: event.pointerId, start: point, original: item.position, - position: item.position, size: item.size, moved: false }; - viewport.setPointerCapture(event.pointerId); - }); - on(viewport, "pointermove", (event) => { - const point = worldPoint(event); - cursor = validPoint(point) ? point : null; - if (drag && event.pointerId === drag.pointerId && !readOnly) { - if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) > 2) drag.moved = true; - drag.position = model.boundedPosition({ x: drag.original.x + point.x - drag.start.x, - y: drag.original.y + point.y - drag.start.y }, drag.size); - scheduleActivity(); - } - schedulePresence(); - }); - on(viewport, "pointerup", (event) => { - if (!drag || event.pointerId !== drag.pointerId) return; - const completed = drag; - drag = null; - if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId); - if (!readOnly && completed.moved) { - model.move(completed.id, completed.position); - announce("Node moved."); - } - publish(); - scheduleActivity(); - }); - on(viewport, "pointercancel", cancelDrag); - on(viewport, "lostpointercapture", () => { if (drag) cancelDrag(); }); - on(viewport, "pointerleave", () => { cursor = null; publish(); }); - on(viewport, "scroll", () => { cursor = null; schedulePresence(); }); - on(window, "blur", () => { cursor = null; cancelDrag(); }); - on(document, "visibilitychange", () => { if (document.hidden) { cursor = null; cancelDrag(); } }); - on(viewport, "click", (event) => { - const node = (event.target as Element).closest(".whiteboard-object"); - if (node && event.detail === 0) { - if (connecting && !readOnly) completeConnection(node.dataset.objectId!); - else select(node.dataset.objectId!); - } - }); - on(root, "keydown", (event) => { - if (event.key === "Escape") { - if (drag) cancelDrag(); - else select(null); - viewport.focus({ preventScroll: true }); - return; - } - if (readOnly || event.isComposing) return; - if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "z") { - event.preventDefault(); - cancelDrag(); - if (event.shiftKey) model.history.redo(); else model.history.undo(); - return; - } - if (event.target !== viewport && !(event.target as Element).closest(".whiteboard-object")) return; - if (selectedEdgeId && ["Delete", "Backspace"].includes(event.key)) { - event.preventDefault(); model.disconnect(selectedEdgeId); return; - } - const item = selected(); - if (!item) return; - if (["Delete", "Backspace"].includes(event.key)) { event.preventDefault(); model.remove(item.id); return; } - const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; - if (delta) { - event.preventDefault(); - const step = event.shiftKey ? 10 : 1; - model.move(item.id, { x: item.position.x + delta[0] * step, y: item.position.y + delta[1] * step }); - } - }); - for (const [input, field] of [[textInput, "text"], [colorInput, "color"]] as const) { - on(input, "focus", () => model.history.stopCapturing()); - on(input, "blur", () => model.history.stopCapturing()); - on(input, "input", () => { if (!readOnly && selectedId) model.edit(selectedId, field, input.value); }); - } - for (const input of [widthInput, heightInput]) on(input, "change", () => { - if (!readOnly && selectedId) model.resize(selectedId, { width: widthInput.valueAsNumber, height: heightInput.valueAsNumber }); - renderInspector(); - }); - on(root.querySelector("#bring-front")!, "click", () => { if (!readOnly && selectedId) model.front(selectedId); }); - on(root.querySelector("#delete-object")!, "click", () => { if (!readOnly && selectedId) model.remove(selectedId); }); - on(undo, "click", () => { if (!readOnly) { cancelDrag(); model.history.undo(); } }); - on(redo, "click", () => { if (!readOnly) { cancelDrag(); model.history.redo(); } }); - - function selectEdge(id: string) { - select(null); - selectedEdgeId = id; - render(); - } - function completeConnection(target: string) { - if (readOnly || !selectedId) return; - if (target === selectedId) { announce("Choose a different node."); return; } - const id = model.connect(selectedId, target, branchInput.value); - if (id) { branchInput.value = ""; selectEdge(id); announce("Nodes connected."); } - } - on(root.querySelector("#add-connection")!, "click", () => completeConnection(targetInput.value)); - on(root.querySelector("#pick-target")!, "click", () => { - if (readOnly) return; - connecting = !connecting; - renderInspector(); - if (connecting) { viewport.focus({ preventScroll: true }); announce("Choose the next node."); } - }); - on(edgeLabel, "focus", () => model.history.stopCapturing()); - on(edgeLabel, "blur", () => model.history.stopCapturing()); - on(edgeLabel, "input", () => { if (!readOnly && selectedEdgeId) model.label(selectedEdgeId, edgeLabel.value); }); - on(root.querySelector("#delete-connection")!, "click", () => { if (!readOnly && selectedEdgeId) model.disconnect(selectedEdgeId); }); - on(connectionList, "click", (event) => { - const button = (event.target as Element).closest("[data-edge-id]"); - if (button) selectEdge(button.dataset.edgeId!); - }); - function scheduleEdges() { - if (!disposed && edgeFrame === undefined) edgeFrame = requestAnimationFrame(paintEdges); - } - function paintEdges() { - if (edgeFrame !== undefined) cancelAnimationFrame(edgeFrame); - edgeFrame = undefined; - if (disposed) return; - const byId = new Map(items.map(item => [item.id, { ...item, position: painted.get(item.id) ?? item.position }])); - for (const edge of model.connections()) { - const pair = edgeNodes.get(edge.id); - if (!pair) continue; - const reverseId = JSON.stringify([edge.target, edge.source]); - const geometry = connectionPath(byId.get(edge.source)!, byId.get(edge.target)!, { reciprocal: edgeNodes.has(reverseId) }); - for (const path of [pair.path, pair.hit]) path.setAttribute("d", geometry.path); - pair.label.setAttribute("x", geometry.label.x); - pair.label.setAttribute("y", geometry.label.y - 10); - } - } - function syncConnections() { - const edges = model.connections(); - const present = new Set(edges.map(edge => edge.id)); - for (const [id, pair] of edgeNodes) if (!present.has(id)) { - pair.group.remove(); pair.button.parentElement.remove(); edgeNodes.delete(id); - } - const title = (id: string) => items.find(item => item.id === id)?.text || "Untitled"; - for (const edge of edges) { - let pair = edgeNodes.get(edge.id); - if (!pair) { - const group = svg("g"), path = svg("path"), hit = svg("path"), label = svg("text"); - group.dataset.edgeId = edge.id; - path.classList.add("flow-edge"); path.setAttribute("marker-end", "url(#flow-arrow)"); - hit.classList.add("flow-edge-hit"); label.classList.add("flow-edge-label"); - group.append(path, hit, label); edgeLayer.append(group); - const li = document.createElement("li"), button = document.createElement("button"); - button.type = "button"; button.dataset.edgeId = edge.id; button.className = "subtle"; - li.append(button); connectionList.append(li); - pair = { group, path, hit, label, button }; edgeNodes.set(edge.id, pair); - } - pair.group.classList.toggle("is-selected", selectedEdgeId === edge.id); - pair.label.textContent = edge.label; - pair.button.textContent = `${title(edge.source)} → ${title(edge.target)}${edge.label ? ` · ${edge.label}` : ""}`; - pair.button.setAttribute("aria-pressed", String(selectedEdgeId === edge.id)); - } - root.querySelector("#connections-empty")!.hidden = edges.length > 0; - scheduleEdges(); - } - - function peers() { - if (room.state.connection !== "connected") return []; - return [...room.awareness.getStates()].filter(([id, state]) => id !== room.awareness.clientID && state.flowchart) - .sort(([a], [b]) => a - b).map(([id, state]) => ({ id, ...state.flowchart, - name: typeof state.user?.name === "string" ? state.user.name.slice(0, 32) : "Guest", - color: /^#[0-9a-f]{6}$/i.test(state.user?.color) ? state.user.color : "#3565b0" })); - } - function scheduleActivity() { - if (!disposed && activityFrame === undefined) activityFrame = requestAnimationFrame(renderActivity); - } - function syncObjects() { - const present = new Set(items.map(item => item.id)); - for (const [id, node] of nodes) if (!present.has(id)) { - motion.forget(node); - node.remove(); - nodes.delete(id); - painted.delete(id); - } - items.forEach((item, index) => { - let node = nodes.get(item.id); - if (!node) { - node = document.createElement("button"); - node.type = "button"; - node.dataset.objectId = item.id; - nodes.set(item.id, node); - layer.append(node); - } - node.className = `whiteboard-object flow-node object-${item.kind} color-${item.color}`; - node.classList.toggle("is-selected", selectedId === item.id); - node.setAttribute("aria-pressed", String(selectedId === item.id)); - node.setAttribute("aria-label", `${kindLabel(item.kind)}: ${item.text || "Untitled"}`); - if (!node.firstChild) node.append(document.createElement("span")); - if (node.firstChild.textContent !== item.text) node.firstChild.textContent = item.text; - Object.assign(node.style, { width: `${item.size.width}px`, height: `${item.size.height}px`, zIndex: String(index) }); - }); - root.querySelector(".canvas-empty")!.hidden = items.length > 0; - const count = `${items.length} ${items.length === 1 ? "node" : "nodes"}`; - if (root.querySelector("#object-count")!.textContent !== count) root.querySelector("#object-count")!.textContent = count; - } - function renderActivity() { - activityFrame = undefined; - if (disposed) return; - const remote = peers(); - const displayed = new Map(); - for (const item of items) { - const node = nodes.get(item.id); - if (!node) continue; - const ownDrag = drag?.id === item.id; - const remoteDrag = remote.findLast((peer: { drag: { id: string; x: number; y: number }; }) => peer.drag?.id === item.id && validPoint(peer.drag))?.drag; - const preview = ownDrag && drag ? drag.position : remoteDrag; - const position = preview ? model.boundedPosition(preview, item.size) : item.position; - const smooth = !ownDrag && (Boolean(remoteDrag) || motion.moving(node)); - motion.move(node, position, smooth); - displayed.set(item.id, { position, smooth }); - } - const presentPeers = new Set(remote.map(peer => peer.id)); - for (const [id, pair] of peerNodes) if (!presentPeers.has(id)) { - motion.forget(pair.ring); - motion.forget(pair.pointer); - pair.ring?.remove(); - pair.pointer?.remove(); - peerNodes.delete(id); - } - for (const peer of remote) { - let pair = peerNodes.get(peer.id); - if (!pair) { pair = {}; peerNodes.set(peer.id, pair); } - const item = items.find(item => item.id === peer.selectedId); - if (item) { - if (!pair.ring) { - pair.ring = document.createElement("div"); - pair.ring.className = "remote-selection"; - pair.ring.append(document.createElement("span")); - presenceLayer.append(pair.ring); - } - const node = nodes.get(item.id); - const ring = pair.ring; - if (ring.dataset.peerSelection !== item.id) motion.forget(ring); - ring.dataset.peerSelection = item.id; - const display = displayed.get(item.id); - motion.move(ring, display.position, display.smooth); - Object.assign(ring.style, { width: node.style.width, height: node.style.height, borderColor: peer.color }); - const label = ring.firstChild; - if (label.textContent !== peer.name) label.textContent = peer.name; - label.style.background = peer.color; - } else { motion.forget(pair.ring); pair.ring?.remove(); pair.ring = null; } - if (validPoint(peer.cursor)) { - if (!pair.pointer) { - pair.pointer = document.createElement("div"); - pair.pointer.className = "remote-cursor"; - const arrow = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - arrow.setAttribute("viewBox", "0 0 28 28"); - arrow.setAttribute("aria-hidden", "true"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", "M1.5 1.5 26.5 10.5 16 15 11 26.5Z"); - arrow.append(path); - pair.pointer.append(arrow, document.createElement("span")); - presenceLayer.append(pair.pointer); - } - const pointer = pair.pointer; - motion.move(pointer, peer.cursor); - pointer.style.color = peer.color; - const label = pointer.lastChild; - if (label.textContent !== peer.name) label.textContent = peer.name; - label.style.background = peer.color; - } else { motion.forget(pair.pointer); pair.pointer?.remove(); pair.pointer = null; } - } - } - - function renderInspector() { - const item = selected(); - root.querySelector("#selection-fields")!.hidden = !item; - root.querySelector("#selection-empty")!.hidden = Boolean(item || selectedEdgeId); - root.querySelector("#edge-fields")!.hidden = !selectedEdgeId; - const edge = model.connections().find(edge => edge.id === selectedEdgeId); - if (edge && edgeLabel.value !== edge.label) edgeLabel.value = edge.label; - edgeLabel.readOnly = readOnly; - root.querySelector("#delete-connection")!.disabled = readOnly; - root.querySelector("#pick-target")!.textContent = connecting ? "Cancel connection" : "Pick on canvas"; - root.querySelector("#pick-target")!.setAttribute("aria-pressed", String(connecting)); - viewport.classList.toggle("is-connecting", connecting); - root.querySelector("#canvas-help")!.textContent = connecting ? "Choose the next node · Escape to cancel" : "Scroll to explore · Select a node to edit or connect"; - if (!item) return; - const previousTarget = targetInput.value; - targetInput.replaceChildren(...items.filter(node => node.id !== item.id).map(node => { - const option = document.createElement("option"); option.value = node.id; option.textContent = node.text || kindLabel(node.kind); return option; - })); - if ([...targetInput.options].some(option => option.value === previousTarget)) targetInput.value = previousTarget; - for (const input of [targetInput, branchInput, root.querySelector("#add-connection")!, root.querySelector("#pick-target")!]) input.disabled = readOnly || !targetInput.options.length; - root.querySelector("#selection-title")!.textContent = kindLabel(item.kind); - if (textInput.value !== item.text) { - const start = textInput.selectionStart; - const end = textInput.selectionEnd; - textInput.value = item.text; - if (document.activeElement === textInput) textInput.setSelectionRange(Math.min(start, item.text.length), Math.min(end, item.text.length)); - } - colorInput.value = item.color; - widthInput.value = String(item.size.width); - heightInput.value = String(item.size.height); - textInput.readOnly = readOnly; - for (const input of [colorInput, widthInput, heightInput, root.querySelector("#bring-front")!, root.querySelector("#delete-object")!]) input.disabled = readOnly; - } - function renderHistory() { - undo.disabled = readOnly || !model.history.canUndo(); - redo.disabled = readOnly || !model.history.canRedo(); - } - function render() { - if (disposed) return; - items = model.list(); - if (selectedId && !items.some(item => item.id === selectedId)) { - selectedId = null; - connecting = false; - cancelDrag(); - publish(); - viewport.focus({ preventScroll: true }); - announce("The selected node was removed."); - } - if (selectedEdgeId && !model.connections().some(edge => edge.id === selectedEdgeId)) selectedEdgeId = null; - syncObjects(); - syncConnections(); - scheduleActivity(); - renderInspector(); - renderHistory(); - } - model.objects.observeDeep(render); - model.edges.observeDeep(render); - room.awareness.on("change", scheduleActivity); - for (const event of ["stack-item-added", "stack-item-popped", "stack-cleared", "stack-item-updated"] as const) model.history.on(event, renderHistory); - setZoom(1); - render(); - function destroy() { - disposed = true; - clearTimeout(presenceTimer); - if (activityFrame !== undefined) cancelAnimationFrame(activityFrame); - if (edgeFrame !== undefined) cancelAnimationFrame(edgeFrame); - motion.destroy(); - peerNodes.clear(); - events.abort(); - model.objects.unobserveDeep(render); - model.edges.unobserveDeep(render); - room.awareness.off("change", scheduleActivity); - model.destroy(); - nodes.clear(); - root.replaceChildren(); - } - destroy.setReadOnly = (value: boolean) => { - if (readOnly !== value) { - readOnly = value; - if (readOnly) { connecting = false; cancelDrag(); } - root.querySelectorAll("[data-add]").forEach(button => { button.disabled = readOnly; }); - render(); - } - scheduleActivity(); - }; - return destroy; -} diff --git a/examples/collaboration/flowchart/canvas.tsx b/examples/collaboration/flowchart/canvas.tsx new file mode 100644 index 0000000..8807966 --- /dev/null +++ b/examples/collaboration/flowchart/canvas.tsx @@ -0,0 +1,211 @@ +import { createContext, memo, useCallback, useContext, useRef, useState, useSyncExternalStore } from "react"; +import { createRoot } from "react-dom/client"; +import { + Background, BaseEdge, Controls, Handle, MiniMap, NodeResizer, Position, ReactFlow, ReactFlowProvider, ViewportPortal, + useReactFlow, useViewport, useInternalNode, type Connection, type EdgeProps, type NodeProps, type OnNodeDrag, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import type { SynixirRoom } from "@synixir/client"; +import { Button } from "../components/ui/button.tsx"; +import { Input } from "../components/ui/input.tsx"; +import { Field, FieldGroup, FieldLabel } from "../components/ui/field.tsx"; +import { NativeSelect } from "../components/ui/native-select.tsx"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty.tsx"; +import { canvasSize, colors, createFlowchartModel } from "./model.ts"; +import { connectionPath } from "./geometry.ts"; +import { createFlowchartAdapter, kindLabel, type FlowchartAdapter, type FlowEdge, type FlowNode } from "./adapter.ts"; +import { createFlowchartPresence, type FlowchartPresence } from "./presence.ts"; + +const AdapterContext = createContext(null); +const extent: [[number, number], [number, number]] = [[0, 0], [canvasSize.width, canvasSize.height]]; +const defaultViewport = { x: 0, y: 0, zoom: 1 }; +const minimapStyle = { width: 120, height: 80 }; +const ariaLabels = { "controls.zoomIn.ariaLabel": "Zoom in", "controls.zoomOut.ariaLabel": "Zoom out", "controls.fitView.ariaLabel": "Fit diagram" }; +const announce = (message: string) => { const element = document.getElementById("board-announcement"); if (element) element.textContent = message; }; +const ShapeNode = memo(function ShapeNode({ id, data, selected }: NodeProps) { + const adapter = useContext(AdapterContext)!; + const onResizeStart = useCallback(() => adapter.beginGesture(id, "resize"), [adapter, id]); + const onResizeEnd = useCallback["onResizeEnd"]>>((_, bounds) => { + adapter.finishGesture(id, { position: { x: bounds.x, y: bounds.y }, size: { width: bounds.width, height: bounds.height } }); + }, [adapter, id]); + return <> + +
    {data.text}
    + + + ; +}); +const ArrowEdge = memo(function ArrowEdge({ id, source, target, data, label, markerEnd, selected }: EdgeProps) { + const from = useInternalNode(source), to = useInternalNode(target); + if (!data || !from || !to) return null; + const bounds = (node: typeof from) => ({ position: node.internals.positionAbsolute, size: { width: node.measured.width ?? node.width ?? 200, height: node.measured.height ?? node.height ?? 90 } }); + const { path, label: point } = connectionPath(bounds(from), bounds(to), { reciprocal: data.reciprocal, targetGap: 7 }); + return <> + + {label ? {label} : null} + ; +}); +const nodeTypes = { shape: ShapeNode }; +const edgeTypes = { arrow: ArrowEdge }; + +function Inspector({ adapter }: { adapter: FlowchartAdapter }) { + const state = useSyncExternalStore(adapter.subscribe, adapter.getSnapshot); + const item = state.items.find(item => item.id === state.selectedId); + const edge = state.connections.find(edge => edge.id === state.selectedEdgeId); + const [target, setTarget] = useState(""); + const [branch, setBranch] = useState(""); + const targets = state.items.filter(node => node.id !== item?.id); + const destination = targets.some(node => node.id === target) ? target : targets[0]?.id ?? ""; + const disabled = state.readOnly; + const breakHistory = () => adapter.model.history.stopCapturing(); + return
    + {!item && !edge ?

    Select a node to edit it, or an arrow to label a branch.

    : null} + {item ?
    +

    {kindLabel(item.kind)}

    + + +
    + + Node text +
    -
    -
    -
    - - -
    `; - const viewport = root.querySelector("#whiteboard-viewport")!; - const sizer = root.querySelector(".canvas-sizer")!; - const canvas = root.querySelector(".whiteboard-canvas")!; - const layer = root.querySelector(".object-layer")!; - const presenceLayer = root.querySelector(".presence-layer")!; - const textInput = root.querySelector("#object-text")!; - const colorInput = root.querySelector("#object-color")!; - const widthInput = root.querySelector("#object-width")!; - const heightInput = root.querySelector("#object-height")!; - canvas.style.width = `${canvasSize.width}px`; - canvas.style.height = `${canvasSize.height}px`; - for (const color of colors) { - const option = document.createElement("option"); - option.value = color; - option.textContent = color[0].toUpperCase() + color.slice(1); - colorInput.append(option); - } - const selected = () => items.find(item => item.id === selectedId); - const validPoint = (point: { x: any; y: any; }) => point && Number.isFinite(point.x) && Number.isFinite(point.y) && - point.x >= 0 && point.y >= 0 && point.x <= canvasSize.width && point.y <= canvasSize.height; - const worldPoint = (event: { clientX: number; clientY: number; }) => { - const rect = canvas.getBoundingClientRect(); - return { x: (event.clientX - rect.left) / zoom, y: (event.clientY - rect.top) / zoom }; - }; - function publish() { - clearTimeout(presenceTimer); - presenceTimer = undefined; - if (disposed) return; - lastPresenceAt = performance.now(); - room.awareness.setLocalStateField("whiteboard", { cursor, selectedId, - drag: drag?.moved ? { id: drag.id, ...drag.position } : null }); - } - function schedulePresence() { - if (presenceTimer !== undefined) return; - const remaining = 60 - (performance.now() - lastPresenceAt); - if (remaining <= 0) publish(); - else presenceTimer = setTimeout(publish, remaining); - } - function select(id: string|null|undefined) { - selectedId = id ?? null; - model.history.stopCapturing(); - render(); - publish(); - } - function cancelDrag() { - const previous = drag; - drag = null; - if (previous && viewport.hasPointerCapture(previous.pointerId)) viewport.releasePointerCapture(previous.pointerId); - publish(); - scheduleActivity(); - } - function setZoom(value: number) { - cancelDrag(); - const center = { x: (viewport.scrollLeft + viewport.clientWidth / 2) / zoom, - y: (viewport.scrollTop + viewport.clientHeight / 2) / zoom }; - zoom = Math.min(1.5, Math.max(0.5, value)); - canvas.style.transform = `scale(${zoom})`; - sizer.style.width = `${canvasSize.width * zoom}px`; - sizer.style.height = `${canvasSize.height * zoom}px`; - viewport.scrollLeft = center.x * zoom - viewport.clientWidth / 2; - viewport.scrollTop = center.y * zoom - viewport.clientHeight / 2; - root.querySelector("#zoom-level")!.textContent = `${Math.round(zoom * 100)}%`; - root.querySelector("#zoom-out")!.disabled = zoom <= 0.5; - root.querySelector("#zoom-in")!.disabled = zoom >= 1.5; - cursor = null; - publish(); - } - on(root.querySelector("#zoom-in")!, "click", () => setZoom(zoom + 0.25)); - on(root.querySelector("#zoom-out")!, "click", () => setZoom(zoom - 0.25)); - on(root.querySelector("#zoom-reset")!, "click", () => setZoom(1)); - root.querySelectorAll("[data-add]").forEach(button => on(button, "click", () => { - if (readOnly) return; - cancelDrag(); - const offset = model.list().length % 5 * 24; - const id = model.add(button.dataset.add, { x: (viewport.scrollLeft + viewport.clientWidth / 2) / zoom - 110 + offset, - y: (viewport.scrollTop + viewport.clientHeight / 2) / zoom - 90 + offset }); - select(id); - textInput.focus(); - textInput.select(); - announce("Object added."); - })); - on(viewport, "pointerdown", (event) => { - if (event.button !== 0 || !event.isPrimary) return; - const node = (event.target as Element).closest(".whiteboard-object"); - if (!node) { select(null); return; } - event.preventDefault(); - node.focus({ preventScroll: true }); - select(node.dataset.objectId); - const item = selected(); - if (readOnly || !item) return; - const point = worldPoint(event); - drag = { id: item.id, pointerId: event.pointerId, start: point, original: item.position, - position: item.position, size: item.size, moved: false }; - viewport.setPointerCapture(event.pointerId); - }); - on(viewport, "pointermove", (event) => { - const point = worldPoint(event); - cursor = validPoint(point) ? point : null; - if (drag && event.pointerId === drag.pointerId && !readOnly) { - if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) > 2) drag.moved = true; - drag.position = model.boundedPosition({ x: drag.original.x + point.x - drag.start.x, - y: drag.original.y + point.y - drag.start.y }, drag.size); - scheduleActivity(); - } - schedulePresence(); - }); - on(viewport, "pointerup", (event) => { - if (!drag || event.pointerId !== drag.pointerId) return; - const completed = drag; - drag = null; - if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId); - if (!readOnly && completed.moved) { - model.move(completed.id, completed.position); - announce("Object moved."); - } - publish(); - scheduleActivity(); - }); - on(viewport, "pointercancel", cancelDrag); - on(viewport, "lostpointercapture", () => { if (drag) cancelDrag(); }); - on(viewport, "pointerleave", () => { cursor = null; publish(); }); - on(viewport, "scroll", () => { cursor = null; schedulePresence(); }); - on(window, "blur", () => { cursor = null; cancelDrag(); }); - on(document, "visibilitychange", () => { if (document.hidden) { cursor = null; cancelDrag(); } }); - on(viewport, "click", (event) => { - const node = (event.target as Element).closest(".whiteboard-object"); - if (node) select(node.dataset.objectId); - }); - on(root, "keydown", (event) => { - if (event.key === "Escape") { - if (drag) cancelDrag(); - else select(null); - viewport.focus({ preventScroll: true }); - return; - } - if (readOnly || event.isComposing) return; - if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "z") { - event.preventDefault(); - cancelDrag(); - if (event.shiftKey) model.history.redo(); else model.history.undo(); - return; - } - if (event.target !== viewport && !(event.target as Element).closest(".whiteboard-object")) return; - const item = selected(); - if (!item) return; - if (["Delete", "Backspace"].includes(event.key)) { event.preventDefault(); model.remove(item.id); return; } - const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; - if (delta) { - event.preventDefault(); - const step = event.shiftKey ? 10 : 1; - model.move(item.id, { x: item.position.x + delta[0] * step, y: item.position.y + delta[1] * step }); - } - }); - for (const [input, field] of [[textInput, "text"], [colorInput, "color"]] as const) { - on(input, "focus", () => model.history.stopCapturing()); - on(input, "blur", () => model.history.stopCapturing()); - on(input, "input", () => { if (!readOnly && selectedId) model.edit(selectedId, field, input.value); }); - } - for (const input of [widthInput, heightInput]) on(input, "change", () => { - if (!readOnly && selectedId) model.resize(selectedId, { width: widthInput.valueAsNumber, height: heightInput.valueAsNumber }); - renderInspector(); - }); - on(root.querySelector("#bring-front")!, "click", () => { if (!readOnly && selectedId) model.front(selectedId); }); - on(root.querySelector("#delete-object")!, "click", () => { if (!readOnly && selectedId) model.remove(selectedId); }); - on(undo, "click", () => { if (!readOnly) { cancelDrag(); model.history.undo(); } }); - on(redo, "click", () => { if (!readOnly) { cancelDrag(); model.history.redo(); } }); - - function peers() { - if (room.state.connection !== "connected") return []; - return [...room.awareness.getStates()].filter(([id, state]) => id !== room.awareness.clientID && state.whiteboard) - .sort(([a], [b]) => a - b).map(([id, state]) => ({ id, ...state.whiteboard, - name: typeof state.user?.name === "string" ? state.user.name.slice(0, 32) : "Guest", - color: /^#[0-9a-f]{6}$/i.test(state.user?.color) ? state.user.color : "#3565b0" })); - } - function scheduleActivity() { - if (!disposed && activityFrame === undefined) activityFrame = requestAnimationFrame(renderActivity); - } - function syncObjects() { - const present = new Set(items.map(item => item.id)); - for (const [id, node] of nodes) if (!present.has(id)) { - motion.forget(node); - node.remove(); - nodes.delete(id); - } - items.forEach((item, index) => { - let node = nodes.get(item.id); - if (!node) { - node = document.createElement("button"); - node.type = "button"; - node.dataset.objectId = item.id; - nodes.set(item.id, node); - layer.append(node); - } - node.className = `whiteboard-object object-${item.kind} color-${item.color}`; - node.classList.toggle("is-selected", selectedId === item.id); - node.setAttribute("aria-pressed", String(selectedId === item.id)); - node.setAttribute("aria-label", `${item.kind === "sticky" ? "Sticky note" : item.kind === "ellipse" ? "Ellipse" : "Rectangle"}: ${item.text || "Untitled"}`); - if (node.textContent !== item.text) node.textContent = item.text; - Object.assign(node.style, { width: `${item.size.width}px`, height: `${item.size.height}px`, zIndex: String(index) }); - }); - root.querySelector(".canvas-empty")!.hidden = items.length > 0; - const count = `${items.length} ${items.length === 1 ? "object" : "objects"}`; - if (root.querySelector("#object-count")!.textContent !== count) root.querySelector("#object-count")!.textContent = count; - } - function renderActivity() { - activityFrame = undefined; - if (disposed) return; - const remote = peers(); - const displayed = new Map(); - for (const item of items) { - const node = nodes.get(item.id); - if (!node) continue; - const ownDrag = drag?.id === item.id; - const remoteDrag = remote.findLast((peer: { drag: { id: string; x: number; y: number }; }) => peer.drag?.id === item.id && validPoint(peer.drag))?.drag; - const preview = ownDrag && drag ? drag.position : remoteDrag; - const position = preview ? model.boundedPosition(preview, item.size) : item.position; - const smooth = !ownDrag && (Boolean(remoteDrag) || motion.moving(node)); - motion.move(node, position, smooth); - displayed.set(item.id, { position, smooth }); - } - const presentPeers = new Set(remote.map(peer => peer.id)); - for (const [id, pair] of peerNodes) if (!presentPeers.has(id)) { - motion.forget(pair.ring); - motion.forget(pair.pointer); - pair.ring?.remove(); - pair.pointer?.remove(); - peerNodes.delete(id); - } - for (const peer of remote) { - let pair = peerNodes.get(peer.id); - if (!pair) { pair = {}; peerNodes.set(peer.id, pair); } - const item = items.find(item => item.id === peer.selectedId); - if (item) { - if (!pair.ring) { - pair.ring = document.createElement("div"); - pair.ring.className = "remote-selection"; - pair.ring.append(document.createElement("span")); - presenceLayer.append(pair.ring); - } - const node = nodes.get(item.id); - const ring = pair.ring; - if (ring.dataset.peerSelection !== item.id) motion.forget(ring); - ring.dataset.peerSelection = item.id; - const display = displayed.get(item.id); - motion.move(ring, display.position, display.smooth); - Object.assign(ring.style, { width: node.style.width, height: node.style.height, borderColor: peer.color }); - const label = ring.firstChild; - if (label.textContent !== peer.name) label.textContent = peer.name; - label.style.background = peer.color; - } else { motion.forget(pair.ring); pair.ring?.remove(); pair.ring = null; } - if (validPoint(peer.cursor)) { - if (!pair.pointer) { - pair.pointer = document.createElement("div"); - pair.pointer.className = "remote-cursor"; - const arrow = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - arrow.setAttribute("viewBox", "0 0 28 28"); - arrow.setAttribute("aria-hidden", "true"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", "M1.5 1.5 26.5 10.5 16 15 11 26.5Z"); - arrow.append(path); - pair.pointer.append(arrow, document.createElement("span")); - presenceLayer.append(pair.pointer); - } - const pointer = pair.pointer; - motion.move(pointer, peer.cursor); - pointer.style.color = peer.color; - const label = pointer.lastChild; - if (label.textContent !== peer.name) label.textContent = peer.name; - label.style.background = peer.color; - } else { motion.forget(pair.pointer); pair.pointer?.remove(); pair.pointer = null; } - } - } - - function renderInspector() { - const item = selected(); - root.querySelector("#selection-empty")!.hidden = Boolean(item); - root.querySelector("#selection-fields")!.hidden = !item; - if (!item) return; - root.querySelector("#selection-title")!.textContent = item.kind === "sticky" ? "Sticky note" : item.kind === "ellipse" ? "Ellipse" : "Rectangle"; - if (textInput.value !== item.text) { - const start = textInput.selectionStart; - const end = textInput.selectionEnd; - textInput.value = item.text; - if (document.activeElement === textInput) textInput.setSelectionRange(Math.min(start, item.text.length), Math.min(end, item.text.length)); - } - colorInput.value = item.color; - widthInput.value = String(item.size.width); - heightInput.value = String(item.size.height); - textInput.readOnly = readOnly; - for (const input of [colorInput, widthInput, heightInput, root.querySelector("#bring-front")!, root.querySelector("#delete-object")!]) input.disabled = readOnly; - } - function renderHistory() { - undo.disabled = readOnly || !model.history.canUndo(); - redo.disabled = readOnly || !model.history.canRedo(); - } - function render() { - if (disposed) return; - items = model.list(); - if (selectedId && !items.some(item => item.id === selectedId)) { - selectedId = null; - cancelDrag(); - publish(); - viewport.focus({ preventScroll: true }); - announce("The selected object was removed."); - } - syncObjects(); - scheduleActivity(); - renderInspector(); - renderHistory(); - } - model.objects.observeDeep(render); - room.awareness.on("change", scheduleActivity); - for (const event of ["stack-item-added", "stack-item-popped", "stack-cleared", "stack-item-updated"] as const) model.history.on(event, renderHistory); - setZoom(1); - render(); - function destroy() { - disposed = true; - clearTimeout(presenceTimer); - if (activityFrame !== undefined) cancelAnimationFrame(activityFrame); - motion.destroy(); - peerNodes.clear(); - events.abort(); - model.objects.unobserveDeep(render); - room.awareness.off("change", scheduleActivity); - model.destroy(); - nodes.clear(); - root.replaceChildren(); - } - destroy.setReadOnly = (value: boolean) => { - if (readOnly !== value) { - readOnly = value; - if (readOnly) cancelDrag(); - root.querySelectorAll("[data-add]").forEach(button => { button.disabled = readOnly; }); - render(); - } - scheduleActivity(); - }; - return destroy; -} +// Loaded by the workspace only after the whiteboard route is selected. +export { createWhiteboard } from "./excalidraw.tsx"; diff --git a/examples/collaboration/whiteboard/copy-assets.mjs b/examples/collaboration/whiteboard/copy-assets.mjs new file mode 100644 index 0000000..cee4acd --- /dev/null +++ b/examples/collaboration/whiteboard/copy-assets.mjs @@ -0,0 +1,7 @@ +import { cp, mkdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +const packageEntry = import.meta.resolve("@excalidraw/excalidraw"); +const source = new URL("fonts/", packageEntry); +const destination = new URL("../public/excalidraw-assets/fonts/", import.meta.url); +await mkdir(destination, { recursive: true }); +await cp(fileURLToPath(source), fileURLToPath(destination), { recursive: true }); diff --git a/examples/collaboration/whiteboard/excalidraw.tsx b/examples/collaboration/whiteboard/excalidraw.tsx new file mode 100644 index 0000000..9645c4a --- /dev/null +++ b/examples/collaboration/whiteboard/excalidraw.tsx @@ -0,0 +1,226 @@ +"use client"; +import "./assets.ts"; +import { createRoot } from "react-dom/client"; +import { Excalidraw, CaptureUpdateAction, MainMenu, restoreElements } from "@excalidraw/excalidraw"; +import "@excalidraw/excalidraw/index.css"; +import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import type { Collaborator, ExcalidrawImperativeAPI, ExcalidrawProps, SocketId } from "@excalidraw/excalidraw/types"; +import type { SynixirRoom } from "@synixir/client"; +import { createWhiteboardModel, sameScene } from "./model.ts"; + +declare global { interface Window { synixirWhiteboardTest?: ExcalidrawImperativeAPI } } +const cloneScene = (scene: readonly ExcalidrawElement[]): ExcalidrawElement[] => JSON.parse(JSON.stringify(scene)); +const unsupported = (element: ExcalidrawElement) => ["image", "iframe", "embeddable", "magicframe"].includes(element.type); + +export function createWhiteboard(room: SynixirRoom) { + const host = document.querySelector("#editor")!; + // This editor owns its controls' styles. The shared surface rules style the + // small DOM examples and would override Excalidraw's toolbar and text editor. + host.classList.replace("surface", "whiteboard-surface"); + const root = createRoot(host); + const model = createWhiteboardModel(room.doc); + const events = new AbortController(); + const undo = document.querySelector("#undo")!; + const redo = document.querySelector("#redo")!; + const count = document.querySelector("#word-count")!; + let api: ExcalidrawImperativeAPI | undefined; + let initialized = false; + let previous: ExcalidrawElement[] = []; + let disposed = false; + let readOnly = true; + let localWrite = false; + let rendering = false; + let pendingFrame: number | undefined; + let presenceTimer: ReturnType | undefined; + let lastPresence = ""; + let pointer: { x: number; y: number; tool: "pointer" | "laser" } | null = null; + let button: "up" | "down" = "up"; + let selectedElementIds: Record = {}; + let lastCount = ""; + let positionedScene = false; + + function historyState() { + undo.disabled = readOnly || !model.history.canUndo(); + redo.disabled = readOnly || !model.history.canRedo(); + } + function updateCount(elements: readonly ExcalidrawElement[]) { + const total = elements.filter(element => !element.isDeleted).length; + const value = `${total} ${total === 1 ? "object" : "objects"}`; + if (lastCount !== value) { count.textContent = value; lastCount = value; } + } + function paint() { + if (disposed || !api || !initialized) return; + const elements = restoreElements(model.list(), null, { repairBindings: true }); + previous = cloneScene(elements); + rendering = true; + api.updateScene({ elements, captureUpdate: CaptureUpdateAction.NEVER }); + rendering = false; + api.history.clear(); + if (!positionedScene && elements.some(element => !element.isDeleted)) { + positionedScene = true; + api.scrollToContent(elements.filter(element => !element.isDeleted), { fitToContent: true, animate: false }); + } + updateCount(elements); + historyState(); + } + function schedulePaint() { + if (pendingFrame === undefined) pendingFrame = requestAnimationFrame(() => { + pendingFrame = undefined; + paint(); + }); + } + const stopModel = model.observe(transaction => { + if (!localWrite && transaction.origin !== model.origin) paint(); + historyState(); + }); + + function publish() { + clearTimeout(presenceTimer); + presenceTimer = undefined; + if (disposed) return; + const state = { pointer, button, selectedElementIds }; + const encoded = JSON.stringify(state); + if (encoded === lastPresence) return; + lastPresence = encoded; + room.awareness.setLocalStateField("whiteboard", state); + } + function schedulePresence() { + if (presenceTimer === undefined) presenceTimer = setTimeout(publish, 60); + } + function clearPointer() { pointer = null; button = "up"; publish(); } + function collaborators() { + if (disposed || !api || !initialized) return; + const peers = new Map(); + if (room.state.connection === "connected") for (const [id, state] of room.awareness.getStates()) { + if (id === room.awareness.clientID || !state.whiteboard) continue; + const remote = state.whiteboard; + const color = /^#[0-9a-f]{6}$/i.test(state.user?.color) ? state.user.color : "#3565b0"; + const selected = Object.fromEntries(Object.entries(remote.selectedElementIds ?? {}) + .filter(([id, value]) => id.length <= 256 && value === true).map(([id]) => [id, true as const])); + peers.set(String(id) as SocketId, { + username: typeof state.user?.name === "string" ? state.user.name.slice(0, 32) : "Guest", + color: { background: color, stroke: color }, + selectedElementIds: selected, + button: remote.button === "down" ? "down" : "up", + ...(remote.pointer && Number.isFinite(remote.pointer.x) && Number.isFinite(remote.pointer.y) + ? { pointer: { x: remote.pointer.x, y: remote.pointer.y, tool: "pointer" as const } } : {}), + }); + } + api.updateScene({ collaborators: peers, captureUpdate: CaptureUpdateAction.NEVER }); + } + room.awareness.on("change", collaborators); + const stopState = room.subscribe(collaborators); + + const onChange: ExcalidrawProps["onChange"] = (elements, appState) => { + if (disposed || rendering) return; + if (!initialized) { initialized = true; paint(); collaborators(); return; } + const selected = appState.selectedElementIds; + if (JSON.stringify(selected) !== JSON.stringify(selectedElementIds)) { + selectedElementIds = { ...selected }; + schedulePresence(); + } + if (readOnly) { if (!sameScene(elements, previous)) schedulePaint(); return; } + // File import and image insertion are disabled. Also reject clipboard, + // library, and keyboard paths which may bypass the visible tool options. + if (elements.some(unsupported)) { schedulePaint(); return; } + localWrite = true; + if (model.apply(elements, previous) && elements.some(element => !element.isDeleted)) positionedScene = true; + localWrite = false; + previous = cloneScene(elements); + api?.history.clear(); + updateCount(elements); + historyState(); + }; + const onPointerUpdate: ExcalidrawProps["onPointerUpdate"] = value => { + pointer = value.pointer; + button = value.button; + schedulePresence(); + }; + const ready = (value: ExcalidrawImperativeAPI) => { + if (disposed) return; + api = value; + if (process.env.NODE_ENV === "development" && process.env.NEXT_PUBLIC_SYNIXIR_BROWSER_TEST === "true") + window.synixirWhiteboardTest = value; + queueMicrotask(() => { + if (!disposed && !value.getAppState().isLoading) { initialized = true; paint(); collaborators(); } + }); + }; + const initialData = { elements: [], appState: { currentItemFontFamily: 2, viewBackgroundColor: "#ffffff" } }; + const uiOptions = { tools: { image: false }, canvasActions: { + loadScene: false, saveToActiveFile: false, changeViewBackgroundColor: false, + } }; + function render() { + root.render(
    + model.beginGesture()} + onPointerUp={() => model.endGesture()} viewModeEnabled={readOnly} isCollaborating + UIOptions={uiOptions} aiEnabled={false} validateEmbeddable={false} + onPaste={data => !readOnly && !Object.keys(data.files ?? {}).length && !data.elements?.some(unsupported)}> + + + + + + + +
    ); + } + function undoLocal(redoAction = false) { + if (readOnly) return; + model.endGesture(); + if (redoAction) model.history.redo(); else model.history.undo(); + api?.history.clear(); + } + undo.addEventListener("click", () => undoLocal(), { signal: events.signal }); + redo.addEventListener("click", () => undoLocal(true), { signal: events.signal }); + host.addEventListener("keydown", event => { + if ((event.ctrlKey || event.metaKey) && !event.altKey && ["z", "y"].includes(event.key.toLowerCase())) { + event.preventDefault(); + event.stopImmediatePropagation(); + undoLocal(event.shiftKey || event.key.toLowerCase() === "y"); + } + }, { signal: events.signal, capture: true }); + host.addEventListener("drop", event => { + if (event.dataTransfer?.files.length) { event.preventDefault(); event.stopImmediatePropagation(); } + }, { signal: events.signal, capture: true }); + host.addEventListener("paste", event => { + if (event.clipboardData?.files.length) { event.preventDefault(); event.stopImmediatePropagation(); } + }, { signal: events.signal, capture: true }); + host.addEventListener("pointerleave", clearPointer, { signal: events.signal }); + window.addEventListener("blur", clearPointer, { signal: events.signal }); + document.addEventListener("visibilitychange", () => { if (document.hidden) clearPointer(); }, { signal: events.signal }); + for (const event of ["stack-item-added", "stack-item-popped", "stack-cleared"] as const) + model.history.on(event, historyState); + render(); + + function destroy() { + disposed = true; + clearTimeout(presenceTimer); + if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame); + stopModel(); stopState(); + room.awareness.off("change", collaborators); + room.awareness.setLocalStateField("whiteboard", null); + events.abort(); + model.destroy(); + delete window.synixirWhiteboardTest; + root.unmount(); + host.classList.replace("whiteboard-surface", "surface"); + count.textContent = ""; + } + destroy.setReadOnly = (value: boolean) => { + if (readOnly !== value) { + readOnly = value; + if (readOnly) { + model.endGesture(); + selectedElementIds = {}; + clearPointer(); + api?.updateScene({ appState: { selectedElementIds: {}, editingTextElement: null }, captureUpdate: CaptureUpdateAction.NEVER }); + paint(); + } + render(); + historyState(); + } + collaborators(); + }; + return destroy; +} diff --git a/examples/collaboration/whiteboard/model.test.ts b/examples/collaboration/whiteboard/model.test.ts index 1a82a4f..7dec21c 100644 --- a/examples/collaboration/whiteboard/model.test.ts +++ b/examples/collaboration/whiteboard/model.test.ts @@ -1,71 +1,169 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import * as Y from "yjs"; -import { createWhiteboardModel, canvasSize } from "./model.ts"; +import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import { createWhiteboardModel } from "./model.ts"; +function shape(id = crypto.randomUUID()): ExcalidrawElement { + return { id, type: "rectangle", x: 80, y: 80, width: 220, height: 180, + angle: 0, strokeColor: "#000000", backgroundColor: "#ffffff", fillStyle: "solid", + strokeWidth: 1, strokeStyle: "solid", roundness: null, roughness: 0, opacity: 100, + seed: 1, version: 1, versionNonce: 1, index: "a0", isDeleted: false, + groupIds: [], frameId: null, boundElements: null, updated: 1, link: null, locked: false, + } as unknown as ExcalidrawElement; +} function clients() { - const a = new Y.Doc(); - const b = new Y.Doc(); - const first = createWhiteboardModel(a); - const second = createWhiteboardModel(b); - return { first, second, sync() { - const left = Y.encodeStateAsUpdate(a); - const right = Y.encodeStateAsUpdate(b); - Y.applyUpdate(a, right); - Y.applyUpdate(b, left); + const a = new Y.Doc(), b = new Y.Doc(); + const first = createWhiteboardModel(a), second = createWhiteboardModel(b); + return { a, b, first, second, sync() { + const left = Y.encodeStateAsUpdate(a), right = Y.encodeStateAsUpdate(b); + Y.applyUpdate(a, right); Y.applyUpdate(b, left); assert.deepEqual(first.list(), second.list()); }, close() { first.destroy(); second.destroy(); a.destroy(); b.destroy(); } }; } +function edit(model: ReturnType, id: string, patch: Partial) { + const before = model.list(); + model.history.stopCapturing(); + model.apply(before.map(element => element.id === id ? { ...element, ...patch } as ExcalidrawElement : element), before); + model.history.stopCapturing(); +} + +test("concurrent element creation, field edits, and local undo preserve remote work", () => { + const c = clients(); + try { + const left = shape(), right = shape(); + c.first.apply([left], []); c.second.apply([right], []); c.sync(); + assert.equal(c.first.list().length, 2); + edit(c.first, left.id, { x: 300, y: 200 }); + edit(c.second, left.id, { backgroundColor: "#b2f2bb" }); c.sync(); + assert.equal(c.first.list().find(e => e.id === left.id)?.x, 300); + c.first.history.undo(); c.sync(); + const restored = c.first.list().find(e => e.id === left.id)!; + assert.equal(restored.x, 80); + assert.equal(restored.backgroundColor, "#b2f2bb"); + c.first.history.redo(); c.sync(); + assert.equal(c.first.list().find(e => e.id === left.id)?.x, 300); + } finally { c.close(); } +}); + +test("concurrent moves converge to one complete position and preserve a resize", () => { + const c = clients(); + try { + const element = shape(); c.first.apply([element], []); c.sync(); + edit(c.first, element.id, { x: 300, y: 200 }); + edit(c.second, element.id, { x: 400, y: 500, width: 300 }); c.sync(); + const merged = c.first.list()[0]; + assert.ok((merged.x === 300 && merged.y === 200) || (merged.x === 400 && merged.y === 500)); + assert.equal(merged.width, 300); + } finally { c.close(); } +}); + +test("deletion wins over an offline edit; undo restores the edited element", () => { + const c = clients(); + try { + const element = shape(); c.first.apply([element], []); c.sync(); + const stale = c.second.list(); + edit(c.first, element.id, { isDeleted: true }); + edit(c.second, element.id, { backgroundColor: "#ffc9c9" }); c.sync(); + assert.equal(c.first.list()[0].isDeleted, true); + // A queued callback from a stale canvas must not revive the deletion. + c.second.apply(stale.map(e => ({ ...e, width: 400 })), stale); c.sync(); + assert.equal(c.first.list()[0].isDeleted, true); + c.first.history.undo(); c.sync(); + assert.equal(c.first.list()[0].isDeleted, false); + assert.equal(c.first.list()[0].backgroundColor, "#ffc9c9"); + assert.equal(c.first.list()[0].width, 400); + } finally { c.close(); } +}); + +test("scene observations do not echo metadata or create undo entries", () => { + const c = clients(); + try { + c.first.apply([shape()], []); c.sync(); + const before = c.second.list(); + const vector = Y.encodeStateVector(c.b); + assert.equal(c.second.apply(before.map(e => ({ ...e, version: 900, versionNonce: 12, updated: Date.now() })), before), false); + assert.deepEqual(Y.encodeStateVector(c.b), vector); + assert.equal(c.second.history.canUndo(), false); + } finally { c.close(); } +}); + +test("concurrent arrow bindings retain both references and deleted arrows detach", () => { + const c = clients(); + try { + const target = shape(); c.first.apply([target], []); c.sync(); + const arrow = (id: string) => ({ ...shape(id), type: "arrow", points: [[0, 0], [100, 100]], + startBinding: null, endBinding: { elementId: target.id, focus: 0, gap: 1 }, + startArrowhead: null, endArrowhead: "arrow", elbowed: false } as unknown as ExcalidrawElement); + c.first.apply([...c.first.list(), arrow("left")], c.first.list()); + c.second.apply([...c.second.list(), arrow("right")], c.second.list()); c.sync(); + assert.deepEqual(c.first.list().find(e => e.id === target.id)?.boundElements?.map(e => e.id).sort(), ["left", "right"]); + edit(c.first, "left", { isDeleted: true }); c.sync(); + assert.deepEqual(c.first.list().find(e => e.id === target.id)?.boundElements, [{ id: "right", type: "arrow" }]); + } finally { c.close(); } +}); + +test("v1 migration is deterministic, read only, and merges concurrent first edits", () => { + const c = clients(); + try { + c.a.getMap("whiteboard:objects:v1").set("old-note", new Y.Map([ + ["kind", "sticky"], ["text", "Keep this idea"], ["position", { x: 100, y: 150 }], + ["size", { width: 220, height: 180 }], ["order", 1], ["color", "mint"], + ])); c.sync(); + const vector = Y.encodeStateVector(c.a); + assert.equal(c.first.list().length, 2); + assert.equal(c.first.list().find(e => e.type === "text")?.text, "Keep this idea"); + assert.deepEqual(Y.encodeStateVector(c.a), vector); + assert.equal(c.first.history.canUndo(), false); + edit(c.first, "old-note", { x: 400 }); + edit(c.second, "old-note", { backgroundColor: "#ffc9c9" }); c.sync(); + const migrated = c.first.list().find(e => e.id === "old-note")!; + assert.equal(migrated.x, 400); + assert.equal(migrated.backgroundColor, "#ffc9c9"); + assert.equal(c.first.list().length, 2); + } finally { c.close(); } +}); -test("concurrent moves choose one position while a teammate's text survives undo", () => { - const { first, second, sync, close } = clients(); +test("a pointer gesture is one undo item and unsupported asset records are ignored", () => { + const c = clients(); try { - const id = first.add("sticky"); - sync(); - first.move(id, { x: 300, y: 200 }); - second.edit(id, "text", "A teammate's idea"); - sync(); - first.history.undo(); - sync(); - assert.deepEqual(first.list()[0].position, { x: 80, y: 80 }); - assert.equal(first.list()[0].text, "A teammate's idea"); - first.move(id, { x: 200, y: 100 }); - second.move(id, { x: 500, y: 400 }); - sync(); - assert.equal(first.list().length, 1); - assert.ok([200, 500].includes(first.list()[0].position.x)); - } finally { close(); } + const element = shape(); c.first.apply([element], []); c.first.history.stopCapturing(); + c.first.beginGesture(); + for (const x of [100, 150, 300]) { + const before = c.first.list(); + c.first.apply(before.map(e => ({ ...e, x })), before); + } + c.first.endGesture(); c.first.history.undo(); + assert.equal(c.first.list()[0].x, 80); + c.first.apply([...c.first.list(), { ...shape(), type: "image" } as ExcalidrawElement], c.first.list()); + assert.equal(c.first.list().length, 1); + } finally { c.close(); } }); -test("objects stay within the canvas and concurrent creation preserves both objects", () => { - const { first, second, sync, close } = clients(); +test("fractional ranks use raw lexical ordering and malformed shared records are ignored", () => { + const doc = new Y.Doc(), model = createWhiteboardModel(doc); try { - const id = first.add("rectangle", { x: -100, y: 9000 }); - second.add("ellipse"); - sync(); - assert.equal(first.list().length, 2); - first.move(id, { x: 9999, y: 9999 }); - first.resize(id, { width: 500, height: 500 }); - sync(); - const item = first.list().find(item => item.id === id); - assert.ok(item); - assert.ok(item.position.x + item.size.width <= canvasSize.width); - assert.ok(item.position.y + item.size.height <= canvasSize.height); - } finally { close(); } + const upper = { ...shape("upper"), index: "a0Z" } as ExcalidrawElement; + const lower = { ...shape("lower"), index: "a0a" } as ExcalidrawElement; + model.apply([lower, upper], []); + doc.getMap("whiteboard:elements:v2").set("invalid", null); + doc.getMap("whiteboard:fields:v2").set("invalid JSON", true); + doc.getMap("whiteboard:fields:v2").set("null", true); + doc.getMap("whiteboard:fields:v2").set(JSON.stringify(["lower", "position"]), null); + assert.deepEqual(model.list().map(e => e.id), ["upper", "lower"]); + assert.equal(model.list()[1].x, 80); + } finally { model.destroy(); doc.destroy(); } }); -test("deletion wins over an offline edit and undo restores the object", () => { - const { first, second, sync, close } = clients(); +test("late scene callbacks cannot recreate an object after its creator undoes it", () => { + const c = clients(); try { - const id = first.add("sticky"); - sync(); - first.remove(id); - second.edit(id, "color", "rose"); - sync(); - assert.equal(first.list().length, 0); - first.history.undo(); - sync(); - assert.equal(first.list().length, 1); - assert.equal(first.list()[0].kind, "sticky"); - } finally { close(); } + const element = shape(); + c.first.apply([element], []); c.sync(); + const stale = c.second.list(); + c.first.history.undo(); c.sync(); + assert.equal(c.second.list().length, 0); + c.second.apply(stale.map(e => ({ ...e, x: 500 })), []); c.sync(); + assert.equal(c.first.list().length, 0); + } finally { c.close(); } }); diff --git a/examples/collaboration/whiteboard/model.ts b/examples/collaboration/whiteboard/model.ts index c7e06fb..059b04e 100644 --- a/examples/collaboration/whiteboard/model.ts +++ b/examples/collaboration/whiteboard/model.ts @@ -1,71 +1,175 @@ import * as Y from "yjs"; -import { canvasItem, type Point, type Size } from "../lib/canvas-data.ts"; +import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import { canvasItem } from "../lib/canvas-data.ts"; -export const canvasSize = { width: 1600, height: 1000 }; -export const kinds = ["sticky", "rectangle", "ellipse"]; -export const colors = ["yellow", "blue", "mint", "rose", "white"]; -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +const ignored = new Set(["id", "version", "versionNonce", "updated", "boundElements"]); +const supported = new Set(["rectangle", "diamond", "ellipse", "text", "line", "arrow", "freedraw", "frame"]); +const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; +const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); +function validElement(value: unknown): value is ExcalidrawElement { + if (!value || typeof value !== "object") return false; + const element = value as ExcalidrawElement; + return typeof element.id === "string" && supported.has(element.type) && + [element.x, element.y, element.width, element.height].every(finite) && + typeof element.isDeleted === "boolean" && (element.index === null || typeof element.index === "string"); +} +function validPatch(field: string, value: unknown) { + if (["__proto__", "constructor", "prototype", "id", "boundElements", "version", "versionNonce"].includes(field)) return false; + if (field === "position" || field === "size") { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return (field === "position" ? [record.x, record.y] : [record.width, record.height]).every(finite); + } + if (field === "isDeleted") return typeof value === "boolean"; + if (field === "type") return typeof value === "string" && supported.has(value); + if (field === "index") return value === null || typeof value === "string"; + if (field === "groupIds") return Array.isArray(value) && value.every(id => typeof id === "string"); + return true; +} +const key = (id: string, field: string) => JSON.stringify([id, field]); +const stable = (value: unknown) => JSON.stringify(value, (_key, value) => value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => compare(a, b))) : value); +const same = (a: unknown, b: unknown) => stable(a) === stable(b); +const copy = (value: T): T => JSON.parse(JSON.stringify(value)); + +// The version nonce invalidates Excalidraw's render cache after a field merge. +// A shared revision counter would itself conflict during concurrent updates. +function hash(value: string) { + let result = 2166136261; + for (let i = 0; i < value.length; i++) result = Math.imul(result ^ value.charCodeAt(i), 16777619); + return result >>> 0; +} +function fields(element: ExcalidrawElement): Record { + const result: Record = {}; + for (const [field, value] of Object.entries(element)) { + if (!ignored.has(field) && !["x", "y", "width", "height"].includes(field) && value !== undefined) + result[field] = value; + } + result.position = { x: element.x, y: element.y }; + result.size = { width: element.width, height: element.height }; + return result; +} + +export function sameScene(a: readonly ExcalidrawElement[], b: readonly ExcalidrawElement[]) { + return same(a.map(element => [element.id, fields(element)]), b.map(element => [element.id, fields(element)])); +} + +function legacyElements(legacy: Y.Map): ExcalidrawElement[] { + const palette: Record = { yellow: "#fff3bf", blue: "#a5d8ff", mint: "#b2f2bb", rose: "#ffc9c9", white: "#ffffff" }; + const items = [...legacy.entries()].map(([id, value]) => canvasItem(id, value, + ["sticky", "rectangle", "ellipse"], Object.keys(palette))) + .filter(item => item !== undefined).sort((a, b) => a.order - b.order || compare(a.id, b.id)); + return items.flatMap((item, index) => { + const base = { + id: item.id, type: item.kind === "ellipse" ? "ellipse" : "rectangle", + ...item.position, ...item.size, strokeColor: "#1e1e1e", backgroundColor: palette[item.color], + fillStyle: "solid", strokeWidth: 1, strokeStyle: "solid", roughness: 0, opacity: 100, + angle: 0, seed: hash(item.id), version: 1, versionNonce: 0, updated: 0, + index: `a0${String(index).padStart(8, "0")}V`, isDeleted: false, groupIds: [], frameId: null, + boundElements: null, link: null, locked: false, roundness: null, + } as unknown as ExcalidrawElement; + if (!item.text) return [base]; + // Stable IDs let two clients open an old room without creating duplicate labels. + return [base, { ...base, id: `legacy-label:${item.id}`, type: "text", + x: base.x + 12, y: base.y + 12, width: Math.max(1, base.width - 24), height: 25, + backgroundColor: "transparent", text: item.text, originalText: item.text, + fontSize: 20, fontFamily: 2, textAlign: "left", verticalAlign: "middle", + containerId: item.id, autoResize: false, lineHeight: 1.25, index: `a0${String(index).padStart(8, "0")}W`, + } as ExcalidrawElement]; + }); +} export function createWhiteboardModel(doc: Y.Doc) { - const objects = doc.getMap("whiteboard:objects:v1"); + // Creation records are immutable. Each subsequent field has its own Yjs key, + // so a remote color edit cannot replace a local move or a different element. + const objects = doc.getMap("whiteboard:elements:v2"); + const changes = doc.getMap("whiteboard:fields:v2"); + const legacy = doc.getMap("whiteboard:objects:v1"); const origin = {}; - const history = new Y.UndoManager(objects, { trackedOrigins: new Set([origin]) }); - function list() { - return [...objects.entries()].map(([id, value]) => canvasItem(id, value, kinds, colors)) - .filter(item => item !== undefined) - .map(item => ({ ...item, position: boundedPosition(item.position, item.size) })) - .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); - } - function boundedPosition(position: Point, size: Size) { - return { x: clamp(position.x, 0, canvasSize.width - size.width), y: clamp(position.y, 0, canvasSize.height - size.height) }; - } - function action(fn: () => void) { - history.stopCapturing(); - doc.transact(fn, origin); - history.stopCapturing(); + const seenIds = new Set(); + const history = new Y.UndoManager([objects, changes], { trackedOrigins: new Set([origin]), captureTimeout: 500 }); + + function list(): ExcalidrawElement[] { + // Migration is a read-through projection. Opening a viewer never writes, and + // concurrent upgrades cannot overwrite one another's first edits. + const records = new Map(legacyElements(legacy).map(element => [element.id, element])); + for (const [id, element] of objects) if (validElement(element) && element.id === id) records.set(id, copy(element)); + for (const id of records.keys()) seenIds.add(id); + const fieldMaps = new Map([...records].map(([id, element]) => [id, fields(element)])); + for (const [entry, value] of changes) { + let parsed: unknown; + try { parsed = JSON.parse(entry); } catch { continue; } + if (!Array.isArray(parsed) || parsed.length !== 2 || !parsed.every(part => typeof part === "string")) continue; + const [id, field] = parsed as [string, string]; + const record = fieldMaps.get(id); + if (record && validPatch(field, value)) record[field] = value; + } + const elements = [...records].map(([id, base]) => { + const values = fieldMaps.get(id)!; + const { position, size, ...rest } = values; + const merged = { ...base, ...rest, ...(position as object), ...(size as object), boundElements: null } as unknown as ExcalidrawElement; + return { ...merged, versionNonce: 0 }; + }); + const byId = new Map(elements.map(element => [element.id, element])); + const bindings = new Map(); + for (const element of elements) { + if (element.isDeleted) continue; + const targets = element.type === "text" ? [element.containerId] : element.type === "arrow" + ? [element.startBinding?.elementId, element.endBinding?.elementId] : []; + for (const target of new Set(targets)) if (target && byId.has(target)) { + const refs = bindings.get(target) ?? []; + refs.push({ id: element.id, type: element.type as "arrow" | "text" }); + bindings.set(target, refs); + } + } + return elements.map(element => { + const merged = { ...element, boundElements: bindings.get(element.id)?.sort((a, b) => compare(a.id, b.id)) ?? null }; + return { ...merged, versionNonce: hash(stable(merged)) }; + }) + .sort((a, b) => compare(a.index ?? "", b.index ?? "") || compare(a.id, b.id)); } - const nextOrder = () => list().reduce((max, item) => Math.max(max, item.order), 0) + 1; + return { - objects, history, list, boundedPosition, - add(kind: string|undefined, position = { x: 80, y: 80 }) { - if (!kind || !kinds.includes(kind) || !Number.isFinite(position.x) || !Number.isFinite(position.y)) return; - const id = crypto.randomUUID(); - const size = kind === "sticky" ? { width: 220, height: 180 } : { width: 240, height: 140 }; - action(() => objects.set(id, new Y.Map([ - ["kind", kind], ["text", kind === "sticky" ? "New idea" : ""], - ["color", kind === "sticky" ? "yellow" : "blue"], - ["position", boundedPosition(position, size)], ["size", size], ["order", nextOrder()], - ]))); - return id; - }, - move(id: string|undefined, position: Point) { - const item = objects.get(id ?? ""); - if (!(item instanceof Y.Map) || !Number.isFinite(position.x) || !Number.isFinite(position.y)) return; - const next = boundedPosition(position, item.get("size")); - const previous = item.get("position"); - if (previous.x === next.x && previous.y === next.y) return; - action(() => item.set("position", next)); - }, - resize(id: string|undefined, size: Size) { - const item = objects.get(id ?? ""); - if (!(item instanceof Y.Map) || !Number.isFinite(size.width) || !Number.isFinite(size.height)) return; - const position = item.get("position"); - const next = { width: clamp(size.width, 80, Math.min(600, canvasSize.width - position.x)), - height: clamp(size.height, 60, Math.min(500, canvasSize.height - position.y)) }; - action(() => item.set("size", next)); - }, - edit(id: string|undefined, field: string, value: string) { - const item = objects.get(id ?? ""); - if (!(item instanceof Y.Map) || !["text", "color"].includes(field) || typeof value !== "string") return; - if (field === "color" && !colors.includes(value)) return; - if (item.get(field) === value) return; - doc.transact(() => item.set(field, field === "text" ? value.slice(0, 2000) : value), origin); + objects, changes, legacy, history, origin, list, + beginGesture() { history.stopCapturing(); history.captureTimeout = Infinity; }, + endGesture() { history.captureTimeout = 500; history.stopCapturing(); }, + apply(next: readonly ExcalidrawElement[], previous: readonly ExcalidrawElement[]) { + const before = new Map(previous.map(element => [element.id, fields(element)])); + const known = new Set(list().map(element => element.id)); + let changed = false; + doc.transact(() => { + for (const element of next) { + if (!validElement(element)) continue; + const prior = before.get(element.id); + if (!prior) { + // A stale scene must not recreate an element removed by remote undo. + if (known.has(element.id) || seenIds.has(element.id) || element.isDeleted) continue; + objects.set(element.id, copy(element)); + seenIds.add(element.id); + changed = true; + continue; + } + if (!known.has(element.id)) continue; + for (const [field, value] of Object.entries(fields(element))) { + if (!same(value, prior[field])) { + changes.set(key(element.id, field), copy(value)); + changed = true; + } + } + } + const present = new Set(next.map(element => element.id)); + for (const element of previous) if (!present.has(element.id) && known.has(element.id) && !element.isDeleted) { + changes.set(key(element.id, "isDeleted"), true); + changed = true; + } + }, origin); + return changed; }, - front(id: string) { - const item = objects.get(id); - if (item instanceof Y.Map) action(() => item.set("order", nextOrder())); + observe(callback: (transaction: Y.Transaction) => void) { + const handler = (_events: unknown, transaction: Y.Transaction) => callback(transaction); + objects.observeDeep(handler); changes.observeDeep(handler); legacy.observeDeep(handler); + return () => { objects.unobserveDeep(handler); changes.unobserveDeep(handler); legacy.unobserveDeep(handler); }; }, - remove(id: string|undefined) { action(() => objects.delete(id ?? "")); }, destroy() { history.destroy(); }, }; } diff --git a/package-lock.json b/package-lock.json index d135bbe..00668cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@codemirror/view": "6.43.11", "@dnd-kit/collision": "0.5.0", "@dnd-kit/react": "0.5.0", + "@excalidraw/excalidraw": "0.18.1", "@synixir/client": "0.1.0", "@tailwindcss/postcss": "4.3.3", "@tiptap/core": "3.31.3", @@ -73,6 +74,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@antfu/install-pkg": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.0.1.tgz", + "integrity": "sha512-iCKVQcIC0e3oDxEfs3SHQGW+ovhBMZmS1TE+bTk50rVyMCBmCfClv7Qi3HQKlumYwvjb/iIMeWCW2i67q6kFfQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.7.0", + "tinyexec": "^1.2.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -461,6 +475,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -506,6 +529,51 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, "node_modules/@codemirror/commands": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", @@ -1259,132 +1327,932 @@ "node": ">=18" } }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "node_modules/@excalidraw/excalidraw": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz", + "integrity": "sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "6.0.2", + "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/mermaid-to-excalidraw": "2.2.2", + "@excalidraw/random-username": "1.1.0", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-tabs": "1.0.2", + "browser-fs-access": "0.29.1", + "canvas-roundrect-polyfill": "0.0.1", + "clsx": "1.1.1", + "cross-env": "7.0.3", + "es6-promise-pool": "2.5.0", + "fractional-indexing": "3.2.0", + "fuzzy": "0.1.3", + "image-blob-reduce": "3.0.1", + "jotai": "2.11.0", + "jotai-scope": "0.7.2", + "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", + "nanoid": "3.3.3", + "open-color": "1.9.1", + "pako": "2.0.3", + "perfect-freehand": "1.2.0", + "pica": "7.1.1", + "png-chunk-text": "1.0.0", + "png-chunks-encode": "1.0.0", + "png-chunks-extract": "1.0.0", + "points-on-curve": "1.0.1", + "pwacompat": "2.0.17", + "roughjs": "4.6.4", + "sass": "1.51.0", + "tunnel-rat": "0.1.2" + }, + "peerDependencies": { + "react": "^17.0.2 || ^18.2.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.12" + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", - "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.8.0" + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT" + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@hono/node-server": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", "license": "MIT", - "engines": { - "node": ">=20" + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { - "hono": "^4" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@img/colour": { + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-id": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, - "funding": { - "url": "https://opencollective.com/libvips" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" }, - "funding": { - "url": "https://opencollective.com/libvips" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" }, - "engines": { - "node": ">=20.9.0" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://opencollective.com/libvips" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-roving-focus": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz", + "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", + "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz", + "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz", + "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/@excalidraw/laser-pointer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", + "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==", + "license": "MIT" + }, + "node_modules/@excalidraw/markdown-to-text": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz", + "integrity": "sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==", + "license": "MIT" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz", + "integrity": "sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==", + "license": "MIT", + "dependencies": { + "@excalidraw/markdown-to-text": "0.1.2", + "@mermaid-js/parser": "^0.6.3", + "mermaid": "^11.12.1", + "nanoid": "4.0.2" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@excalidraw/random-username": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz", + "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" @@ -1885,6 +2753,15 @@ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", "license": "MIT" }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -4846,12 +5723,102 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", @@ -4861,6 +5828,54 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -4870,12 +5885,78 @@ "@types/d3-color": "*" } }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "license": "MIT" }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -4895,6 +5976,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/http-proxy": { "version": "1.17.17", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", @@ -4944,6 +6031,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -5290,6 +6384,16 @@ "node": ">=16.20.0" } }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@xyflow/react": { "version": "12.11.6", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.6.tgz", @@ -5396,6 +6500,31 @@ "node": ">=8" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5456,6 +6585,18 @@ "node": ">=6.0.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -5517,6 +6658,12 @@ "node": ">=8" } }, + "node_modules/browser-fs-access": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz", + "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==", + "license": "Apache-2.0" + }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -5633,6 +6780,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-roundrect-polyfill": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz", + "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", + "license": "MIT" + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -5645,6 +6798,57 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5842,6 +7046,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", @@ -5868,65 +7081,231 @@ } } }, - "node_modules/crelt": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", - "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", "license": "MIT" }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" }, "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "internmap": "1 - 2" }, "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/cssesc": { + "node_modules/d3-axis": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" }, "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } }, "node_modules/d3-color": { "version": "3.1.0", @@ -5937,6 +7316,30 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dispatch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", @@ -5959,6 +7362,52 @@ "node": ">=12" } }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -5968,6 +7417,62 @@ "node": ">=12" } }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -5980,6 +7485,111 @@ "node": ">=12" } }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -5990,6 +7600,42 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -6034,6 +7680,22 @@ "node": ">=12" } }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debounce-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", @@ -6129,6 +7791,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6162,6 +7833,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -6304,6 +7984,27 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/es6-promise-pool": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", + "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -6538,6 +8239,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -6654,6 +8364,15 @@ "node": ">= 0.6" } }, + "node_modules/fractional-indexing": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -6681,7 +8400,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6701,6 +8419,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzy": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", + "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/fuzzysort": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", @@ -6814,6 +8540,12 @@ "node": ">= 6" } }, + "node_modules/glur": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6832,6 +8564,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6935,6 +8673,21 @@ "node": ">= 4" } }, + "node_modules/image-blob-reduce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", + "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==", + "license": "MIT", + "dependencies": { + "pica": "^7.1.0" + } + }, + "node_modules/immutable": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6951,12 +8704,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", @@ -6981,6 +8753,18 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -7183,6 +8967,38 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jotai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", + "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-scope": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz", + "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.9.2", + "react": ">=17.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7265,6 +9081,36 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -7274,6 +9120,28 @@ "node": ">=6" } }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lib0": { "version": "0.2.117", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", @@ -7581,6 +9449,24 @@ "node": ">=6" } }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -7636,6 +9522,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7685,6 +9583,75 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/mermaid/node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/mermaid/node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/mermaid/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/mermaid/node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7786,6 +9753,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multimath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "object-assign": "^4.1.1" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -7923,6 +9900,15 @@ "node": ">=18" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -8046,6 +10032,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-color": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", + "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==", + "license": "MIT" + }, "node_modules/ora": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", @@ -8138,6 +10130,18 @@ "node": ">=6" } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8195,6 +10199,12 @@ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "license": "MIT" }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -8223,6 +10233,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/perfect-freehand": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", + "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==", + "license": "MIT" + }, "node_modules/phoenix": { "version": "1.8.13", "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.13.tgz", @@ -8230,6 +10246,19 @@ "license": "MIT", "peer": true }, + "node_modules/pica": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "inherits": "^2.0.3", + "multimath": "^2.0.0", + "object-assign": "^4.1.1", + "webworkify": "^1.5.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8298,6 +10327,53 @@ "node": ">=20" } }, + "node_modules/png-chunk-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", + "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", + "license": "MIT" + }, + "node_modules/png-chunks-encode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", + "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0", + "sliced": "^1.0.1" + } + }, + "node_modules/png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0" + } + }, + "node_modules/points-on-curve": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", + "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/points-on-path/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -8543,6 +10619,12 @@ "node": ">= 0.10" } }, + "node_modules/pwacompat": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", + "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==", + "license": "Apache-2.0" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -8786,6 +10868,30 @@ } } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/recast": { "version": "0.23.21", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", @@ -8868,6 +10974,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", @@ -8908,6 +11020,24 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/roughjs": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", + "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/roughjs/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8959,12 +11089,35 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", + "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -9254,6 +11407,13 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", + "deprecated": "Unsupported", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -9317,6 +11477,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -9452,6 +11618,12 @@ } } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/synixir-collaboration-example": { "resolved": "examples/collaboration", "link": true @@ -9507,6 +11679,15 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9545,6 +11726,15 @@ "node": ">=0.6" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", @@ -9595,6 +11785,15 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -9805,6 +12004,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -9901,12 +12113,67 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/webworkify": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==", + "license": "MIT" + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", From d642e88b4847b1c6df3aa0c71c7c1b26f0eb4222 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 00:44:02 +0800 Subject: [PATCH 07/12] feat(examples): integrate React Hook Form and Zod --- .../collaboration/multiplayer-form/README.md | 20 + .../multiplayer-form/form-shell.tsx | 108 ++++ .../multiplayer-form/form-state.ts | 49 ++ .../collaboration/multiplayer-form/form.css | 9 +- .../collaboration/multiplayer-form/form.ts | 160 ++--- .../multiplayer-form/model.test.ts | 53 ++ .../collaboration/multiplayer-form/model.ts | 26 +- examples/collaboration/package.json | 5 +- .../tests/multiplayer-form.spec.ts | 28 +- package-lock.json | 585 ++++++++++++++++-- 10 files changed, 877 insertions(+), 166 deletions(-) create mode 100644 examples/collaboration/multiplayer-form/README.md create mode 100644 examples/collaboration/multiplayer-form/form-shell.tsx create mode 100644 examples/collaboration/multiplayer-form/form-state.ts diff --git a/examples/collaboration/multiplayer-form/README.md b/examples/collaboration/multiplayer-form/README.md new file mode 100644 index 0000000..2ee439b --- /dev/null +++ b/examples/collaboration/multiplayer-form/README.md @@ -0,0 +1,20 @@ +# Multiplayer form + +This example uses React Hook Form `7.88.0`, `@hookform/resolvers` `5.9.1`, and Zod `4.6.2` for local form state and validation. Synixir and Yjs still synchronize and save the project brief. + +`form-state.ts` uses React Hook Form's [`createFormControl`](https://react-hook-form.com/docs/createFormControl) API because the workspace mounts examples through an imperative room lifecycle. The adapter registers each field, mirrors completed Yjs transactions with `setValue`, and keeps touched, dirty, and error state local. `form-shell.tsx` renders the existing shadcn Field components around stable CodeMirror mounts. + +The Zod `briefSchema` describes a brief that can be reviewed. It checks required text, length limits, the selected team and priority, calendar dates, and launch channels. It does not trim or reject shared text while people type. Errors appear after leaving a field or choosing **Review brief**. A remote correction revalidates an already touched field without marking untouched fields as edited. Reviewing opens a live preview and writes nothing to the shared document. + +Each text field retains its existing `multiplayer-form::v1` Y.Text and `y-codemirror.next` binding. React and React Hook Form never replace editor text, so character merging, relative cursor positions, selections, and undo remain with Yjs. Each launch channel retains its own map key, allowing independent offline choices to merge. The room permission callback blocks mutations and history immediately when access changes; it also updates the displayed disabled state. + +From the repository root, run: + +```sh +node --import tsx --test examples/collaboration/multiplayer-form/model.test.ts +npm run typecheck +npm run build +npm run test:browser -- multiplayer-form.spec.ts placeholder-cursors.spec.ts +``` + +The browser tests cover concurrent edits, field presence, local undo, offline merging, persistence after a server restart, validation, preview updates, viewer restrictions, revocation, placeholder cursors, stable editor elements, and mobile layout. diff --git a/examples/collaboration/multiplayer-form/form-shell.tsx b/examples/collaboration/multiplayer-form/form-shell.tsx new file mode 100644 index 0000000..52a4568 --- /dev/null +++ b/examples/collaboration/multiplayer-form/form-shell.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { Fragment, type ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { Button } from "@/components/ui/button"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { channels, priorities, teams, textFields } from "./model.ts"; + +interface ShellState { + errors: Record; + readOnly: boolean; + reviewing: boolean; +} + +function FormField({ id, label, required = false, state, children }: { + id: string; label: string; required?: boolean; state: ShellState; children: ReactNode; +}) { + return +
    + + {label}{required ? : null} + + +
    + {children} + {state.errors[id]} +
    ; +} + +function FormShell({ state }: { state: ShellState }) { + return +
    +
    +

    PROJECT BRIEF

    Let's get on the same page.

    A shared starting point for your next project.

    + 0 of 4 complete +
    + +
    +

    01 The idea

    + + {textFields.map(spec => + {/* React owns the mount; CodeMirror alone owns its content. */} +
    + )} + +
    +
    +

    02 The details

    + + + + Choose a team + {teams.map(team => {team})} + + + + + {priorities.map(priority => {priority})} + + + + + + +
    +
    +

    03 Where it will live

    +
    + Launch channels + + + {channels.map(channel => + + {channel} + )} + + {state.errors.channels} + Choose as many as you need. You can decide later. +
    +
    +
    +

    Changes save as you go. Review a live preview when you're ready.

    + +
    +

    +

    + +
    +

    LIVE PREVIEW

    Project brief

    + +
    +

    This preview updates as your team edits. It does not submit the form.

    +
    +
    +
    ; +} + +export function createFormShell(container: HTMLElement) { + const root = createRoot(container); + flushSync(() => root.render()); + return { + update(state: ShellState) { root.render(); }, + destroy() { root.unmount(); }, + }; +} diff --git a/examples/collaboration/multiplayer-form/form-state.ts b/examples/collaboration/multiplayer-form/form-state.ts new file mode 100644 index 0000000..832d4c1 --- /dev/null +++ b/examples/collaboration/multiplayer-form/form-state.ts @@ -0,0 +1,49 @@ +import { createFormControl } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { Doc, Transaction } from "yjs"; +import { briefSchema, type createFormModel, type FormValues } from "./model.ts"; + +export type FormFieldName = keyof FormValues; + +export function createBriefFormState(doc: Doc, model: ReturnType) { + const control = createFormControl({ + defaultValues: model.values(), + resolver: zodResolver(briefSchema, undefined, { mode: "sync" }), + shouldFocusError: false, + }); + const names = Object.keys(model.values()) as FormFieldName[]; + for (const name of names) control.register(name); + let submitted = false; + const unsubscribe = control.subscribe({ + formState: { isSubmitted: true, touchedFields: true, dirtyFields: true, errors: true }, + callback: state => { submitted = state.isSubmitted ?? submitted; }, + }); + + function syncDraft(transaction: Transaction) { + const next = model.values(); + const current = control.getValues(); + const revalidate: FormFieldName[] = []; + for (const name of names) { + if (JSON.stringify(current[name]) === JSON.stringify(next[name])) continue; + // This mirror never resets or writes to an editor. Yjs remains the shared + // source, including drafts that cannot pass the review schema yet. + control.setValue(name, next[name], { shouldDirty: transaction.local }); + if (submitted || control.getFieldState(name).isTouched) revalidate.push(name); + } + // Validate after every changed value is mirrored, so cross-field rules see + // one complete transaction rather than a partly updated form. + if (revalidate.length) void control.trigger(revalidate); + } + doc.on("afterTransaction", syncDraft); + + return { + control, + touch(name: FormFieldName) { + control.setValue(name, model.values()[name], { shouldTouch: true, shouldValidate: true }); + }, + destroy() { + doc.off("afterTransaction", syncDraft); + unsubscribe(); + }, + }; +} diff --git a/examples/collaboration/multiplayer-form/form.css b/examples/collaboration/multiplayer-form/form.css index 211e712..3a3a9c1 100644 --- a/examples/collaboration/multiplayer-form/form.css +++ b/examples/collaboration/multiplayer-form/form.css @@ -14,22 +14,21 @@ .brief-section h3 { display: flex; align-items: center; gap: 10px; margin: 0 0 24px; font-size: 15px; font-weight: 600; } .brief-section h3 > span { color: #67826b; font-size: 11px; font-variant-numeric: tabular-nums; padding: 4px 6px; border: 1px solid #d7e1d4; border-radius: 5px; } .brief-field { min-width: 0; } -#text-fields .brief-field + .brief-field { margin-top: 22px; } .field-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 3px 12px; margin-bottom: 8px; } .field-heading label { font-size: 13px; font-weight: 600; margin: 0; } .required-marker { color: #6b7a70; } .field-people { color: var(--peer-color); font-size: 11px; line-height: 1.5; max-width: 100%; overflow-wrap: anywhere; } -.brief-field select, .brief-field input[type="date"] { width: 100%; font-size: 14px; padding: 11px 12px; } +.brief-field select, .brief-field input[type="date"] { min-height: 44px; } .brief-text { border: 1px solid #b6c2b9; border-radius: 6px; background: #fff; } #editor .brief-text .cm-editor { min-height: 96px; font-size: 14px; border-radius: 6px; } #editor .brief-text .cm-content { min-height: 96px; padding: 10px 12px; } #editor .brief-name .cm-editor, #editor .brief-name .cm-content { min-height: 44px; } #editor .brief-text .cm-scroller { line-height: 1.65; } #editor .brief-text .cm-editor.cm-focused { outline: 2px solid #357c63; outline-offset: -1px; } -.brief-field.has-collaborator .brief-text, .brief-field.has-collaborator > select, .brief-field.has-collaborator > input { border-color: var(--peer-color); box-shadow: 0 0 0 1px var(--peer-color); } +.brief-field.has-collaborator .brief-text, .brief-field.has-collaborator select, .brief-field.has-collaborator > input { border-color: var(--peer-color); box-shadow: 0 0 0 1px var(--peer-color); } .brief-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px 20px; } -.channel-options { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; } -.channel-option { display: flex; align-items: center; gap: 8px; margin: 0; padding: 10px 14px; border: 1px solid #cbd5ca; border-radius: 7px; font-size: 13px; cursor: pointer; } +.channel-options { display: flex; flex-direction: row; flex-wrap: wrap; gap: 10px; } +.channel-option { display: flex; width: auto; align-items: center; gap: 8px; margin: 0; padding: 10px 14px; border: 1px solid #cbd5ca; border-radius: 7px; font-size: 13px; cursor: pointer; } .channel-option:has(:checked) { border-color: #6a9979; background: #eff6ed; } .channel-option input { width: 16px; height: 16px; margin: 0; accent-color: #245e48; } .channel-option:has(:disabled) { cursor: default; } diff --git a/examples/collaboration/multiplayer-form/form.ts b/examples/collaboration/multiplayer-form/form.ts index 500a9f2..b662960 100644 --- a/examples/collaboration/multiplayer-form/form.ts +++ b/examples/collaboration/multiplayer-form/form.ts @@ -3,32 +3,26 @@ import { EditorView, drawSelection, keymap, placeholder } from "@codemirror/view import { Compartment, EditorState, Prec } from "@codemirror/state"; import { defaultKeymap } from "@codemirror/commands"; import { yCollab, yUndoManagerKeymap } from "y-codemirror.next"; -import { createFormModel, textFields, teams, priorities, channels, requiredFields, validate } from "./model.ts"; +import { createFormModel, textFields, requiredFields, validate } from "./model.ts"; +import { createFormShell } from "./form-shell.tsx"; +import { createBriefFormState, type FormFieldName } from "./form-state.ts"; export function createMultiplayerForm(room: SynixirRoom) { const root = document.querySelector("#editor")!; document.querySelector("#open-peer")!.href = window.location.href; const model = createFormModel(room.doc); + const briefForm = createBriefFormState(room.doc, model); + const shell = createFormShell(root); const events = new AbortController(); const on = (target: EventTarget, event: K, handler: (event: DocumentEventMap[K]) => void, capture = false) => target.addEventListener(event, handler as EventListener, { signal: events.signal, capture }); - const editors = new Map(); - const fields = new Map(); - const touched = new Set(); + const editors = new Map(); + const fields = new Map(); let readOnly = true; let activeField: string | null = null; let disposed = false; - root.innerHTML = ` -
    -

    PROJECT BRIEF

    Let’s get on the same page.

    A shared starting point for your next project.

    0 of 4 complete
    - -

    01 The idea

    -

    02 The details

    -

    03 Where it will live

    -

    Changes save as you go. Review a live preview when you’re ready.

    -

    -
    -

    LIVE PREVIEW

    Project brief

    This preview updates as your team edits. It does not submit the form.

    `; const form = root.querySelector("form")!; const dialog = root.querySelector("dialog")!; const undo = document.querySelector("#undo")!; @@ -39,42 +33,24 @@ export function createMultiplayerForm(room: SynixirRoom) { if (text !== undefined) node.textContent = text; return node; } - function field(id: string|undefined, label: string|undefined, parent: HTMLElement, required = false) { - const node = element("div", "brief-field"); - node.dataset.field = id; - const heading = element("div", "field-heading"); - const title = element("label", "", label); - title.id = `label-${id}`; - title.htmlFor = `field-${id}`; - if (required) { - const marker = element("span", "required-marker", " *"); - marker.setAttribute("aria-hidden", "true"); - title.append(marker); - } - const people = element("span", "field-people"); - people.id = `people-${id}`; - const error = element("p", "field-error"); - error.id = `error-${id}`; - error.hidden = true; - heading.append(title, people); - node.append(heading, error); - parent.append(node); - fields.set(id, { node, title, people, error }); - return node; + for (const node of root.querySelectorAll("[data-field]")) { + const id = node.dataset.field as FormFieldName; + fields.set(id, { node, title: root.querySelector(`#label-${id}`)!, + people: root.querySelector(`#people-${id}`)!, + control: node.querySelector("select, input[type=date]") ?? undefined, + }); } function publish() { room.awareness.setLocalStateField("multiplayerForm", activeField ? { field: activeField, action: readOnly ? "viewing" : "editing" } : null); } - function activate(id: any) { + function activate(id: string) { if (activeField === id) return; model.history.stopCapturing(); activeField = id; publish(); } for (const spec of textFields) { - const node = field(spec.id, spec.label, root.querySelector("#text-fields")!, true); - const mount = element("div", `brief-text ${spec.id === "name" ? "brief-name" : ""}`); - node.insertBefore(mount, fields.get(spec.id).error); + const mount = root.querySelector(`#editor-${spec.id}`)!; const permission = new Compartment(); const editor = new EditorView({ parent: mount, @@ -93,48 +69,17 @@ export function createMultiplayerForm(room: SynixirRoom) { yCollab(model.texts[spec.id], room.awareness, { undoManager: model.history }), ], }); - on(fields.get(spec.id).title, "click", () => editor.focus()); + on(fields.get(spec.id)!.title, "click", () => editor.focus()); editors.set(spec.id, { editor, permission }); } - function input(id: string, label: string, tag: "select" | "input", values: string[] = [], required = false) { - const node = field(id, label, root.querySelector("#detail-fields")!, required); - const control = document.createElement(tag); - control.id = `field-${id}`; - control.name = id; - control.required = required; - control.setAttribute("aria-describedby", `people-${id} error-${id}`); - if (tag === "select") { - for (const value of values) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value || "Choose a team"; - control.append(option); - } - } else (control as HTMLInputElement).type = "date"; - node.insertBefore(control, fields.get(id).error); - fields.get(id).control = control; + for (const id of ["team", "priority", "date"] as const) { + const control = fields.get(id)!.control!; on(control, "change", () => { if (!readOnly) model.set(id, control.value); }); } - input("team", "Team", "select", ["", ...teams], true); - input("priority", "Priority", "select", priorities); - input("date", "Target date", "input"); - const channelNode = field("channels", "Launch channels", root.querySelector("#channel-field")!); - const choices = element("div", "channel-options"); - choices.setAttribute("role", "group"); - choices.setAttribute("aria-labelledby", "label-channels"); - fields.get("channels").title.removeAttribute("for"); - for (const channel of channels) { - const label = element("label", "channel-option"); - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.value = channel; - checkbox.setAttribute("aria-describedby", "people-channels"); - label.append(checkbox, document.createTextNode(channel)); - choices.append(label); - on(checkbox, "change", () => { if (!readOnly) model.set(`channel:${channel}`, checkbox.checked); }); + const choices = root.querySelector(".channel-options")!; + for (const checkbox of choices.querySelectorAll("input")) { + on(checkbox, "change", () => { if (!readOnly) model.set(`channel:${checkbox.value}`, checkbox.checked); }); } - channelNode.insertBefore(choices, fields.get("channels").error); - channelNode.append(element("p", "note", "Choose as many as you need. You can decide later.")); on(form, "focusin", (event) => { const id = (event.target as Element).closest("[data-field]")?.dataset.field; @@ -142,7 +87,7 @@ export function createMultiplayerForm(room: SynixirRoom) { }); on(form, "focusout", (event) => { const id = (event.target as Element).closest("[data-field]")?.dataset.field; - if (id) { touched.add(id); render(); } + if (id && fields.has(id as FormFieldName)) briefForm.touch(id as FormFieldName); if ((event.relatedTarget as Element | null)?.closest("[data-field]")?.dataset.field !== activeField) { activeField = null; model.history.stopCapturing(); @@ -170,22 +115,19 @@ export function createMultiplayerForm(room: SynixirRoom) { event.preventDefault(); if (!readOnly) key === "y" || event.shiftKey ? model.history.redo() : model.history.undo(); }); - on(form, "submit", (event) => { - event.preventDefault(); - for (const id of fields.keys()) touched.add(id); - render(); - const errors = validate(model.values()); - const first = Object.keys(errors)[0]; - if (first) { - root.querySelector("#brief-announcement")!.textContent = "Complete the highlighted fields to review your brief."; - if (editors.has(first)) editors.get(first).editor.focus(); - else fields.get(first).control.focus(); - return; - } + const review = briefForm.control.handleSubmit(() => { + if (disposed) return; root.querySelector("#brief-announcement")!.textContent = ""; renderPreview(model.values()); - dialog.showModal(); + if (!dialog.open) dialog.showModal(); + }, errors => { + if (disposed) return; + const first = Object.keys(errors)[0]; + root.querySelector("#brief-announcement")!.textContent = "Complete the highlighted fields to review your brief."; + if (first && editors.has(first)) editors.get(first)!.editor.focus(); + else if (first) fields.get(first as FormFieldName)?.control?.focus(); }); + on(form, "submit", event => { event.preventDefault(); void review(); }); on(root.querySelector("#close-preview")!, "click", () => dialog.close()); function renderPreview(values: import("./model.ts").FormValues) { @@ -203,16 +145,11 @@ export function createMultiplayerForm(room: SynixirRoom) { if (disposed) return; const values = model.values(); const errors = validate(values); - for (const [id, { control, error }] of fields) { + for (const [id, { control }] of fields) { if (control) { - if (control.value !== String(values[id as keyof typeof values] ?? "")) control.value = String(values[id as keyof typeof values] ?? ""); + if (control.value !== String(values[id] ?? "")) control.value = String(values[id] ?? ""); control.disabled = readOnly; } - const message = touched.has(id) ? errors[id] ?? "" : ""; - if (error.textContent !== message) error.textContent = message; - error.hidden = !message; - const target = control ?? editors.get(id)?.editor.contentDOM; - if (target) target.setAttribute("aria-invalid", String(Boolean(message))); } for (const checkbox of choices.querySelectorAll("input")) { checkbox.checked = values.channels.includes(checkbox.value); @@ -237,6 +174,26 @@ export function createMultiplayerForm(room: SynixirRoom) { node.classList.toggle("has-collaborator", current.length > 0); } } + let reviewing = false; + function renderValidation() { + if (disposed) return; + const errors: Record = {}; + for (const [id, { control }] of fields) { + const message = briefForm.control.getFieldState(id).error?.message; + if (message) errors[id] = message; + const target = control ?? editors.get(id)?.editor.contentDOM; + target?.setAttribute("aria-invalid", String(Boolean(message))); + } + if (Object.keys(errors).length === 0) root.querySelector("#brief-announcement")!.textContent = ""; + shell.update({ errors, readOnly, reviewing }); + } + const unsubscribeForm = briefForm.control.subscribe({ + formState: { errors: true, touchedFields: true, isValidating: true }, + callback: state => { + reviewing = state.isValidating ?? reviewing; + renderValidation(); + }, + }); for (const text of Object.values(model.texts)) text.observe(render); model.properties.observe(render); room.awareness.on("change", renderPresence); @@ -248,11 +205,13 @@ export function createMultiplayerForm(room: SynixirRoom) { for (const text of Object.values(model.texts)) text.unobserve(render); model.properties.unobserve(render); room.awareness.off("change", renderPresence); + unsubscribeForm(); + briefForm.destroy(); for (const { editor } of editors.values()) editor.destroy(); model.destroy(); clearPresence(); dialog.close(); - root.replaceChildren(); + shell.destroy(); } destroy.setReadOnly = (value: boolean) => { if (value !== readOnly) { @@ -260,6 +219,7 @@ export function createMultiplayerForm(room: SynixirRoom) { for (const { editor, permission } of editors.values()) editor.dispatch({ effects: permission.reconfigure(EditorState.readOnly.of(value)) }); publish(); render(); + renderValidation(); } renderPresence(); }; diff --git a/examples/collaboration/multiplayer-form/model.test.ts b/examples/collaboration/multiplayer-form/model.test.ts index 26ab582..1c86963 100644 --- a/examples/collaboration/multiplayer-form/model.test.ts +++ b/examples/collaboration/multiplayer-form/model.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import * as Y from "yjs"; import { createFormModel, validate } from "./model.ts"; +import { createBriefFormState } from "./form-state.ts"; test("form fields and separate channel choices merge, while conflicting choices converge", () => { const a = new Y.Doc(); @@ -58,3 +59,55 @@ test("validation preserves incomplete drafts and checks text lengths and dates", assert.equal(model.values().date, "2028-02-29"); } finally { model.destroy(); doc.destroy(); } }); + +test("form state validates a shared draft without writing to Yjs or marking remote fields touched", async () => { + const doc = new Y.Doc(); + const peerDoc = new Y.Doc(); + const model = createFormModel(doc); + const peer = createFormModel(peerDoc); + const form = createBriefFormState(doc, model); + const sync = () => Y.applyUpdate(doc, Y.encodeStateAsUpdate(peerDoc)); + try { + peer.texts.name.insert(0, "N".repeat(121)); + sync(); + assert.equal(form.control.getValues("name"), "N".repeat(121)); + assert.equal(form.control.getFieldState("name").isTouched, false); + assert.equal(form.control.getFieldState("name").isDirty, false); + assert.equal(form.control.getFieldState("name").error, undefined); + form.touch("name"); + await form.control.trigger("name"); + assert.equal(form.control.getFieldState("name").error?.message, "Use 120 characters or fewer."); + assert.equal(model.texts.name.length, 121); + peerDoc.transact(() => { + peer.texts.name.delete(0, 121); + peer.texts.name.insert(0, " Our launch "); + peer.texts.goal.insert(0, "Plan together"); + peer.texts.audience.insert(0, "Product teams"); + peer.set("team", "Product"); + }); + sync(); + await form.control.trigger(); + assert.equal(form.control.getFieldState("name").error, undefined); + assert.equal(form.control.getFieldState("name").isTouched, true); + assert.equal(form.control.getFieldState("goal").isTouched, false); + const before = Y.encodeStateAsUpdate(doc); + let reviewed = false; + await form.control.handleSubmit(values => { + reviewed = true; + assert.equal(values.name, " Our launch "); + })(); + assert.equal(reviewed, true); + assert.deepEqual(Y.encodeStateAsUpdate(doc), before); + } finally { + form.destroy(); model.destroy(); peer.destroy(); doc.destroy(); peerDoc.destroy(); + } +}); + +test("review schema rejects malformed choices and impossible dates", () => { + const valid = { name: "Launch", goal: "Plan together", audience: "Teams", team: "Product", date: "2028-02-29", priority: "Normal", channels: ["Website"] }; + assert.deepEqual(validate(valid), {}); + assert.deepEqual(validate({ ...valid, date: "2027-02-29", priority: "Now", channels: ["Unknown"] }), { + date: "Choose a valid date.", priority: "Choose a priority.", channels: "Choose a launch channel.", + }); + assert.equal(validate({ ...valid, name: " " }).name, "Project name is required."); +}); diff --git a/examples/collaboration/multiplayer-form/model.ts b/examples/collaboration/multiplayer-form/model.ts index 3fe3089..c8b23f9 100644 --- a/examples/collaboration/multiplayer-form/model.ts +++ b/examples/collaboration/multiplayer-form/model.ts @@ -1,4 +1,5 @@ import * as Y from "yjs"; +import { z } from "zod"; export interface FormValues { name: string; goal: string; audience: string; team: string; date: string; priority: string; channels: string[] } export const textFields = [ @@ -18,14 +19,29 @@ function validDate(value: string) { return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; } +const textSchema = (field: typeof textFields[number]) => z.string() + .refine(value => value.trim().length > 0, `${field.label} is required.`) + .max(field.max, `Use ${field.max} characters or fewer.`); + +// This schema validates a reviewable brief. Shared Y.Text drafts deliberately +// accept incomplete and over-limit text, and validation never rewrites it. +export const briefSchema = z.object({ + name: textSchema(textFields[0]), + goal: textSchema(textFields[1]), + audience: textSchema(textFields[2]), + team: z.string().refine(value => teams.includes(value), "Choose a team."), + date: z.string().refine(validDate, "Choose a valid date."), + priority: z.string().refine(value => priorities.includes(value), "Choose a priority."), + channels: z.array(z.string().refine(value => channels.includes(value), "Choose a launch channel.")), +}); + export function validate(values: Omit & Partial>) { + const result = briefSchema.safeParse({ priority: "Normal", channels: [], ...values }); const errors: Record = {}; - for (const field of textFields) { - if (!values[field.id].trim()) errors[field.id] = `${field.label} is required.`; - else if (values[field.id].length > field.max) errors[field.id] = `Use ${field.max} characters or fewer.`; + if (!result.success) for (const issue of result.error.issues) { + const field = String(issue.path[0]); + errors[field] ??= issue.message; } - if (!teams.includes(values.team)) errors.team = "Choose a team."; - if (!validDate(values.date)) errors.date = "Choose a valid date."; return errors; } diff --git a/examples/collaboration/package.json b/examples/collaboration/package.json index 8a6dfba..dcf1fc5 100644 --- a/examples/collaboration/package.json +++ b/examples/collaboration/package.json @@ -20,6 +20,7 @@ "@dnd-kit/collision": "0.5.0", "@dnd-kit/react": "0.5.0", "@excalidraw/excalidraw": "0.18.1", + "@hookform/resolvers": "5.9.1", "@synixir/client": "0.1.0", "@tailwindcss/postcss": "4.3.3", "@tiptap/core": "3.31.3", @@ -36,11 +37,13 @@ "react": "19.3.0", "react-data-grid": "7.0.0-beta.61", "react-dom": "19.3.0", + "react-hook-form": "7.88.0", "shadcn": "4.21.0", "tailwindcss": "4.3.3", "tw-animate-css": "1.4.0", "y-codemirror.next": "0.3.6", - "yjs": "13.6.32" + "yjs": "13.6.32", + "zod": "4.6.2" }, "devDependencies": { "@playwright/test": "1.63.0", diff --git a/examples/collaboration/tests/multiplayer-form.spec.ts b/examples/collaboration/tests/multiplayer-form.spec.ts index 284066f..1cd8a0e 100644 --- a/examples/collaboration/tests/multiplayer-form.spec.ts +++ b/examples/collaboration/tests/multiplayer-form.spec.ts @@ -55,6 +55,27 @@ test("form shares fields, field presence and local undo without replacing focuse await saved(page); }); +test("form validation stays local and remote corrections preserve the active editor", async ({ page, context, baseURL }) => { + const { url } = await setup(page, baseURL); + const peer = await context.newPage(); + await peer.goto(url); + await saved(peer); + const name = field(page, "Project name"); + const original = await name.elementHandle(); + await name.fill("N".repeat(121)); + await hasValue(peer, "Project name", "N".repeat(121)); + await expect(page.locator("#error-name")).toHaveCount(0); + await field(page, "What are we making?").click(); + await expect(page.locator("#error-name")).toHaveText("Use 120 characters or fewer."); + await expect(peer.locator("#error-name")).toHaveCount(0); + await field(peer, "Project name").fill("A corrected shared draft"); + await expect(page.locator("#error-name")).toHaveCount(0); + await hasValue(page, "Project name", "A corrected shared draft"); + await expect(field(page, "What are we making?")).toBeFocused(); + expect(await name.evaluate((node, before) => node === before, original)).toBe(true); + await saved(peer); +}); + test("same-field offline edits and checkbox choices merge and persist after restart", async ({ page, context, baseURL, backend }) => { const { url } = await setup(page, baseURL); await field(page, "Project name").fill("Launch"); @@ -91,7 +112,7 @@ test("same-field offline edits and checkbox choices merge and persist after rest await expect(fresh.getByRole("checkbox", { name: "Website", exact: true })).toBeChecked(); }); -test("form validates required fields, shows a live preview and keeps other example content separate", async ({ page, context, baseURL }) => { +test("form validates required fields, shows a live preview and keeps other example content separate", async ({ page, context, baseURL }, testInfo) => { const { roomId, url } = await setup(page, baseURL); await page.getByRole("button", { name: "Review brief", exact: true }).click(); await expect(field(page, "Project name")).toHaveAttribute("aria-invalid", "true"); @@ -99,7 +120,9 @@ test("form validates required fields, shows a live preview and keeps other examp await expect(page.getByRole("dialog")).not.toBeVisible(); await fillBrief(page); await expect(page.locator("#brief-completion")).toHaveText("4 of 4 complete"); + await expect(page.locator("#brief-announcement")).toHaveText(""); await page.getByLabel("Target date", { exact: true }).fill("2027-06-15"); + await page.screenshot({ path: testInfo.outputPath("form-desktop.png"), fullPage: true }); await page.getByRole("button", { name: "Review brief", exact: true }).click(); await expect(page.getByRole("dialog")).toContainText("A shared launch"); await expect(page.getByRole("dialog")).toContainText("2027-06-15"); @@ -119,7 +142,7 @@ test("form validates required fields, shows a live preview and keeps other examp await hasValue(peer, "Project name", "A better shared launch"); }); -test("form viewers and revoked editors cannot mutate fields or use history; mobile fits", async ({ page, browser, baseURL }) => { +test("form viewers and revoked editors cannot mutate fields or use history; mobile fits", async ({ page, browser, baseURL }, testInfo) => { const { roomId, url } = await setup(page, baseURL); await fillBrief(page); const viewerContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); @@ -136,6 +159,7 @@ test("form viewers and revoked editors cannot mutate fields or use history; mobi await field(viewer, "Who is it for?").click(); await expect(page.locator("#people-audience")).toHaveText(`${user.username} viewing`); expect(await viewer.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await viewer.screenshot({ path: testInfo.outputPath("form-mobile.png"), fullPage: true }); await role("editor"); await viewer.reload(); await saved(viewer); diff --git a/package-lock.json b/package-lock.json index 00668cf..8395335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@dnd-kit/collision": "0.5.0", "@dnd-kit/react": "0.5.0", "@excalidraw/excalidraw": "0.18.1", + "@hookform/resolvers": "5.9.1", "@synixir/client": "0.1.0", "@tailwindcss/postcss": "4.3.3", "@tiptap/core": "3.31.3", @@ -47,11 +48,13 @@ "react": "19.3.0", "react-data-grid": "7.0.0-beta.61", "react-dom": "19.3.0", + "react-hook-form": "7.88.0", "shadcn": "4.21.0", "tailwindcss": "4.3.3", "tw-animate-css": "1.4.0", "y-codemirror.next": "0.3.6", - "yjs": "13.6.32" + "yjs": "13.6.32", + "zod": "4.6.2" }, "devDependencies": { "@playwright/test": "1.63.0", @@ -62,6 +65,126 @@ "node": ">=22.12.0" } }, + "examples/collaboration/node_modules/@hookform/resolvers": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.1.tgz", + "integrity": "sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0 || ^18.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=6.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "examples/collaboration/node_modules/zod": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", + "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", @@ -2038,6 +2161,30 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@excalidraw/excalidraw/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/@excalidraw/excalidraw/node_modules/clsx": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", @@ -2047,6 +2194,12 @@ "node": ">=6" } }, + "node_modules/@excalidraw/excalidraw/node_modules/immutable": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "license": "MIT" + }, "node_modules/@excalidraw/excalidraw/node_modules/nanoid": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", @@ -2059,6 +2212,47 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/@excalidraw/excalidraw/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/sass": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", + "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@excalidraw/laser-pointer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", @@ -2981,6 +3175,294 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@playwright/test": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", @@ -4784,6 +5266,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -6826,27 +7314,19 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", + "optional": true, "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" } }, "node_modules/class-variance-authority": { @@ -8683,10 +9163,11 @@ } }, "node_modules/immutable": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", - "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", - "license": "MIT" + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "license": "MIT", + "optional": true }, "node_modules/import-fresh": { "version": "3.3.1", @@ -9891,6 +10372,14 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.55", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", @@ -10799,6 +11288,23 @@ "react": "^19.3.0" } }, + "node_modules/react-hook-form": { + "version": "7.88.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz", + "integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -10869,27 +11375,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "license": "MIT", + "optional": true, "engines": { - "node": ">=8.6" + "node": ">= 20.19.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/recast": { @@ -11101,23 +11597,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sass": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", - "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", - "license": "MIT", - "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", From a495df3b5080f479a5bf10bb0245bf2b013a07b3 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 00:48:09 +0800 Subject: [PATCH 08/12] docs: describe the example library integrations --- README.md | 82 +++++++++++---------- examples/collaboration/README.md | 23 +++++- examples/collaboration/whiteboard/README.md | 3 +- 3 files changed, 66 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e019fc6..ce785c7 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,9 @@ The production build also includes `/kanban.html`. - Add cards to Backlog, In progress, or Done. Open a card to edit its title, description, color, and status. Changes save as you type. -- Drag cards between columns, or use the status selector with a keyboard or on - a touch device. Moved cards go to the end of the destination column. +- Drag cards within or between columns using a mouse or touch. Focus a card's + move handle and press Space to sort with the keyboard, then Escape to cancel. + The status selector remains available for moving a card to another column. - Open the same board in two tabs to see changes and card activity. To collaborate with another account, grant its username access under **Manage access**. - Undo and redo affect this tab's local changes. Deleted cards can be restored @@ -211,8 +212,10 @@ so simultaneous moves converge to one location without duplicating a card. Edits to different fields merge. Concurrent edits to the same title, description, color, or placement resolve through Y.Map's conflict rules to one value; these fields do not provide character-level text merging. A deletion wins over a -concurrent edit to that card. Ordering ties use the card ID. This first example -has fixed columns and does not support reordering within a column. +concurrent edit to that card. Fixed columns support card reordering through +`@dnd-kit/react`. Fractional ranks include stable card IDs to resolve ordering +ties without rewriting a column. Existing numeric orders remain readable. +See the [kanban integration notes](examples/collaboration/kanban/README.md). The [shared React application](examples/collaboration/components/example-app.tsx) owns account, room, connection, permission, and cleanup UI for all examples. Each editing @@ -236,32 +239,28 @@ same development servers running. Sign in and create a room, or open an existing one. Links between the examples retain the room. Each example stores its data separately inside that room's Yjs document. -- Add sticky notes, rectangles, and ellipses. Select an object to edit its text, - color, width, or height, bring it to the front, or delete it. -- Drag objects with a mouse or touch. Arrow keys move the selected object one - canvas pixel; Shift + arrows move it ten. Escape cancels an active drag. -- Scroll around the fixed 1600 × 1000 canvas. Zoom controls range from 50% to - 150%; zoom and scroll are local to each tab. -- Teammates see cursors, selection outlines, and live movement previews. Presence - labels are client-supplied and do not establish identity or lock objects. -- Remote cursors and drag previews interpolate between presence updates with - an 80 ms animation. Local dragging updates on the next animation frame without - interpolation. Reduced-motion preferences disable interpolation. Cursor updates - reuse existing activity elements and do not rebuild the participant list or - edit controls. Presence still sends at most one scheduled movement update per - 60 ms, with immediate final positions and activity changes. -- A completed drag saves one position change and creates one undo step. Movement - previews use temporary awareness state. Cancelling a drag or closing its tab - before releasing it leaves the saved position unchanged. -- Undo and redo affect this tab's changes. Viewers can select objects and publish - cursors, but cannot change content, geometry, or layer order. - -The [whiteboard model](examples/collaboration/whiteboard/model.ts) uses nested -Y.Maps under `whiteboard:objects:v1`. Position and size are separate atomic values, -so moving an object does not overwrite a teammate's text or color edits. -Simultaneous edits to the same property resolve to one value through Y.Map's -conflict rules. Text does not merge at the character level. Deleting an object -wins over a concurrent edit to that object. The UI bounds objects to the canvas. +- Draw shapes, freehand lines, text, and arrows with Excalidraw's native tools. + Select, resize, group, and reorder objects, or pan and zoom around the scene. +- Teammates see cursors, selections, and live changes. Presence labels are + client-supplied and do not establish identity or lock objects. +- Undo and redo affect this tab's changes. A pointer gesture creates one undo + item. Shape changes synchronize during the gesture. +- Viewers can explore the scene and publish cursors, but cannot edit it. +- Fonts load from local assets included in development and production builds. + Image insertion, embedded content, and scene-file import are disabled because + this example has no attachment store. Local drawing export is available. + +The [whiteboard model](examples/collaboration/whiteboard/model.ts) stores creation +records in `whiteboard:elements:v2` and property changes in `whiteboard:fields:v2`. +Position and size are separate atomic pairs, so moving an object does not overwrite +another participant's text or color edits. Concurrent changes to the same field +resolve through Yjs conflict rules. Text does not merge at the character level. +Deletion wins over a concurrent property edit. + +Saved `whiteboard:objects:v1` scenes remain readable through a deterministic +adapter, including their sticky-note labels. Opening an old room does not write +migration updates. See the [Excalidraw integration notes](examples/collaboration/whiteboard/README.md) +for the scene model, asset setup, and package constraints. Offline edits remain in the open tab until reconnecting. Wait for **Saved** before closing it. The production build includes `/whiteboard.html` and uses the same @@ -335,6 +334,9 @@ The [form model](examples/collaboration/multiplayer-form/model.ts) stores text i separate top-level Y.Text fields named `multiplayer-form::v1`, with choices in `multiplayer-form:properties:v1`. The text controls reuse the existing CodeMirror/Yjs binding for shared selections, composition input, and undo. +React Hook Form tracks local touched fields and review state, with Zod validation +through `@hookform/resolvers`. Incomplete shared drafts remain in Yjs while users +edit. See the [form integration notes](examples/collaboration/multiplayer-form/README.md). The `multiplayerForm` awareness field identifies the active form field. All data is separate from the other examples, with the same room permissions and storage. @@ -354,15 +356,16 @@ Open [127.0.0.1:5173/flowchart.html](http://127.0.0.1:5173/flowchart.html) with same development servers running. Create a room or use an existing one, then open another tab to build a workflow together. -- Add process, decision, and start/end nodes. Drag nodes, move them with arrow - keys, and edit their labels, colors, and sizes. Zoom and scroll to explore. +- Add process, decision, and start/end nodes. React Flow provides dragging, + resize handles, pan/zoom controls, and a minimap. Arrow keys move selected + nodes; the inspector edits labels, colors, and sizes. - Select a source node and choose a destination in **Connect to**, or use - **Pick on canvas** and click another node. Label branches such as Yes and No. + **Pick on canvas** and click another node. You can also drag between node + connection handles. Label branches such as Yes and No. - Select an arrow or its entry in **Connections** to edit its label or delete it. Deleting a node removes its attached arrows. One undo restores both. - Live cursors, collaborator selections, and drag previews show where teammates are working. Arrows follow the displayed node positions during dragging. - Cursor labels use participant colors with white text, matching the whiteboard. - Undo and redo affect this tab's changes. Viewers can explore and select nodes and connections, while edits and history controls require editing permission. @@ -376,8 +379,8 @@ Arrows with deleted endpoints stay hidden, including ones created offline during a deletion. Undo can restore their endpoints. The `flowchart` awareness field carries temporary cursor, selection, and drag -positions. The canvas reuses the whiteboard's motion helper and commits one -position change per completed drag. Other examples keep separate data and cursor +positions. The React Flow adapter interpolates remote drag previews and commits +one position change per completed drag. Other examples keep separate data and cursor fields within the same room. This is a diagram editor; it does not execute workflows or automatically route arrows around intervening nodes. @@ -427,8 +430,11 @@ columns independently of concurrent renames or text edits. Deleted cells remain in the document so undo can restore them. The UI allows adding up to 100 rows and 12 columns, and pasting at most 50,000 characters at a time. These are example interaction limits, not server-enforced quotas; -concurrent additions can exceed them. Only the active cell mounts a CodeMirror -editor. Other cells keep stable DOM elements and display shared text updates. +concurrent additions can exceed them. `react-data-grid` provides navigation, +resizable columns, and virtualization. Column widths are local to each tab. +The active cell and cells with remote editors mount CodeMirror bindings so peers' +carets remain visible. Other visible cells render plain text. See the +[table integration notes](examples/collaboration/table/README.md). The `collaborativeTable` awareness field carries the selected row and column; text caret positions use the existing CodeMirror/Yjs awareness binding. diff --git a/examples/collaboration/README.md b/examples/collaboration/README.md index 41d5f69..49a90f3 100644 --- a/examples/collaboration/README.md +++ b/examples/collaboration/README.md @@ -11,7 +11,7 @@ npm ci npm run dev --workspace synixir-collaboration-example ``` -The `predev` step compiles the SDK. Open http://127.0.0.1:5173. The development +The `predev` step compiles the SDK and copies Excalidraw fonts into local assets. Open http://127.0.0.1:5173. The development server in `dev.ts` proxies `/api` and `/socket` to Phoenix at http://127.0.0.1:4000; set `SYNIXIR_ENDPOINT` to override it. The proxy preserves the browser's Origin header so Phoenix can enforce its origin allowlist. @@ -21,11 +21,28 @@ room selection, and navigation. `components/workspace/workspace.tsx` owns each SDK connection and its subscriptions, permissions, presence, and teardown. The editor/canvas modules mount inside a React-owned host and release their listeners and bindings when that host unmounts. They load only when selected. -The [whiteboard](whiteboard/README.md) embeds Excalidraw with a Yjs scene adapter -and locally served fonts. `components/ui/` contains the generated shadcn primitives, and `app/globals.css` contains Tailwind, theme tokens, and shared layout styles. +## Libraries by example + +| Example | UI and editing packages | Shared data | +|---|---|---| +| Text editor | CodeMirror 6, `y-codemirror.next` | Y.Text | +| [Kanban](kanban/README.md) | `@dnd-kit/react`, `@dnd-kit/collision` | Y.Map cards and sortable placements | +| [Whiteboard](whiteboard/README.md) | `@excalidraw/excalidraw` | Yjs creation records and field changes | +| Rich text | Tiptap and its collaboration extension | Y.XmlFragment | +| [Project brief](multiplayer-form/README.md) | React Hook Form, `@hookform/resolvers`, Zod, CodeMirror | Y.Text fields and Y.Map choices | +| [Flowchart](flowchart/README.md) | `@xyflow/react` | Y.Map nodes and connections | +| [Table](table/README.md) | `react-data-grid`, CodeMirror | Y.Text cells and Y.Map schema | +| Shared settings | Native controls and `@synixir/client` | Y.Map | + +Every example uses the same Synixir/Phoenix connection and persistence. The UI +packages do not introduce another collaboration service. The text, rich-text, +and shared-settings examples retain their existing integrations. + +## Checks and production build + ```sh npm run typecheck npm test diff --git a/examples/collaboration/whiteboard/README.md b/examples/collaboration/whiteboard/README.md index 59c690d..9e07ede 100644 --- a/examples/collaboration/whiteboard/README.md +++ b/examples/collaboration/whiteboard/README.md @@ -57,7 +57,8 @@ The `0.18.1` package currently pulls `nanoid` `3.3.3` and `4.0.2`, and `lodash-es` `4.17.21`. npm audit reports advisories in those transitive dependencies. Parent-scoped and version-qualified npm overrides did not change the resolved workspace dependencies, including after a clean install; no -ineffective override is retained. +ineffective override is retained. npm tracks this workspace resolution behavior +in [npm/cli#9659](https://github.com/npm/cli/issues/9659). ## Verification From 04f32543e32a30c6f32396ba32c6d7e7717a28f2 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 00:51:53 +0800 Subject: [PATCH 09/12] fix(examples): restore flowchart layout and presence styles --- examples/collaboration/flowchart/canvas.css | 29 +++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/examples/collaboration/flowchart/canvas.css b/examples/collaboration/flowchart/canvas.css index 56f9c91..107b7af 100644 --- a/examples/collaboration/flowchart/canvas.css +++ b/examples/collaboration/flowchart/canvas.css @@ -1,7 +1,26 @@ /* Shape rendering and the inspector are app UI; React Flow owns the canvas. */ .flowchart-editor { color: var(--foreground); background: var(--card); min-width: 0; } -.flowchart-editor #whiteboard-viewport { height: 520px; overflow: hidden; background: var(--background); } -.flowchart-editor .react-flow { --xy-background-color: var(--background); --xy-node-color: var(--foreground); --xy-edge-stroke: var(--muted-foreground); --xy-edge-stroke-selected: var(--primary); --xy-controls-button-background-color: var(--card); --xy-controls-button-background-color-hover: var(--muted); --xy-controls-button-color: var(--foreground); --xy-controls-button-border-color: var(--border); --xy-minimap-background-color: var(--card); --xy-minimap-mask-background-color: color-mix(in srgb, var(--background) 65%, transparent); } +/* These layout rules belong to the flowchart, independently of the whiteboard editor. */ +.flowchart-editor .whiteboard-tools { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 14px; padding: 14px; border-bottom: 1px solid var(--border); } +.flowchart-editor .object-tools, .flowchart-editor .zoom-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.flowchart-editor #zoom-level { min-width: 46px; text-align: center; font-size: 13px; font-variant-numeric: tabular-nums; } +.flowchart-editor .canvas-summary { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); color: var(--muted-foreground); font-size: 13px; } +.flowchart-editor .remote-selection { position: absolute; left: 0; top: 0; border: 2px solid; outline: 1px solid var(--card); } +.flowchart-editor .remote-selection > span { position: absolute; left: -2px; bottom: calc(100% + 2px); color: white; font-size: 13px; line-height: 1.4; padding: 3px 7px; border-radius: 4px 4px 0 0; white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis; } +.flowchart-editor .remote-cursor { position: absolute; left: 0; top: 0; will-change: transform; } +.flowchart-editor .remote-cursor > svg { display: block; width: 28px; height: 28px; fill: currentColor; stroke: currentColor; stroke-width: 1.5; stroke-linejoin: round; } +.flowchart-editor .remote-cursor > span { position: absolute; left: 24px; top: 22px; border-radius: 10px; padding: 6px 11px; color: white; font-size: 16px; font-weight: 500; line-height: 1.25; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.flowchart-editor .object-inspector { padding: 18px; border-top: 1px solid var(--border); } +.flowchart-editor #selection-empty { margin: 0; font-size: 14px; color: var(--muted-foreground); line-height: 1.6; } +.flowchart-editor .inspector-heading { display: flex; justify-content: space-between; align-items: center; gap: 15px; margin-bottom: 12px; } +.flowchart-editor .inspector-heading h2 { margin: 0; } +.flowchart-editor .inspector-heading > div { display: flex; flex-wrap: wrap; gap: 8px; } +.flowchart-editor .inspector-fields { display: grid; grid-template-columns: minmax(140px, 2fr) minmax(95px, 1fr) 85px 85px; gap: 14px; align-items: start; } +.flowchart-editor .inspector-fields textarea { width: 100%; font: inherit; color: inherit; } +.flowchart-editor [data-slot="native-select"] { padding: 4px 32px 4px 10px; font-size: 13px; line-height: 20px; } +.flowchart-editor .inspector-fields textarea { border: 1px solid var(--input); border-radius: 6px; padding: 10px; line-height: 1.5; resize: vertical; } +.flowchart-editor #whiteboard-viewport { position: relative; width: 100%; min-width: 0; height: 520px; overflow: hidden; overscroll-behavior: contain; background: var(--background); } +.flowchart-editor .react-flow { width: 100%; height: 100%; --xy-background-color: var(--background); --xy-node-color: var(--foreground); --xy-edge-stroke: var(--muted-foreground); --xy-edge-stroke-selected: var(--primary); --xy-controls-button-background-color: var(--card); --xy-controls-button-background-color-hover: var(--muted); --xy-controls-button-color: var(--foreground); --xy-controls-button-border-color: var(--border); --xy-minimap-background-color: var(--card); --xy-minimap-mask-background-color: color-mix(in srgb, var(--background) 65%, transparent); } .flowchart-editor .flow-node { padding: 0; background: transparent; border: 0; font-size: 17px; color: #223e33; } .flowchart-editor .flow-node.selected, .flowchart-editor .flow-node:focus-visible { outline: 2px solid var(--ring); outline-offset: 5px; } .flow-shape { display: grid; place-items: center; width: 100%; height: 100%; padding: 16px; border: 1.5px solid #819b8d; border-radius: 10px; background: var(--flow-node-fill); box-shadow: 0 3px 8px #28332c12; } @@ -41,3 +60,9 @@ .flow-connections #connection-list button { text-align: left; overflow-wrap: anywhere; white-space: normal; height: auto; min-height: 32px; } .flow-connections #connection-list [aria-pressed="true"] { background: var(--muted); border-color: var(--ring); } @media (max-width: 640px) { .flowchart-editor #whiteboard-viewport { height: 440px; } .flowchart-editor .react-flow__minimap { display: none; } } + +@media (max-width: 640px) { + .flowchart-editor .inspector-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .flowchart-editor .object-text-field { grid-column: 1 / -1; } + .flowchart-editor .inspector-heading { align-items: flex-start; flex-wrap: wrap; } +} From ca14cdebf3ce1712615f0af44cea004fc4f4cb31 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 01:14:05 +0800 Subject: [PATCH 10/12] fix(examples): restore smooth remote cursor motion The library adapters displayed cursors directly at awareness packet positions, dropping interpolation. Share a frame-based cursor interpolator, publish the first whiteboard movement immediately, and skip scene processing for presence-only callbacks. Add browser checks for intermediate positions, bounded settling time, reduced motion, and zero cursor-triggered Yjs transactions. --- examples/collaboration/flowchart/README.md | 5 +- examples/collaboration/flowchart/presence.ts | 24 ++++-- examples/collaboration/lib/cursor-motion.ts | 73 +++++++++++++++++++ .../collaboration/tests/flowchart.spec.ts | 43 +++++++++++ .../tests/whiteboard-motion.spec.ts | 49 ++++++++++++- examples/collaboration/whiteboard/README.md | 7 +- .../collaboration/whiteboard/excalidraw.tsx | 33 ++++++++- 7 files changed, 219 insertions(+), 15 deletions(-) create mode 100644 examples/collaboration/lib/cursor-motion.ts diff --git a/examples/collaboration/flowchart/README.md b/examples/collaboration/flowchart/README.md index ff90992..7a94d3a 100644 --- a/examples/collaboration/flowchart/README.md +++ b/examples/collaboration/flowchart/README.md @@ -30,7 +30,10 @@ mutation paths. `presence.ts` sends cursor, selected node, and drag previews through Synixir awareness at most once per 60 ms. Remote drag positions interpolate over 80 ms, respecting reduced motion. Arrows read the same React Flow node positions as the -canvas so they stay attached during interpolation. Viewports remain local. +canvas so they stay attached during interpolation. Remote cursors interpolate +through the shared `lib/cursor-motion.ts` helper over 60 ms, without rebuilding +the graph or writing to Yjs. Reduced-motion and hidden tabs use the received +position directly. Viewports remain local. Synixir continues to own room access, offline edits, reconnect, and persistence. The implementation follows the public [custom-node API](https://reactflow.dev/learn/customization/custom-nodes), diff --git a/examples/collaboration/flowchart/presence.ts b/examples/collaboration/flowchart/presence.ts index 8cd933b..2cacdf1 100644 --- a/examples/collaboration/flowchart/presence.ts +++ b/examples/collaboration/flowchart/presence.ts @@ -2,6 +2,7 @@ import type { SynixirRoom } from "@synixir/client"; import type { Point } from "../lib/canvas-data.ts"; import type { FlowchartAdapter } from "./adapter.ts"; import { canvasSize } from "./model.ts"; +import { createCursorMotion } from "../lib/cursor-motion.ts"; export interface FlowPeer { id: number; name: string; color: string; selectedId: string | null; cursor: Point | null; drag: (Point & { id: string }) | null } function validPoint(value: unknown): value is Point { @@ -12,6 +13,7 @@ function validPoint(value: unknown): value is Point { export function createFlowchartPresence(room: SynixirRoom, adapter: FlowchartAdapter) { let peers: FlowPeer[] = []; + let targets: FlowPeer[] = []; let cursor: Point | null = null; let disposed = false; let timer: ReturnType | undefined; @@ -19,6 +21,11 @@ export function createFlowchartPresence(room: SynixirRoom, adapter: FlowchartAda let lastPublished = -Infinity; let queued = "", published = ""; const listeners = new Set<() => void>(); + const cursorMotion = createCursorMotion(paintCursors); + function paintCursors() { + peers = targets.map(peer => ({ ...peer, cursor: cursorMotion.get(peer.id) ?? null })); + for (const listener of listeners) listener(); + } const motion = new Map(); const reduced = matchMedia("(prefers-reduced-motion: reduce)"); const localState = () => { @@ -55,7 +62,7 @@ export function createFlowchartPresence(room: SynixirRoom, adapter: FlowchartAda } function readPeers() { if (disposed) return; - peers = room.state.connection === "connected" ? [...room.awareness.getStates()] + targets = room.state.connection === "connected" ? [...room.awareness.getStates()] .filter(([id, state]) => id !== room.awareness.clientID && state.flowchart && typeof state.flowchart === "object") .sort(([a], [b]) => a - b).map(([id, state]) => { const flow = state.flowchart; @@ -65,22 +72,23 @@ export function createFlowchartPresence(room: SynixirRoom, adapter: FlowchartAda cursor: validPoint(flow.cursor) ? flow.cursor : null, drag: validPoint(flow.drag) && "id" in flow.drag && typeof flow.drag.id === "string" ? flow.drag as Point & { id: string } : null }; }) : []; + cursorMotion.update(new Map(targets.flatMap(peer => peer.cursor ? [[peer.id, peer.cursor] as const] : []))); const items = adapter.getSnapshot().items; - const targets = new Map(); - for (const peer of peers) if (peer.drag) { + const dragTargets = new Map(); + for (const peer of targets) if (peer.drag) { const item = items.find(item => item.id === peer.drag!.id); - if (item) targets.set(item.id, adapter.model.boundedPosition(peer.drag, item.size)); + if (item) dragTargets.set(item.id, adapter.model.boundedPosition(peer.drag, item.size)); } let changed = false; - for (const id of motion.keys()) if (!targets.has(id)) { motion.delete(id); changed = true; } - for (const [id, target] of targets) { + for (const id of motion.keys()) if (!dragTargets.has(id)) { motion.delete(id); changed = true; } + for (const [id, target] of dragTargets) { const previous = motion.get(id); if (previous?.target.x === target.x && previous.target.y === target.y) continue; const from = previous?.current ?? items.find(item => item.id === id)!.position; motion.set(id, { from, current: from, target, start: performance.now() }); changed = true; } if (changed && frame === undefined) frame = requestAnimationFrame(tick); - for (const listener of listeners) listener(); + paintCursors(); } const unsubscribeGraph = adapter.subscribe(schedulePublish); room.awareness.on("change", readPeers); @@ -97,7 +105,7 @@ export function createFlowchartPresence(room: SynixirRoom, adapter: FlowchartAda disposed = true; clearTimeout(timer); if (frame !== undefined) cancelAnimationFrame(frame); unsubscribeGraph(); unsubscribeRoom(); room.awareness.off("change", readPeers); reduced.removeEventListener("change", settle); document.removeEventListener("visibilitychange", settle); - listeners.clear(); motion.clear(); room.awareness.setLocalStateField("flowchart", null); + cursorMotion.destroy(); listeners.clear(); motion.clear(); room.awareness.setLocalStateField("flowchart", null); }, }; } diff --git a/examples/collaboration/lib/cursor-motion.ts b/examples/collaboration/lib/cursor-motion.ts new file mode 100644 index 0000000..33cfa96 --- /dev/null +++ b/examples/collaboration/lib/cursor-motion.ts @@ -0,0 +1,73 @@ +import type { Point } from "./canvas-data.ts"; + +type Motion = { from: Point; current: Point; target: Point; started: number; moving: boolean }; + +// Render between awareness packets without increasing network traffic. Only +// cursor presentation changes here; document state and local input stay direct. +export function createCursorMotion(onFrame: () => void) { + const positions = new Map(); + const reduced = matchMedia("(prefers-reduced-motion: reduce)"); + const duration = 60; + let frame: number | undefined; + let disposed = false; + + function tick(time: number) { + frame = undefined; + let moving = false; + for (const state of positions.values()) { + if (!state.moving) continue; + const progress = Math.min(1, Math.max(0, (time - state.started) / duration)); + state.current = { + x: state.from.x + (state.target.x - state.from.x) * progress, + y: state.from.y + (state.target.y - state.from.y) * progress, + }; + state.moving = progress < 1; + moving ||= state.moving; + } + onFrame(); + if (moving) frame = requestAnimationFrame(tick); + } + function settle() { + if (frame !== undefined) cancelAnimationFrame(frame); + frame = undefined; + let changed = false; + for (const state of positions.values()) { + changed ||= state.moving; + state.current = state.target; + state.moving = false; + } + if (changed) onFrame(); + } + const visibility = () => { if (document.hidden) settle(); }; + reduced.addEventListener("change", settle); + document.addEventListener("visibilitychange", visibility); + + return { + get: (key: Key) => positions.get(key)?.current, + update(targets: ReadonlyMap) { + if (disposed) return; + for (const key of positions.keys()) if (!targets.has(key)) positions.delete(key); + for (const [key, point] of targets) { + const state = positions.get(key); + if (state?.target.x === point.x && state.target.y === point.y) continue; + const target = { ...point }; + const smooth = state && !reduced.matches && !document.hidden; + const from = smooth ? state.current : target; + positions.set(key, { from, current: from, target, started: performance.now(), moving: Boolean(smooth) }); + } + if ([...positions.values()].some(state => state.moving)) { + if (frame === undefined) frame = requestAnimationFrame(tick); + } else if (frame !== undefined) { + cancelAnimationFrame(frame); + frame = undefined; + } + }, + destroy() { + disposed = true; + if (frame !== undefined) cancelAnimationFrame(frame); + positions.clear(); + reduced.removeEventListener("change", settle); + document.removeEventListener("visibilitychange", visibility); + }, + }; +} diff --git a/examples/collaboration/tests/flowchart.spec.ts b/examples/collaboration/tests/flowchart.spec.ts index a3d66fd..e0f6abd 100644 --- a/examples/collaboration/tests/flowchart.spec.ts +++ b/examples/collaboration/tests/flowchart.spec.ts @@ -224,3 +224,46 @@ test("React Flow handles connect nodes, resize once per gesture, and pan without expect(await page.evaluate(() => JSON.stringify(window.synixirTest.room!.doc.toJSON()))).toBe(beforeDocument); await peer.close(); }); + +for (const reducedMotion of [false, true]) test(`flowchart interpolates sparse remote cursor updates, reduced motion=${reducedMotion}`, async ({ page, context, baseURL }) => { + const { url } = await setup(page, baseURL); + await page.goto(url); await saved(page); + const peer = await context.newPage(); + await peer.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" }); + await peer.goto(url); await saved(peer); + await page.locator('#whiteboard-viewport').scrollIntoViewIfNeeded(); + const box = (await page.locator('#whiteboard-viewport').boundingBox())!; + await page.mouse.move(box.x + 250, box.y + 300); + await expect(peer.locator('.remote-cursor')).toBeVisible(); + await expect.poll(async () => (await position(peer.locator('.remote-cursor'))).x).toBeCloseTo(250, 0); + const docBefore = await peer.evaluate(() => JSON.stringify(window.synixirTest.room!.doc.toJSON())); + const samples = peer.evaluate(async () => { + const points: { x: number; time: number }[] = []; + let received: number | undefined; + const started = performance.now(); + while (performance.now() - started < 300) { + await new Promise(requestAnimationFrame); + const time = performance.now(); + const remote = [...window.synixirTest.room!.awareness.getStates()] + .find(([id]) => id !== window.synixirTest.room!.awareness.clientID)?.[1]; + if (received === undefined && remote?.flowchart?.cursor?.x === 450) received = time; + const cursor = document.querySelector('.remote-cursor'); + if (cursor) points.push({ x: new DOMMatrixReadOnly(getComputedStyle(cursor).transform).m41, time }); + } + return { points, received }; + }); + await page.mouse.move(box.x + 450, box.y + 300); + const { points, received } = await samples; + const intermediate = new Set(points.filter(point => point.x > 250.5 && point.x < 449.5).map(point => Math.round(point.x * 10))); + if (reducedMotion) expect(intermediate.size).toBe(0); + else expect(intermediate.size, `Cursor must animate between awareness packets; sampled x=${JSON.stringify(points)}`).toBeGreaterThanOrEqual(2); + expect(points.at(-1)?.x).toBeCloseTo(450, 0); + expect(received).toBeDefined(); + const settled = points.find(point => Math.abs(point.x - 450) < 0.5)!; + const delay = settled.time - received!; + expect(delay, "Cursor should settle promptly after the received update").toBeLessThan(150); + console.log(`flowchart cursor: ${intermediate.size} intermediate positions, ${Math.round(delay)}ms to settle, reduced motion=${reducedMotion}`); + expect(await peer.evaluate(() => JSON.stringify(window.synixirTest.room!.doc.toJSON()))).toBe(docBefore); + await page.mouse.move(box.x + 250, box.y - 20); + await expect(peer.locator('.remote-cursor')).toHaveCount(0); +}); diff --git a/examples/collaboration/tests/whiteboard-motion.spec.ts b/examples/collaboration/tests/whiteboard-motion.spec.ts index d0b2ba7..042a54c 100644 --- a/examples/collaboration/tests/whiteboard-motion.spec.ts +++ b/examples/collaboration/tests/whiteboard-motion.spec.ts @@ -18,17 +18,21 @@ for (const reducedMotion of [false, true]) test(`Excalidraw cursor motion leaves const stable = peer.evaluate(async () => { const canvas = document.querySelector(".excalidraw__canvas.interactive"); const roster = document.querySelector("#participants li:last-child"); - let mutations = 0; + let mutations = 0, sceneTransactions = 0; + const doc = window.synixirTest.room!.doc; + const onTransaction = () => { sceneTransactions++; }; + doc.on("afterTransaction", onTransaction); const observer = new MutationObserver(records => { mutations += records.length; }); observer.observe(document.querySelector("#participants")!, { childList: true, subtree: true, characterData: true, attributes: true }); await new Promise(resolve => setTimeout(resolve, 500)); observer.disconnect(); + doc.off("afterTransaction", onTransaction); return { sameCanvas: canvas === document.querySelector(".excalidraw__canvas.interactive"), - sameRoster: roster === document.querySelector("#participants li:last-child"), mutations }; + sameRoster: roster === document.querySelector("#participants li:last-child"), mutations, sceneTransactions }; }); await page.mouse.move(box!.x + 450, box!.y + 400, { steps: 8 }); await expect.poll(async () => (await remotePointer())?.x).toBeCloseTo(original.x + 200, 0); - expect(await stable).toEqual({ sameCanvas: true, sameRoster: true, mutations: 0 }); + expect(await stable).toEqual({ sameCanvas: true, sameRoster: true, mutations: 0, sceneTransactions: 0 }); expect(await peer.evaluate(() => JSON.stringify(window.synixirTest.room!.doc.toJSON()))).toBe(docBefore); await page.mouse.move(box!.x + 250, box!.y - 20); await expect.poll(remotePointer).toBeUndefined(); @@ -53,3 +57,42 @@ test("Excalidraw shows a peer's active drag and selection before pointer release await page.mouse.up(); await saved(page); expect((await scene(peer))[0].x).toBe((await scene(page))[0].x); }); + +for (const reducedMotion of [false, true]) test(`Excalidraw interpolates sparse remote cursor updates, reduced motion=${reducedMotion}`, async ({ page, context, baseURL }) => { + const { url } = await setup(page, baseURL); + await page.goto(url); await saved(page); + const peer = await context.newPage(); + await peer.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" }); + await peer.goto(url); await saved(peer); + await canvas(page).scrollIntoViewIfNeeded(); + const box = (await canvas(page).boundingBox())!; + await page.mouse.move(box.x + 250, box.y + 400); + const pointerX = () => peer.evaluate(() => [...window.synixirWhiteboardTest!.getAppState().collaborators.values()][0]?.pointer?.x); + await expect.poll(pointerX).toBeCloseTo(250, 0); + const samples = peer.evaluate(async () => { + const points: { x: number; time: number }[] = []; + let received: number | undefined; + const started = performance.now(); + while (performance.now() - started < 300) { + await new Promise(requestAnimationFrame); + const time = performance.now(); + const remote = [...window.synixirTest.room!.awareness.getStates()] + .find(([id]) => id !== window.synixirTest.room!.awareness.clientID)?.[1]; + if (received === undefined && remote?.whiteboard?.pointer?.x === 450) received = time; + const x = [...window.synixirWhiteboardTest!.getAppState().collaborators.values()][0]?.pointer?.x; + if (typeof x === "number") points.push({ x, time }); + } + return { points, received }; + }); + await page.mouse.move(box.x + 450, box.y + 400); + const { points, received } = await samples; + const intermediate = new Set(points.filter(point => point.x > 250.5 && point.x < 449.5).map(point => Math.round(point.x * 10))); + if (reducedMotion) expect(intermediate.size).toBe(0); + else expect(intermediate.size, `Cursor must animate between awareness packets; sampled x=${JSON.stringify(points)}`).toBeGreaterThanOrEqual(2); + expect(points.at(-1)?.x).toBeCloseTo(450, 0); + expect(received).toBeDefined(); + const settled = points.find(point => Math.abs(point.x - 450) < 0.5)!; + const delay = settled.time - received!; + expect(delay, "Cursor should settle promptly after the received update").toBeLessThan(150); + console.log(`Excalidraw cursor: ${intermediate.size} intermediate positions, ${Math.round(delay)}ms to settle, reduced motion=${reducedMotion}`); +}); diff --git a/examples/collaboration/whiteboard/README.md b/examples/collaboration/whiteboard/README.md index 9e07ede..93ea4c6 100644 --- a/examples/collaboration/whiteboard/README.md +++ b/examples/collaboration/whiteboard/README.md @@ -36,7 +36,12 @@ room, including as a viewer, does not write migration updates or add undo items. ## Presence and permissions Pointers and selected element IDs use the `whiteboard` awareness field and -Excalidraw's collaborator renderer. Presence never enters the scene document. +Excalidraw's collaborator renderer. The first movement after idle publishes +immediately; subsequent movements coalesce at 60 ms intervals. Remote cursors +interpolate over 60 ms through `lib/cursor-motion.ts`, shared with the flowchart. +Reduced-motion and hidden tabs use the received position directly. Cursor-only +callbacks skip scene diffing and Yjs transactions when element revisions have +not changed. Presence never enters the scene document. The adapter clears the pointer on exit/blur, removes disconnected peers, and cleans up subscriptions when leaving the room. Excalidraw view mode follows the SDK's read-only flag, including live access revocation. The first received scene diff --git a/examples/collaboration/whiteboard/excalidraw.tsx b/examples/collaboration/whiteboard/excalidraw.tsx index 9645c4a..009e158 100644 --- a/examples/collaboration/whiteboard/excalidraw.tsx +++ b/examples/collaboration/whiteboard/excalidraw.tsx @@ -7,6 +7,7 @@ import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import type { Collaborator, ExcalidrawImperativeAPI, ExcalidrawProps, SocketId } from "@excalidraw/excalidraw/types"; import type { SynixirRoom } from "@synixir/client"; import { createWhiteboardModel, sameScene } from "./model.ts"; +import { createCursorMotion } from "../lib/cursor-motion.ts"; declare global { interface Window { synixirWhiteboardTest?: ExcalidrawImperativeAPI } } const cloneScene = (scene: readonly ExcalidrawElement[]): ExcalidrawElement[] => JSON.parse(JSON.stringify(scene)); @@ -33,6 +34,10 @@ export function createWhiteboard(room: SynixirRoom) { let pendingFrame: number | undefined; let presenceTimer: ReturnType | undefined; let lastPresence = ""; + let lastPublished = -Infinity; + let peerTargets = new Map(); + let lastPeers = ""; + const cursorMotion = createCursorMotion(paintCollaborators); let pointer: { x: number; y: number; tool: "pointer" | "laser" } | null = null; let button: "up" | "down" = "up"; let selectedElementIds: Record = {}; @@ -82,12 +87,24 @@ export function createWhiteboard(room: SynixirRoom) { const encoded = JSON.stringify(state); if (encoded === lastPresence) return; lastPresence = encoded; + lastPublished = performance.now(); room.awareness.setLocalStateField("whiteboard", state); } function schedulePresence() { - if (presenceTimer === undefined) presenceTimer = setTimeout(publish, 60); + if (presenceTimer !== undefined) return; + const delay = 60 - (performance.now() - lastPublished); + if (delay <= 0) publish(); else presenceTimer = setTimeout(publish, delay); } function clearPointer() { pointer = null; button = "up"; publish(); } + function paintCollaborators() { + if (disposed || !api || !initialized) return; + const peers = new Map(); + for (const [id, peer] of peerTargets) { + const point = cursorMotion.get(id); + peers.set(id, { ...peer, ...(point ? { pointer: { ...point, tool: "pointer" as const } } : {}) }); + } + api.updateScene({ collaborators: peers, captureUpdate: CaptureUpdateAction.NEVER }); + } function collaborators() { if (disposed || !api || !initialized) return; const peers = new Map(); @@ -106,7 +123,12 @@ export function createWhiteboard(room: SynixirRoom) { ? { pointer: { x: remote.pointer.x, y: remote.pointer.y, tool: "pointer" as const } } : {}), }); } - api.updateScene({ collaborators: peers, captureUpdate: CaptureUpdateAction.NEVER }); + const encoded = JSON.stringify([...peers]); + if (encoded === lastPeers) return; + lastPeers = encoded; + peerTargets = peers; + cursorMotion.update(new Map([...peers].flatMap(([id, peer]) => peer.pointer ? [[id, peer.pointer] as const] : []))); + paintCollaborators(); } room.awareness.on("change", collaborators); const stopState = room.subscribe(collaborators); @@ -119,6 +141,12 @@ export function createWhiteboard(room: SynixirRoom) { selectedElementIds = { ...selected }; schedulePresence(); } + // Excalidraw calls onChange for cursor/app-state updates too. Element + // revisions change on edits, so presence frames need no scene diff or Yjs + // transaction. Compare each revision, including order, without hashing it. + const unchanged = elements.length === previous.length && elements.every((element, index) => + element.id === previous[index].id && element.version === previous[index].version && element.versionNonce === previous[index].versionNonce); + if (unchanged) return; if (readOnly) { if (!sameScene(elements, previous)) schedulePaint(); return; } // File import and image insertion are disabled. Also reject clipboard, // library, and keyboard paths which may bypass the visible tool options. @@ -196,6 +224,7 @@ export function createWhiteboard(room: SynixirRoom) { function destroy() { disposed = true; clearTimeout(presenceTimer); + cursorMotion.destroy(); if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame); stopModel(); stopState(); room.awareness.off("change", collaborators); From b0d89382975e109dca4ff4ad00fcbe1b9d83916a Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 01:28:52 +0800 Subject: [PATCH 11/12] fix(ci): retry transient staging navigation changes Retry only Chromium ERR_NETWORK_CHANGED navigation failures up to three attempts. Keep other navigation and HTTP failures visible, add regression coverage, and report browser diagnostics when the staging pilot fails. --- package.json | 2 +- scripts/staging-pilot.ts | 14 ++++-- scripts/support/browser-navigation.test.ts | 51 ++++++++++++++++++++++ scripts/support/browser-navigation.ts | 20 +++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 scripts/support/browser-navigation.test.ts create mode 100644 scripts/support/browser-navigation.ts diff --git a/package.json b/package.json index 7dfa086..faf1f52 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "node": ">=22.12.0" }, "scripts": { - "test": "npm test --workspace @synixir/client && node --import tsx --test examples/collaboration/kanban/model.test.ts examples/collaboration/whiteboard/model.test.ts examples/collaboration/flowchart/model.test.ts examples/collaboration/table/model.test.ts examples/collaboration/multiplayer-form/model.test.ts", + "test": "npm test --workspace @synixir/client && node --import tsx --test examples/collaboration/kanban/model.test.ts examples/collaboration/whiteboard/model.test.ts examples/collaboration/flowchart/model.test.ts examples/collaboration/table/model.test.ts examples/collaboration/multiplayer-form/model.test.ts scripts/support/browser-navigation.test.ts", "test:package": "node --import tsx scripts/check-client-package.ts", "test:browser": "npm test --workspace synixir-collaboration-example --", "build": "npm run build --workspace @synixir/client && npm run build --workspace synixir-collaboration-example", diff --git a/scripts/staging-pilot.ts b/scripts/staging-pilot.ts index da2e644..c66ce37 100644 --- a/scripts/staging-pilot.ts +++ b/scripts/staging-pilot.ts @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import https from "node:https"; import { BrowserContext, type Page, type BrowserContextOptions, chromium, expect } from "@playwright/test"; +import { navigateStaging } from "./support/browser-navigation.ts"; const [mode, baseURL, manifestPath] = process.argv.slice(2); assert.equal(new URL(baseURL).hostname, "localhost", "pilot is restricted to local staging"); @@ -53,7 +54,7 @@ async function account(role: string) { async function open(ctx: BrowserContext, roomId: string, role: string|RegExp|readonly (string|RegExp)[]) { const page = await ctx.newPage(); - const response = await page.goto(`/?room=${roomId}`); + const response = await navigateStaging(page, `/?room=${roomId}`); assert.ok(response); assert.equal(response.status(), 200); assert.equal(response.headers()["cache-control"], "no-store"); @@ -133,13 +134,13 @@ try { assert.equal(denied.status(), 403); const settings = await owner.ctx.newPage(); - await settings.goto(`/sdk.html?room=${roomId}`); + await navigateStaging(settings, `/sdk.html?room=${roomId}`); await expect(settings.locator("#sdk-state")).toHaveText("connected · saved", { timeout: 15000 }); await settings.locator("#sdk-title").fill("Packaged SDK settings"); await expect(settings.locator("#sdk-state")).toHaveText("connected · saved"); // Exercise every exported route and its lazy-loaded editor bundle in the release. for (const route of ["kanban", "whiteboard", "rich-text", "multiplayer-form", "flowchart", "table"]) { - await settings.goto(`/${route}.html?room=${roomId}`); + await navigateStaging(settings, `/${route}.html?room=${roomId}`); await expect(settings.locator("#status")).toHaveText("Connected", { timeout: 15000 }); await expect(settings.locator("#editor")).toBeVisible(); } @@ -181,7 +182,7 @@ try { const owner = manifest.accounts.owner; assert.equal((await api(ctx, "/api/session", "POST", { username: owner.username, password: owner.password })).status(), 200); const settings = await ctx.newPage(); - await settings.goto(`/sdk.html?room=${manifest.roomId}`); + await navigateStaging(settings, `/sdk.html?room=${manifest.roomId}`); await expect(settings.locator("#sdk-state")).toHaveText("connected · saved", { timeout: 15000 }); await expect(settings.locator("#sdk-title")).toHaveValue(manifest.title); console.log("Pilot passed after replacement: sessions, passwords, permissions, document and SDK state retained"); @@ -189,6 +190,11 @@ try { throw new Error("Expected seed or verify"); } assert.deepEqual(pageErrors, [], "browser JavaScript errors"); +} catch (error) { + console.error("Pilot browser diagnostics:", JSON.stringify({ + pageErrors, failedRequests: failedRequests.slice(-20), + })); + throw error; } finally { await Promise.allSettled(contexts.map(ctx => ctx.close())); await browser.close(); diff --git a/scripts/support/browser-navigation.test.ts b/scripts/support/browser-navigation.test.ts new file mode 100644 index 0000000..8d88af5 --- /dev/null +++ b/scripts/support/browser-navigation.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Page, Response } from "@playwright/test"; +import { navigateStaging } from "./browser-navigation.ts"; + +const url = "/?room=restore-pilot"; +const response = { status: () => 200 } as Response; +const networkChanged = () => new Error("page.goto: net::ERR_NETWORK_CHANGED at https://localhost:8444/?room=restore-pilot"); + +function pageWith(outcomes: (Error | Response | null)[]) { + const calls: string[] = []; + return { + calls, + page: { goto: async (target: string) => { + calls.push(target); + const outcome = outcomes.shift(); + assert.notEqual(outcome, undefined, "navigation exceeded its attempt budget"); + if (outcome instanceof Error) throw outcome; + return outcome!; + } } satisfies Pick, + }; +} + +test("staging navigation recovers from Chromium's transient network-change notification", async () => { + const { page, calls } = pageWith([networkChanged(), response]); + assert.equal(await navigateStaging(page, url), response); + assert.deepEqual(calls, [url, url]); +}); + +test("staging navigation stops after three network-change failures", async () => { + const last = networkChanged(); + const { page, calls } = pageWith([networkChanged(), networkChanged(), last]); + await assert.rejects(navigateStaging(page, url), error => error === last); + assert.equal(calls.length, 3); +}); + +for (const failure of ["ERR_CONNECTION_REFUSED", "ERR_CERT_AUTHORITY_INVALID", "ERR_ABORTED", "Timeout 30000ms exceeded"]) { + test(`staging navigation does not retry ${failure}`, async () => { + const error = new Error(`page.goto: ${failure}`); + const { page, calls } = pageWith([error]); + await assert.rejects(navigateStaging(page, url), actual => actual === error); + assert.deepEqual(calls, [url]); + }); +} + +test("staging navigation preserves HTTP errors for the caller's status assertion", async () => { + const unavailable = { status: () => 503 } as Response; + const { page, calls } = pageWith([unavailable]); + assert.equal(await navigateStaging(page, url), unavailable); + assert.deepEqual(calls, [url]); +}); diff --git a/scripts/support/browser-navigation.ts b/scripts/support/browser-navigation.ts new file mode 100644 index 0000000..f7b4f6b --- /dev/null +++ b/scripts/support/browser-navigation.ts @@ -0,0 +1,20 @@ +import type { Page } from "@playwright/test"; +import { setTimeout } from "node:timers/promises"; + +export async function navigateStaging(page: Pick, url: string) { + for (let attempt = 1; ; attempt++) { + try { + return await page.goto(url); + } catch (error) { + // Chromium can report a network change as the staging containers restart. + if ( + attempt >= 3 || + !(error instanceof Error) || + !error.message.includes("net::ERR_NETWORK_CHANGED") + ) { + throw error; + } + await setTimeout(100 * attempt); + } + } +} From fc0a0af79ff57e9c58251e8feccf0dfd6f963604 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 01:30:51 +0800 Subject: [PATCH 12/12] fix(deploy): preserve API rate budgets during asset loading Exclude GET and HEAD requests for immutable Next.js chunks from the shared request-rate bucket while retaining connection limits and limits on all dynamic routes. Reproduce the previous HTTP 429 failure with a 240-request staging asset burst and verify that the session API remains available. --- deploy/nginx.conf | 7 ++++++- docs/deployment.md | 9 ++++++--- scripts/staging-pilot.ts | 14 +++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/deploy/nginx.conf b/deploy/nginx.conf index e95c0f2..675527c 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -7,7 +7,12 @@ http { keepalive_timeout 15s; map $http_upgrade $connection_upgrade { default upgrade; '' close; } limit_conn_zone $binary_remote_addr zone=connections:10m; - limit_req_zone $binary_remote_addr zone=requests:10m rate=30r/s; + # Static chunk fan-out must not exhaust the API and WebSocket request budget. + map "$request_method:$uri" $request_limit_key { + default $binary_remote_addr; + ~^(GET|HEAD):/_next/static/ ""; + } + limit_req_zone $request_limit_key zone=requests:10m rate=30r/s; # Resolve a replaced app container through Docker's DNS without keeping its old IP. resolver 127.0.0.11 valid=5s ipv6=off; upstream synixir { diff --git a/docs/deployment.md b/docs/deployment.md index 0057c45..c95cf99 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -81,9 +81,12 @@ The reproduction, runtime checks and primary sources are in The gateway terminates TLS and proxies WebSocket upgrades. It overwrites the forwarded scheme and client address, rejects hosts other than `localhost`, bounds HTTP bodies to 16 KiB, allows at most 64 active connections per IP, and applies -30 HTTP requests per second with a burst of 120. Existing channel limits govern -messages after a WebSocket upgrade. These are staging defaults, not capacity -measurements. Gateway access logs are disabled to avoid recording grant URLs. +30 HTTP requests per second with a burst of 120. GET and HEAD requests for +`/_next/static/` assets are excluded from the request rate limit so loading editor +bundles leaves the API budget available; the connection limit still applies. +Existing channel limits govern messages after a WebSocket upgrade. These are +staging defaults, not capacity measurements. Gateway access logs are disabled to +avoid recording grant URLs. The app requires `DATABASE_URL`, `SECRET_KEY_BASE` and an HTTPS origin in `SYNIXIR_PUBLIC_URL`. WebSocket origin checks include the scheme and port. Session diff --git a/scripts/staging-pilot.ts b/scripts/staging-pilot.ts index c66ce37..83615ea 100644 --- a/scripts/staging-pilot.ts +++ b/scripts/staging-pilot.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import https from "node:https"; -import { BrowserContext, type Page, type BrowserContextOptions, chromium, expect } from "@playwright/test"; +import { BrowserContext, type Page, type BrowserContextOptions, type APIResponse, chromium, expect } from "@playwright/test"; import { navigateStaging } from "./support/browser-navigation.ts"; const [mode, baseURL, manifestPath] = process.argv.slice(2); @@ -107,6 +107,18 @@ try { assert.equal((await api(owner.ctx, `/api/rooms/${roomId}/members/${person.username}`, "PUT", { role })).status(), 200); } const first = await open(owner.ctx, roomId, "owner"); + // A page's chunk fan-out must not consume the API and WebSocket request budget. + const assetPath = await first.locator('script[src^="/_next/static/"]').first().getAttribute("src"); + assert.ok(assetPath); + for (let batch = 0; batch < 15; batch++) { + const assets: APIResponse[] = await Promise.all(Array.from({ length: 16 }, () => owner.ctx.request.head(assetPath))); + for (const asset of assets) { + assert.equal(asset.status(), 200, "static chunk requests must not be rate limited"); + await asset.dispose(); + } + } + assert.equal((await owner.ctx.request.get("/api/session")).status(), 200, + "static chunk requests must leave the API request budget available"); const second = await open(editor.ctx, roomId, "editor"); const third = await open(viewer.ctx, roomId, "viewer"); await expect(third.locator(".cm-content")).toHaveAttribute("aria-readonly", "true");