A universal infinite-canvas framework — Figma/Freeform-grade interaction,
real-time collaboration, and a defineWidget primitive that turns any React
(or R3F) component into a canvas citizen: selectable, movable, resizable,
wired into a node graph, synced over CRDT, undoable per gesture.
Built on @vibecook/strata-ecs:
an archetype ECS with reactivity and opt-in Loro-CRDT durable + ephemeral
(presence) layers.
npm @vibecook/ice ·
docs https://jamesyong-42.github.io/infinite-canvas-engine/ ·
MIT license
pnpm add @vibecook/ice react react-dom # react/react-dom are optional peersimport { createCanvasEngine, defineWidget, p } from "@vibecook/ice";
import { EngineProvider, InfiniteCanvas, useCommit, useWidgetProps } from "@vibecook/ice/react";
const Sticky = defineWidget({
type: "sticky",
surface: "dom",
props: { text: p.string({ default: "…" }), color: p.enum(["lemon", "mint", "rose"]) },
component: StickyView, // a plain React component
interaction: { resizable: true },
});
function StickyView({ entity, world }) {
// useWidgetProps returns T | undefined and does not infer T — pass the shape,
// and handle the undefined window before the group cell exists.
const props = useWidgetProps<{ text: string; color: string }>(world, entity, "sticky");
const commit = useCommit(); // the sanctioned write path: one tx = one undo step
if (!props) return null;
return <textarea value={props.text} onChange={(e) =>
commit((tx) => tx.edit(entity).set(Sticky.groups[0].component, { ...props, text: e.target.value }))
} />;
}
const engine = createCanvasEngine({ widgets: [Sticky] });
engine.docs.create(); // local-first document; .open()/.join() for load/collab
root.render(
<EngineProvider engine={engine}>
<InfiniteCanvas engine={engine} />
</EngineProvider>,
);One npm package, six entry points (the repo develops them as workspace
packages; packages/ice bundles them for publish):
| Entry | Contents | May import |
|---|---|---|
@vibecook/ice/kernel |
Pure math: coordinates (THE one Y-flip), snap, spatial index, bezier/anchors, zoom bands, eviction | rbush only |
@vibecook/ice |
The engine: ECS catalog, frame contract, interaction stack, widget runtime, node graph, nested canvas, doc kit, presence, bootstrap, migrations, createCanvasEngine facade |
strata-ecs, kernel, loro-crdt |
@vibecook/ice/dom |
DOM planes + reflectors (grid, widgets, wires, chrome, cursors), pointer/measure adapters | core, kernel |
@vibecook/ice/react |
<InfiniteCanvas>, EngineProvider, hooks (useCommit, useWidgetProps, useSelected, useTool, useUndoStatus, usePresencePeers), keymap |
dom, core, kernel + react/react-dom |
@vibecook/ice/r3f |
GL widget islands + virtual-texture compositor, GL pointer router | react + three/@react-three/fiber peers |
@vibecook/ice/devtools |
attachDevtools(engine) — pointers/recognizers, planes, sovereignty, loop tabs |
core only; nobody imports devtools |
Import walls are dependency-cruiser-enforced and CI-fatal.
- Everything is world state. Pointers, gestures, recognizers, selection, camera, ports — all entities/resources in one ECS; there are no closure FSMs and no singletons, so devtools and collab see everything.
- One frame contract.
engine.step(now)= sync → tick (11 fixed phases) → publish (presence I/O) → notify (reactivity) → reflect (the ONLY DOM/GL writers). Systems never touch output; reflectors never write ECS. - Sovereignty is per-entity, decided at spawn. Durable entities live in the CRDT document; runtime entities die with the session; ephemeral ones ride presence. Components stay pure — the spawn path is the class.
- Gestures are divergence. A drag writes runtime cells live under a claim; release commits ONE transaction (one undo step). A remote edit to the same cell mid-gesture simply wins or loses at commit — divergence is the signal, not an error.
- Widgets are prefabs.
defineWidgetcompiles props into conflict-group components on a durable prefab; views are React portals from one root (DOM) or compositor islands (GL); cull ≠ unmount; hidden trees freeze. - Tools are configuration. A tool parameterizes recognizer spawn, drag routing, gates, and cursor — it adds no code paths. Custom behaviors register systems through the same extension slot the built-ins use.
// --- ops: every app-handler write path (design-005 §4) ---
engine.ops.spawnWidget("sticky", { x, y, props }); // one tx ({undoable:false} for seeds)
engine.ops.deleteSelection(); // cascade: children + wires
engine.ops.duplicateSelection(); // +16/+16 twins, one undo step
engine.ops.reorder(ids, "top"); // fractional StackZ
engine.ops.zoomToFit();
engine.ops.setTool("connect"); // cancels active gestures first
engine.docs.undo(); // per-gesture; restores selection
// --- documents (local-first; collab is a posture, not a mode) ---
const session = engine.docs.create();
const bytes = session.exportEnvelope(); // versioned ICE1 envelope
engine.docs.open(bytes); // gate: ok | readOnly | migrate | reject
engine.docs.autosave(storage); // debounced, gesture-deferred, quarantining
// --- collab: the app moves bytes, the engine owns the protocol ---
import { webSocketByteChannel } from "@vibecook/ice";
await engine.docs.join(webSocketByteChannel(ws), {
presence: { name: "James", color: "#4f8ef7" }, // cursors + selection summaries
}); // ⇒ { role: "seeder" | "joiner", session, leave }
// role === "seeder" ⇒ this peer arrived first — seed the board via ops({undoable:false})
// --- node editor (opt-in per widget) ---
defineWidget({ …, ports: [{ id: "out", side: "e", accepts: ["number"] }] });
// connect tool: drag port → port creates a durable wire (widget + port IDs —
// port entities are runtime-on-demand; panning with the select tool spawns zero).
// --- nested canvas ---
defineWidget({ …, container: { accepts: ["node"] } });
engine.ops.enterContainer(frame); // camera memory + index rebuildRun the demos: pnpm --filter graybox|cardboard|glboard|nodeboard|moodboard dev
(nodeboard/moodboard support ?room=x&relay=ws://localhost:9301 after pnpm relay).
Measured, not estimated. The bench source in packages/core/bench/ is the
source of truth; docs/benchmarks.md records the full
output (Apple M1 Max, 2026-07-15).
Collaboration is the tightest ceiling — plan shared boards around ~3,000 objects. A local-only document scales to ~100k, but a collaboratively-edited one is bounded by how fast a peer applies an incoming edit, which still scales with document size: roughly 3,000 objects for an actively-editing peer, ~40,000 for a receive-only one. Partition large shared boards into containers.
Scale through containers, not flat boards. Idle frames no longer scale with the board (872 µs at 100k entities, down from 25 ms before the churn gates). Camera gestures still can: nested boards stay cheap (276–460 µs at 10k, 2.6–4.4 ms at 100k), but a flat, all-active 100k board costs 40–64 ms per gesture frame — the honest O(N) ceiling, since nothing can be skipped when everything is active. Containers let active-scoped queries skip whole chunks.
Opening a big document is a one-time cost: full-document projection at attach is ~0.3–0.5 s at 10k rows, ~3.2–3.9 s at 100k. Envelope ≈ 100 B/row.
Deliberately out of scope — each has a named extension seam rather than a
hidden TODO: a general layout engine (beyond ops.arrange), rich text,
comments/threads, and permissions. strata has no authority model, so multi-user
trust is the application's to own.
Not yet built: an engine-level focus model for widget keyboard exclusivity
and wheel/scroll opt-out (design-007 is a reviewed proposal, no code). Native
form controls already work — the keymap ignores keystrokes targeting an input,
textarea, select, or contenteditable — but a widget wanting arrow keys or
its own scrolling has no engine contract yet.
The substrate is pre-1.0: @vibecook/strata-ecs minor versions may break
APIs, and this repo tracks them closely (eight releases absorbed to date).
pnpm install
pnpm run ci # typecheck + lint + 754 tests + import walls — the merge gate- Design docs: the reviewed decision record lives in
draft/(local branch);docs/implementation-plan.mdtracks milestones M0–M10 with exit criteria. - Every counterintuitive behavior is a cited decision — check the design docs before "fixing" it (ports on-demand, stratified z, OS cursor, …).
- Improvement asks against strata-ecs are petitions:
docs/strata-petitions.md. - Release notes:
CHANGELOG.md.
Current release: 0.2.0. Documents written by 0.1.0 migrate automatically on
open (schema 2 — ordered ChildOf replaces scalar z); a 0.1.0 build opening a
migrated document still gets a correct read-only view. See the changelog.
Engine v1 is complete: kernel math, the engine spine, the interaction stack, durable documents + per-gesture undo, the DOM widget runtime, GL islands, the node editor, nested canvas, presence + bootstrap + migrations, the facade, and devtools. The scope fence holds: no layout engine, no rich-text, no comments, no permissions — each exclusion has a named seam instead.