@jamesyong42/infinite-canvas 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,33 +4,21 @@
4
4
  [![npm version](https://img.shields.io/npm/v/@jamesyong42/infinite-canvas)](https://www.npmjs.com/package/@jamesyong42/infinite-canvas)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
6
6
 
7
- **[Live Demo](https://jamesyong-42.github.io/infinite-canvas/)** | **[npm](https://www.npmjs.com/org/jamesyong42)**
7
+ Build Figma-style infinite canvases in React -- drag, resize, snap, zoom, nested containers, and WebGL widgets from a single composable component.
8
8
 
9
- A high-performance infinite canvas library for React, built on an Entity Component System (ECS) architecture with WebGL-accelerated rendering.
9
+ **[Live Demo](https://jamesyong-42.github.io/infinite-canvas/)** | **[npm](https://www.npmjs.com/package/@jamesyong42/infinite-canvas)**
10
10
 
11
11
  ## Features
12
12
 
13
- - **ECS-powered** Decoupled entity/component architecture with spatial indexing (RBush), change detection, and topologically-sorted system scheduling
14
- - **Dual rendering surfaces** DOM widgets and WebGL (R3F) widgets on the same canvas
15
- - **GPU-rendered chrome** Dot grid, selection outlines, resize handles, hover highlights, and snap guides all rendered via SDF shaders in a single draw call
16
- - **Figma-style interactions** Snap alignment (edge/center), equal spacing detection, multi-select group bounding box
17
- - **Mobile-first gestures** iOS Freeform-style touch: single-finger pan, pinch-to-zoom, tap-to-select, double-tap to enter containers
18
- - **Responsive widgets** Breakpoint system adapts widget rendering based on screen-space size (micro/compact/normal/expanded/detailed)
19
- - **Undo/redo** Command buffer with grouped operations (entire drag = one undo step)
20
- - **Hierarchical navigation** Enter/exit nested containers with camera state preservation
21
- - **Performance profiling** Built-in profiler with User Timing API integration, per-system timing, percentile stats
22
- - **Dark mode** — Full dark mode support across canvas, widgets, and UI panels
23
- - **Configurable** — Grid, selection, snap, zoom, and breakpoint parameters all exposed
24
-
25
- ## Package
26
-
27
- Everything ships in a single package: **`@jamesyong42/infinite-canvas`**. It exposes three entry points:
28
-
29
- | Import | Purpose |
30
- |--------|---------|
31
- | `@jamesyong42/infinite-canvas` | Main API — `<InfiniteCanvas>`, `createLayoutEngine`, hooks, built-in components |
32
- | `@jamesyong42/infinite-canvas/ecs` | ECS primitives for advanced users (`defineComponent`, `defineSystem`, `World`) |
33
- | `@jamesyong42/infinite-canvas/advanced` | WebGL renderers, serialization, profiler, spatial index |
13
+ - **Figma-style interactions** -- Snap alignment (edge + center), equal spacing detection, multi-select with group bounding box
14
+ - **Mobile-first gestures** -- Pinch-to-zoom, single-finger pan, tap-to-select, double-tap to enter containers
15
+ - **Responsive widgets** -- Breakpoint system adapts widget rendering based on screen-space size (micro / compact / normal / expanded / detailed)
16
+ - **Dual rendering** -- DOM and WebGL (React Three Fiber) widgets on the same canvas
17
+ - **Undo / redo** -- Command buffer with grouped operations (an entire drag is one undo step)
18
+ - **Hierarchical navigation** -- Enter and exit nested containers with camera state preservation
19
+ - **ECS architecture** -- Extensible via custom components, tags, and systems with topologically-sorted scheduling
20
+ - **Performance** -- SDF shaders for grid and selection chrome, RBush spatial indexing, viewport culling, per-system profiling
21
+ - **Dark mode** -- Full dark mode support across canvas, widgets, and UI chrome
34
22
 
35
23
  ## Quick Start
36
24
 
@@ -41,73 +29,102 @@ npm install three @react-three/fiber
41
29
  ```
42
30
 
43
31
  ```tsx
32
+ import { useMemo } from 'react';
33
+ import type { DomWidget, EntityId } from '@jamesyong42/infinite-canvas';
44
34
  import { createLayoutEngine, InfiniteCanvas, useWidgetData } from '@jamesyong42/infinite-canvas';
35
+ import { z } from 'zod';
45
36
 
46
- // 1. Define your widget component
47
- function MyCard({ entityId }) {
48
- const data = useWidgetData(entityId);
49
- return <div className="p-4 bg-white rounded shadow">{data.title}</div>;
50
- }
37
+ const schema = z.object({ title: z.string().default('Card') });
38
+ type CardData = z.infer<typeof schema>;
51
39
 
52
- // 2. Create the layout engine
53
- const engine = createLayoutEngine({ zoom: { min: 0.05, max: 8 } });
40
+ function MyCardView({ entityId }: { entityId: EntityId }) {
41
+ const data = useWidgetData<CardData>(entityId);
42
+ return <div style={{ padding: 16, background: 'white', borderRadius: 8 }}>{data.title}</div>;
43
+ }
54
44
 
55
- // 3. Add widgets
56
- engine.addWidget({
45
+ const MyCard: DomWidget<CardData> = {
57
46
  type: 'card',
58
- position: { x: 100, y: 100 },
59
- size: { width: 250, height: 180 },
60
- data: { title: 'Hello World' },
61
- });
62
-
63
- // 4. Render — widgets prop wires up the widget types
64
- function App() {
65
- return (
66
- <InfiniteCanvas
67
- engine={engine}
68
- widgets={[
69
- { type: 'card', component: MyCard, defaultSize: { width: 250, height: 180 } },
70
- ]}
71
- className="h-screen w-screen"
72
- />
73
- );
47
+ schema,
48
+ defaultData: { title: 'Card' },
49
+ defaultSize: { width: 250, height: 180 },
50
+ component: MyCardView,
51
+ };
52
+
53
+ export default function App() {
54
+ const engine = useMemo(() => {
55
+ const e = createLayoutEngine({
56
+ zoom: { min: 0.05, max: 8 },
57
+ widgets: [MyCard],
58
+ });
59
+ e.spawn('card', { at: { x: 100, y: 100 }, data: { title: 'Hello World' } });
60
+ return e;
61
+ }, []);
62
+
63
+ return <InfiniteCanvas engine={engine} style={{ width: '100vw', height: '100vh' }} />;
74
64
  }
75
65
  ```
76
66
 
77
- ## WebGL Widgets (R3F)
78
-
79
- Register widgets with `surface: 'webgl'` to render 3D content via React Three Fiber:
67
+ Widgets declare a **schema** (any [Standard Schema v1](https://standardschema.dev)-compatible validator — Zod 3.24+, Valibot, ArkType) and **default data**. Entities are spawned by `engine.spawn(archetypeOrWidgetId, options)`. For widgets without a custom archetype, the engine synthesizes a default one that makes the entity selectable, draggable, and resizable.
80
68
 
81
- ```tsx
82
- import { useFrame } from '@react-three/fiber';
83
- import { useRef } from 'react';
69
+ ## Package
84
70
 
85
- function My3DWidget({ entityId, width, height }) {
86
- const meshRef = useRef();
87
- useFrame((_, delta) => { meshRef.current.rotation.y += delta; });
88
- return (
89
- <mesh ref={meshRef}>
90
- <boxGeometry args={[width * 0.5, height * 0.5, 50]} />
91
- <meshBasicMaterial color="hotpink" wireframe />
92
- </mesh>
93
- );
94
- }
71
+ Everything ships in a single package: **`@jamesyong42/infinite-canvas`**. It exposes two entry points:
95
72
 
96
- <InfiniteCanvas
97
- engine={engine}
98
- widgets={[
99
- { type: 'my-3d', surface: 'webgl', component: My3DWidget, defaultSize: { width: 250, height: 250 } },
100
- ]}
101
- />
102
- ```
73
+ | Import | Purpose |
74
+ |--------|---------|
75
+ | `@jamesyong42/infinite-canvas` | Main API -- `<InfiniteCanvas>`, `createLayoutEngine`, hooks, built-in components |
76
+ | `@jamesyong42/infinite-canvas/advanced` | WebGL renderers, serialization, profiler, spatial index |
103
77
 
104
- WebGL widgets get a transparent R3F canvas layered between the grid and DOM layers. The R3F camera is synced with the engine camera every frame. Widget components receive `entityId`, `width`, and `height` props and work in centered local coordinates.
78
+ The underlying ECS primitives (`defineComponent`, `defineSystem`, `World`, `SystemScheduler`) live in a separate package: [**`@jamesyong42/reactive-ecs`**](https://github.com/jamesyong-42/reactive-ecs).
79
+
80
+ ## Why This Library?
81
+
82
+ | | Infinite Canvas | React Flow | Konva | Excalidraw |
83
+ |---|---|---|---|---|
84
+ | **Use case** | Freeform spatial positioning of arbitrary React components | Node-edge graphs and flowcharts | Imperative canvas-mode rendering | Whiteboard application |
85
+ | **Rendering** | React DOM + WebGL widgets | React DOM | HTML5 Canvas | HTML5 Canvas |
86
+ | **Extension model** | ECS components, tags, and systems | Plugins and custom nodes | Shapes and layers | Not designed as a library |
87
+
88
+ **What makes this library unique:** ECS extension system, mixed DOM + WebGL widgets on the same canvas, responsive breakpoint system that adapts widgets to their screen-space size, and SDF-rendered chrome (grid, selection, snap guides) in a single draw call.
89
+
90
+ ## API Reference
91
+
92
+ ### Hooks
93
+
94
+ | Hook | Description |
95
+ |------|-------------|
96
+ | `useWidgetData<T>(entityId)` | Custom data attached to a widget |
97
+ | `useBreakpoint(entityId)` | Responsive breakpoint (`'micro'` / `'compact'` / `'normal'` / `'expanded'` / `'detailed'`) |
98
+ | `useIsSelected(entityId)` | Whether the entity is currently selected |
99
+ | `useUpdateWidget(entityId)` | Returns a function to patch widget data |
100
+ | `useChildren(entityId)` | Child entity IDs of a container |
101
+ | `useComponent<T>(entityId, type)` | Read any ECS component reactively |
102
+ | `useTag(entityId, type)` | Check if an entity has a tag |
103
+ | `useQuery(...types)` | Entity IDs matching component/tag types |
104
+ | `useTaggedEntities(type)` | All entity IDs with a specific tag |
105
+ | `useResource<T>(type)` | Read an ECS resource reactively |
106
+ | `useLayoutEngine()` | Access the `LayoutEngine` instance from context |
107
+
108
+ ### InfiniteCanvas Props
109
+
110
+ | Prop | Type | Description |
111
+ |------|------|-------------|
112
+ | `engine` | `LayoutEngine` | Engine instance (required) -- widgets and archetypes are registered on the engine, not passed as props |
113
+ | `grid` | `Partial<GridConfig> \| false` | Grid configuration, or `false` to disable |
114
+ | `selection` | `Partial<SelectionConfig>` | Selection style overrides |
115
+ | `onSelectionChange` | `(ids: EntityId[]) => void` | Called when selected entities change |
116
+ | `onCameraChange` | `(camera) => void` | Called on pan/zoom |
117
+ | `onNavigationChange` | `(depth, containerId) => void` | Called when entering/exiting containers |
118
+ | `style` | `CSSProperties` | Inline styles for the root element |
119
+ | `className` | `string` | CSS class for the root element |
120
+ | `ref` | `Ref<InfiniteCanvasHandle>` | Imperative handle for `panTo`, `zoomToFit`, `undo`, `redo` |
105
121
 
106
122
  ## Widget Development
107
123
 
108
- Widgets are React components that receive `entityId` and use hooks to read/write ECS data:
124
+ A widget is a self-contained plugin: a schema describing its data, a default data object, and a React view. Export it as a `DomWidget<T>` (or `R3FWidget<T>` — see below) and register it on the engine.
109
125
 
110
126
  ```tsx
127
+ import type { DomWidget, EntityId } from '@jamesyong42/infinite-canvas';
111
128
  import {
112
129
  Transform2D,
113
130
  useBreakpoint,
@@ -116,20 +133,126 @@ import {
116
133
  useUpdateWidget,
117
134
  useWidgetData,
118
135
  } from '@jamesyong42/infinite-canvas';
136
+ import { z } from 'zod';
137
+
138
+ const schema = z.object({
139
+ title: z.string().default('Widget'),
140
+ note: z.string().default(''),
141
+ });
142
+ type MyWidgetData = z.infer<typeof schema>;
119
143
 
120
- function MyWidget({ entityId }) {
121
- const data = useWidgetData(entityId); // custom widget data
122
- const breakpoint = useBreakpoint(entityId); // 'micro' | 'compact' | 'normal' | 'expanded' | 'detailed'
123
- const isSelected = useIsSelected(entityId); // selection state
124
- const transform = useComponent(entityId, Transform2D); // position/size
125
- const updateWidget = useUpdateWidget(entityId); // update widget data
144
+ function MyWidgetView({ entityId }: { entityId: EntityId }) {
145
+ const data = useWidgetData<MyWidgetData>(entityId); // typed custom data
146
+ const breakpoint = useBreakpoint(entityId); // 'micro' | 'compact' | 'normal' | 'expanded' | 'detailed'
147
+ const isSelected = useIsSelected(entityId); // selection state
148
+ const transform = useComponent(entityId, Transform2D); // position/size
149
+ const updateWidget = useUpdateWidget(entityId); // patch widget data
126
150
 
127
151
  if (breakpoint === 'micro') return <div>...</div>; // minimal view
128
152
  if (breakpoint === 'compact') return <div>...</div>; // condensed view
129
153
  return <div>...</div>; // full view
130
154
  }
155
+
156
+ export const MyWidget: DomWidget<MyWidgetData> = {
157
+ type: 'my-widget',
158
+ schema,
159
+ defaultData: { title: 'Widget', note: '' },
160
+ defaultSize: { width: 280, height: 200 },
161
+ component: MyWidgetView,
162
+ };
131
163
  ```
132
164
 
165
+ DOM widget components receive only `{ entityId }`. The outer slot div is sized by CSS, so read `Transform2D` via `useComponent` if you need width/height. Register the widget via `createLayoutEngine({ widgets: [MyWidget] })` or imperatively with `engine.registerWidget(MyWidget)`.
166
+
167
+ ## Archetypes
168
+
169
+ An **archetype** is a recipe for spawning an entity — a bundle of components and tags, optionally referencing a widget type. Every widget you register gets a default archetype automatically (with `Selectable`, `Draggable`, `Resizable`). Write a custom archetype when you need to attach extra behaviour like `Container` + `Children` for an enterable container, or skip the interactive defaults for a locked decoration.
170
+
171
+ ```tsx
172
+ import type { Archetype, DomWidget, EntityId } from '@jamesyong42/infinite-canvas';
173
+ import { Children, Container } from '@jamesyong42/infinite-canvas';
174
+
175
+ export const MyContainer: DomWidget<{ title: string }> = {
176
+ type: 'my-container',
177
+ schema,
178
+ defaultData: { title: 'Container' },
179
+ defaultSize: { width: 500, height: 350 },
180
+ component: MyContainerView,
181
+ };
182
+
183
+ export const MyContainerArchetype: Archetype = {
184
+ id: 'my-container',
185
+ widget: 'my-container',
186
+ components: [
187
+ [Container, { enterable: true }],
188
+ [Children, { ids: [] as EntityId[] }],
189
+ ],
190
+ };
191
+
192
+ // Use it:
193
+ const engine = createLayoutEngine({
194
+ widgets: [MyContainer],
195
+ archetypes: [MyContainerArchetype],
196
+ });
197
+ const id = engine.spawn('my-container', { at: { x: 50, y: 50 }, data: { title: 'Hello' } });
198
+ ```
199
+
200
+ Spawning is uniform: `engine.spawn(id, options)`. If `id` matches an archetype, that archetype is used; otherwise the engine synthesizes a default from the widget. Options:
201
+
202
+ | Option | Description |
203
+ |--------|-------------|
204
+ | `at` | World-space position. Defaults to `{ x: 0, y: 0 }`. |
205
+ | `size` | Overrides the widget's `defaultSize`. |
206
+ | `data` | Patch merged into the widget's `defaultData`. |
207
+ | `zIndex` | Rendering + hit-test order. |
208
+ | `parent` | Parent entity id for nesting. |
209
+ | `rotation` | Initial rotation in radians. |
210
+
211
+ ## WebGL Widgets (R3F)
212
+
213
+ Define an `R3FWidget<T>` with `surface: 'webgl'` to render 3D content via React Three Fiber. R3F widget views receive `{ entityId, width, height }` and render in local coordinates (origin at widget centre):
214
+
215
+ ```tsx
216
+ import type { EntityId, R3FWidget } from '@jamesyong42/infinite-canvas';
217
+ import { useFrame } from '@react-three/fiber';
218
+ import { useRef } from 'react';
219
+ import { z } from 'zod';
220
+ import type { Mesh } from 'three';
221
+
222
+ const schema = z.object({ color: z.string().default('hotpink') });
223
+
224
+ function My3DView({
225
+ entityId,
226
+ width,
227
+ height,
228
+ }: { entityId: EntityId; width: number; height: number }) {
229
+ const meshRef = useRef<Mesh>(null);
230
+ useFrame((_, delta) => {
231
+ if (meshRef.current) meshRef.current.rotation.y += delta;
232
+ });
233
+ return (
234
+ <mesh ref={meshRef}>
235
+ <boxGeometry args={[width * 0.5, height * 0.5, 50]} />
236
+ <meshBasicMaterial color="hotpink" wireframe />
237
+ </mesh>
238
+ );
239
+ }
240
+
241
+ export const My3D: R3FWidget<z.infer<typeof schema>> = {
242
+ type: 'my-3d',
243
+ surface: 'webgl',
244
+ schema,
245
+ defaultData: { color: 'hotpink' },
246
+ defaultSize: { width: 250, height: 250 },
247
+ component: My3DView,
248
+ };
249
+
250
+ const engine = createLayoutEngine({ widgets: [My3D] });
251
+ engine.spawn('my-3d', { at: { x: 100, y: 100 } });
252
+ ```
253
+
254
+ WebGL widgets get a transparent R3F canvas layered between the grid and DOM layers. The R3F camera is synced with the engine camera every frame.
255
+
133
256
  ## Configuration
134
257
 
135
258
  ### Grid
@@ -177,38 +300,149 @@ function MyWidget({ entityId }) {
177
300
  const engine = createLayoutEngine({
178
301
  zoom: { min: 0.05, max: 8 },
179
302
  breakpoints: { micro: 40, compact: 120, normal: 500, expanded: 1200 },
303
+ snap: { enabled: true, threshold: 5 },
304
+ });
305
+ ```
306
+
307
+ ## Serialization
308
+
309
+ Save and restore canvas state with the serialization API:
310
+
311
+ ```tsx
312
+ import { serializeWorld, deserializeWorld } from '@jamesyong42/infinite-canvas/advanced';
313
+ import {
314
+ Transform2D, Widget, WidgetData, WidgetBreakpoint, ZIndex,
315
+ Parent, Children, Container, Hitbox, InteractionRole, HandleSet, CursorHint,
316
+ Selectable, Draggable, Resizable, Locked, Selected, Active, Visible,
317
+ } from '@jamesyong42/infinite-canvas';
318
+
319
+ const componentTypes = [
320
+ Transform2D, Widget, WidgetData, WidgetBreakpoint, ZIndex,
321
+ Parent, Children, Container, Hitbox, InteractionRole, HandleSet, CursorHint,
322
+ ];
323
+ const tagTypes = [Selectable, Draggable, Resizable, Locked, Selected, Active, Visible];
324
+
325
+ // Save
326
+ const camera = engine.getCamera();
327
+ const doc = serializeWorld(engine.world, componentTypes, tagTypes, camera, []);
328
+ localStorage.setItem('canvas', JSON.stringify(doc));
329
+
330
+ // Load
331
+ const saved = JSON.parse(localStorage.getItem('canvas'));
332
+ deserializeWorld(engine.world, saved, componentTypes, tagTypes);
333
+ engine.markDirty();
334
+ ```
335
+
336
+ ## Programmatic Control
337
+
338
+ ### Camera
339
+
340
+ ```tsx
341
+ engine.panTo(500, 300); // pan to world coordinates
342
+ engine.zoomTo(1.5); // set zoom level
343
+ engine.zoomToFit(); // fit all entities in viewport
344
+ engine.zoomToFit([id1, id2]); // fit specific entities
345
+ engine.markDirty(); // schedule a re-render
346
+ ```
347
+
348
+ ### Undo / Redo
349
+
350
+ ```tsx
351
+ engine.undo();
352
+ engine.redo();
353
+ engine.markDirty();
354
+ ```
355
+
356
+ ### Imperative Handle
357
+
358
+ Use a ref on `<InfiniteCanvas>` for imperative control from outside:
359
+
360
+ ```tsx
361
+ const canvasRef = useRef<InfiniteCanvasHandle>(null);
362
+
363
+ <InfiniteCanvas ref={canvasRef} engine={engine} />
364
+
365
+ // Later:
366
+ canvasRef.current?.zoomToFit();
367
+ canvasRef.current?.undo();
368
+ ```
369
+
370
+ ### Keyboard Shortcuts
371
+
372
+ Wire up shortcuts in your app (this pattern is from the playground):
373
+
374
+ ```tsx
375
+ useEffect(() => {
376
+ const onKeyDown = (e: KeyboardEvent) => {
377
+ const mod = e.metaKey || e.ctrlKey;
378
+ if (mod && !e.shiftKey && e.key === 'z') { e.preventDefault(); engine.undo(); engine.markDirty(); }
379
+ if (mod && e.shiftKey && e.key === 'z') { e.preventDefault(); engine.redo(); engine.markDirty(); }
380
+ if (e.key === 'Escape' && engine.getNavigationDepth() > 0) {
381
+ engine.exitContainer(); engine.markDirty();
382
+ }
383
+ if (e.key === 'Backspace' || e.key === 'Delete') {
384
+ const selected = engine.getSelectedEntities();
385
+ for (const id of selected) engine.destroyEntity(id);
386
+ if (selected.length > 0) engine.markDirty();
387
+ }
388
+ };
389
+ window.addEventListener('keydown', onKeyDown);
390
+ return () => window.removeEventListener('keydown', onKeyDown);
391
+ }, [engine]);
392
+ ```
393
+
394
+ ## Custom ECS Extensions
395
+
396
+ Define custom components and systems to extend the canvas:
397
+
398
+ ```tsx
399
+ import { defineComponent, defineSystem } from '@jamesyong42/reactive-ecs';
400
+ import { Visible } from '@jamesyong42/infinite-canvas';
401
+
402
+ const Health = defineComponent('Health', { hp: 100, maxHp: 100 });
403
+
404
+ const healthSystem = defineSystem({
405
+ name: 'health',
406
+ after: 'breakpoint',
407
+ execute: (world) => {
408
+ for (const id of world.queryChanged(Health)) {
409
+ const h = world.getComponent(id, Health);
410
+ if (h && h.hp <= 0) world.removeTag(id, Visible);
411
+ }
412
+ },
180
413
  });
181
414
 
182
- // Snap guides
183
- engine.setSnapEnabled(true);
184
- engine.setSnapThreshold(5); // world pixels
415
+ // Register with the engine
416
+ engine.registerSystem(healthSystem);
185
417
  ```
186
418
 
419
+ Systems are topologically sorted based on `after` and `before` constraints, so you can insert custom logic at any point in the pipeline.
420
+
187
421
  ## Architecture
188
422
 
189
423
  ```
190
424
  @jamesyong42/infinite-canvas
191
- ├── Main API (InfiniteCanvas, createLayoutEngine, hooks, components)
192
- ├── /ecs (ECS primitives: defineComponent, defineSystem, World)
193
- └── /advanced (WebGL renderers, serialization, profiler, spatial index)
425
+ +-- Main API (InfiniteCanvas, createLayoutEngine, hooks, components)
426
+ +-- /ecs (ECS primitives: defineComponent, defineSystem, World)
427
+ +-- /advanced (WebGL renderers, serialization, profiler, spatial index)
194
428
  ```
195
429
 
196
430
  ### Rendering Stack
197
431
 
198
432
  ```
199
433
  z:0 WebGL canvas (Three.js)
200
- ├── GridRenderer multi-level dot grid (SDF shader)
201
- └── SelectionRenderer outlines, handles, hover, snap guides (SDF shader)
434
+ +-- GridRenderer -- multi-level dot grid (SDF shader)
435
+ +-- SelectionRenderer -- outlines, handles, hover, snap guides (SDF shader)
202
436
 
203
437
  z:1 R3F canvas (React Three Fiber, lazy)
204
- └── WebGL widgets 3D content with synced orthographic camera
438
+ +-- WebGL widgets -- 3D content with synced orthographic camera
205
439
 
206
440
  z:2 DOM layer
207
- ├── WidgetSlots DOM widget content + pointer events
208
- └── SelectionOverlays invisible pointer event layer for WebGL widgets
441
+ +-- WidgetSlots -- DOM widget content + pointer events
442
+ +-- SelectionOverlays -- invisible pointer event layer for WebGL widgets
209
443
 
210
444
  z:3 UI chrome
211
- └── Panels, buttons, toggles
445
+ +-- Panels, buttons, toggles
212
446
  ```
213
447
 
214
448
  ### ECS Components
@@ -223,6 +457,10 @@ z:3 UI chrome
223
457
  | `ZIndex` | Rendering order |
224
458
  | `Parent` / `Children` | Hierarchy |
225
459
  | `Container` | Marks entity as enterable |
460
+ | `Hitbox` | Hit-test geometry |
461
+ | `InteractionRole` | Interaction behavior (drag, select, resize, etc.) |
462
+ | `HandleSet` | Child handle entity references |
463
+ | `CursorHint` | Cursor style on hover/active |
226
464
 
227
465
  ### ECS Tags
228
466
 
@@ -230,22 +468,13 @@ z:3 UI chrome
230
468
 
231
469
  ### Systems (execution order)
232
470
 
233
- 1. `transformPropagate` Propagate transforms down hierarchy, compute WorldBounds
234
- 2. `spatialIndex` Update RBush spatial index
235
- 3. `navigationFilter` Filter entities to active navigation layer
236
- 4. `cull` Mark viewport-visible entities
237
- 5. `breakpoint` Compute responsive breakpoints
238
- 6. `sort` Z-index ordering
239
-
240
- ## Keyboard Shortcuts (Playground)
241
-
242
- | Shortcut | Action |
243
- |----------|--------|
244
- | `Cmd/Ctrl+Z` | Undo |
245
- | `Cmd/Ctrl+Shift+Z` | Redo |
246
- | `Escape` | Exit container |
247
- | `Delete` / `Backspace` | Delete selected |
248
- | Double-click | Enter container |
471
+ 1. `transformPropagate` -- Propagate transforms down hierarchy, compute WorldBounds
472
+ 2. `handleSync` -- Synchronize resize handle entities with parent widgets
473
+ 3. `hitboxWorldBounds` -- Compute world-space hitbox bounds
474
+ 4. `navigationFilter` -- Filter entities to active navigation layer
475
+ 5. `cull` -- Mark viewport-visible entities
476
+ 6. `breakpoint` -- Compute responsive breakpoints
477
+ 7. `sort` -- Z-index ordering
249
478
 
250
479
  ## Performance Profiling
251
480
 
@@ -264,6 +493,20 @@ console.log(stats.budgetUsed); // % of 16.67ms budget used
264
493
 
265
494
  All timing data integrates with Chrome DevTools Performance tab via the User Timing API (`performance.mark`/`performance.measure`).
266
495
 
496
+ ## SSR / Next.js
497
+
498
+ This library requires browser APIs (WebGL, ResizeObserver, requestAnimationFrame). For Next.js, use dynamic import with SSR disabled or ensure the component only mounts client-side:
499
+
500
+ ```tsx
501
+ import dynamic from 'next/dynamic';
502
+
503
+ const Canvas = dynamic(() => import('./MyCanvas'), { ssr: false });
504
+ ```
505
+
506
+ ## Browser Support
507
+
508
+ Chrome 90+, Firefox 88+, Safari 14+, Edge 90+. Requires WebGL 2.
509
+
267
510
  ## Development
268
511
 
269
512
  ```bash
@@ -287,17 +530,9 @@ pnpm exec tsc --noEmit -p packages/infinite-canvas/tsconfig.json
287
530
 
288
531
  ## Tech Stack
289
532
 
290
- - **TypeScript** Strict mode, fully typed public API
291
- - **React 18 / 19** Compatible with both versions
292
- - **Three.js** WebGL rendering (grid, selection chrome)
293
- - **React Three Fiber** — WebGL widget rendering (optional peer dependency)
294
- - **RBush** — Spatial indexing for hit testing and viewport culling
295
- - **tsup** — Library bundling (ESM + CJS + DTS)
296
- - **Vite** — Playground bundling
297
- - **Tailwind CSS v4** — Playground styling
298
- - **Biome** — Linting and formatting
299
- - **Vitest** — Testing
300
- - **pnpm** — Workspace management
533
+ - **React 18 / 19** -- Compatible with both versions
534
+ - **Three.js** -- WebGL rendering (grid, selection chrome, WebGL widgets via R3F)
535
+ - **RBush** -- Spatial indexing for hit testing and viewport culling
301
536
 
302
537
  ## Contributing
303
538