@jamesyong42/infinite-canvas 0.0.1 → 0.1.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,99 @@ npm install three @react-three/fiber
41
29
  ```
42
30
 
43
31
  ```tsx
32
+ import { useMemo } from 'react';
44
33
  import { createLayoutEngine, InfiniteCanvas, useWidgetData } from '@jamesyong42/infinite-canvas';
45
34
 
46
- // 1. Define your widget component
47
35
  function MyCard({ entityId }) {
48
36
  const data = useWidgetData(entityId);
49
- return <div className="p-4 bg-white rounded shadow">{data.title}</div>;
37
+ return <div style={{ padding: 16, background: 'white', borderRadius: 8 }}>{data.title}</div>;
50
38
  }
51
39
 
52
- // 2. Create the layout engine
53
- const engine = createLayoutEngine({ zoom: { min: 0.05, max: 8 } });
54
-
55
- // 3. Add widgets
56
- engine.addWidget({
57
- type: 'card',
58
- position: { x: 100, y: 100 },
59
- size: { width: 250, height: 180 },
60
- data: { title: 'Hello World' },
61
- });
40
+ const widgets = [
41
+ { type: 'card', component: MyCard, defaultSize: { width: 250, height: 180 } },
42
+ ];
43
+
44
+ export default function App() {
45
+ const engine = useMemo(() => {
46
+ const e = createLayoutEngine({ zoom: { min: 0.05, max: 8 } });
47
+ e.addWidget({
48
+ type: 'card',
49
+ position: { x: 100, y: 100 },
50
+ size: { width: 250, height: 180 },
51
+ data: { title: 'Hello World' },
52
+ });
53
+ return e;
54
+ }, []);
62
55
 
63
- // 4. Render — widgets prop wires up the widget types
64
- function App() {
65
56
  return (
66
57
  <InfiniteCanvas
67
58
  engine={engine}
68
- widgets={[
69
- { type: 'card', component: MyCard, defaultSize: { width: 250, height: 180 } },
70
- ]}
71
- className="h-screen w-screen"
59
+ widgets={widgets}
60
+ style={{ width: '100vw', height: '100vh' }}
72
61
  />
73
62
  );
74
63
  }
75
64
  ```
76
65
 
77
- ## WebGL Widgets (R3F)
78
-
79
- Register widgets with `surface: 'webgl'` to render 3D content via React Three Fiber:
80
-
81
- ```tsx
82
- import { useFrame } from '@react-three/fiber';
83
- import { useRef } from 'react';
66
+ ## Package
84
67
 
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
- }
68
+ Everything ships in a single package: **`@jamesyong42/infinite-canvas`**. It exposes three entry points:
95
69
 
96
- <InfiniteCanvas
97
- engine={engine}
98
- widgets={[
99
- { type: 'my-3d', surface: 'webgl', component: My3DWidget, defaultSize: { width: 250, height: 250 } },
100
- ]}
101
- />
102
- ```
70
+ | Import | Purpose |
71
+ |--------|---------|
72
+ | `@jamesyong42/infinite-canvas` | Main API -- `<InfiniteCanvas>`, `createLayoutEngine`, hooks, built-in components |
73
+ | `@jamesyong42/infinite-canvas/ecs` | ECS primitives for advanced users (`defineComponent`, `defineSystem`, `World`) |
74
+ | `@jamesyong42/infinite-canvas/advanced` | WebGL renderers, serialization, profiler, spatial index |
103
75
 
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.
76
+ ## Why This Library?
77
+
78
+ | | Infinite Canvas | React Flow | Konva | Excalidraw |
79
+ |---|---|---|---|---|
80
+ | **Use case** | Freeform spatial positioning of arbitrary React components | Node-edge graphs and flowcharts | Imperative canvas-mode rendering | Whiteboard application |
81
+ | **Rendering** | React DOM + WebGL widgets | React DOM | HTML5 Canvas | HTML5 Canvas |
82
+ | **Extension model** | ECS components, tags, and systems | Plugins and custom nodes | Shapes and layers | Not designed as a library |
83
+
84
+ **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.
85
+
86
+ ## API Reference
87
+
88
+ ### Hooks
89
+
90
+ | Hook | Description |
91
+ |------|-------------|
92
+ | `useWidgetData<T>(entityId)` | Custom data attached to a widget |
93
+ | `useBreakpoint(entityId)` | Responsive breakpoint (`'micro'` / `'compact'` / `'normal'` / `'expanded'` / `'detailed'`) |
94
+ | `useIsSelected(entityId)` | Whether the entity is currently selected |
95
+ | `useUpdateWidget(entityId)` | Returns a function to patch widget data |
96
+ | `useChildren(entityId)` | Child entity IDs of a container |
97
+ | `useComponent<T>(entityId, type)` | Read any ECS component reactively |
98
+ | `useTag(entityId, type)` | Check if an entity has a tag |
99
+ | `useQuery(...types)` | Entity IDs matching component/tag types |
100
+ | `useTaggedEntities(type)` | All entity IDs with a specific tag |
101
+ | `useResource<T>(type)` | Read an ECS resource reactively |
102
+ | `useLayoutEngine()` | Access the `LayoutEngine` instance from context |
103
+
104
+ ### InfiniteCanvas Props
105
+
106
+ | Prop | Type | Description |
107
+ |------|------|-------------|
108
+ | `engine` | `LayoutEngine` | Engine instance (required) |
109
+ | `widgets` | `WidgetDef[]` | Widget type definitions |
110
+ | `grid` | `Partial<GridConfig> \| false` | Grid configuration, or `false` to disable |
111
+ | `selection` | `Partial<SelectionConfig>` | Selection style overrides |
112
+ | `onSelectionChange` | `(ids: EntityId[]) => void` | Called when selected entities change |
113
+ | `onCameraChange` | `(camera) => void` | Called on pan/zoom |
114
+ | `onNavigationChange` | `(depth, containerId) => void` | Called when entering/exiting containers |
115
+ | `style` | `CSSProperties` | Inline styles for the root element |
116
+ | `className` | `string` | CSS class for the root element |
117
+ | `ref` | `Ref<InfiniteCanvasHandle>` | Imperative handle for `panTo`, `zoomToFit`, `undo`, `redo` |
105
118
 
106
119
  ## Widget Development
107
120
 
108
- Widgets are React components that receive `entityId` and use hooks to read/write ECS data:
121
+ Widgets are React components that receive an `entityId` prop and use hooks to read/write ECS data:
109
122
 
110
123
  ```tsx
124
+ import type { WidgetProps } from '@jamesyong42/infinite-canvas';
111
125
  import {
112
126
  Transform2D,
113
127
  useBreakpoint,
@@ -117,7 +131,7 @@ import {
117
131
  useWidgetData,
118
132
  } from '@jamesyong42/infinite-canvas';
119
133
 
120
- function MyWidget({ entityId }) {
134
+ function MyWidget({ entityId }: WidgetProps) {
121
135
  const data = useWidgetData(entityId); // custom widget data
122
136
  const breakpoint = useBreakpoint(entityId); // 'micro' | 'compact' | 'normal' | 'expanded' | 'detailed'
123
137
  const isSelected = useIsSelected(entityId); // selection state
@@ -130,6 +144,37 @@ function MyWidget({ entityId }) {
130
144
  }
131
145
  ```
132
146
 
147
+ The `WidgetProps` interface provides `entityId`, `width`, `height`, and `zoom`.
148
+
149
+ ## WebGL Widgets (R3F)
150
+
151
+ Register widgets with `surface: 'webgl'` to render 3D content via React Three Fiber:
152
+
153
+ ```tsx
154
+ import { useFrame } from '@react-three/fiber';
155
+ import { useRef } from 'react';
156
+
157
+ function My3DWidget({ entityId, width, height }) {
158
+ const meshRef = useRef();
159
+ useFrame((_, delta) => { meshRef.current.rotation.y += delta; });
160
+ return (
161
+ <mesh ref={meshRef}>
162
+ <boxGeometry args={[width * 0.5, height * 0.5, 50]} />
163
+ <meshBasicMaterial color="hotpink" wireframe />
164
+ </mesh>
165
+ );
166
+ }
167
+
168
+ <InfiniteCanvas
169
+ engine={engine}
170
+ widgets={[
171
+ { type: 'my-3d', surface: 'webgl', component: My3DWidget, defaultSize: { width: 250, height: 250 } },
172
+ ]}
173
+ />
174
+ ```
175
+
176
+ 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.
177
+
133
178
  ## Configuration
134
179
 
135
180
  ### Grid
@@ -177,38 +222,149 @@ function MyWidget({ entityId }) {
177
222
  const engine = createLayoutEngine({
178
223
  zoom: { min: 0.05, max: 8 },
179
224
  breakpoints: { micro: 40, compact: 120, normal: 500, expanded: 1200 },
225
+ snap: { enabled: true, threshold: 5 },
180
226
  });
227
+ ```
228
+
229
+ ## Serialization
181
230
 
182
- // Snap guides
183
- engine.setSnapEnabled(true);
184
- engine.setSnapThreshold(5); // world pixels
231
+ Save and restore canvas state with the serialization API:
232
+
233
+ ```tsx
234
+ import { serializeWorld, deserializeWorld } from '@jamesyong42/infinite-canvas/advanced';
235
+ import {
236
+ Transform2D, Widget, WidgetData, WidgetBreakpoint, ZIndex,
237
+ Parent, Children, Container, Hitbox, InteractionRole, HandleSet, CursorHint,
238
+ Selectable, Draggable, Resizable, Locked, Selected, Active, Visible,
239
+ } from '@jamesyong42/infinite-canvas';
240
+
241
+ const componentTypes = [
242
+ Transform2D, Widget, WidgetData, WidgetBreakpoint, ZIndex,
243
+ Parent, Children, Container, Hitbox, InteractionRole, HandleSet, CursorHint,
244
+ ];
245
+ const tagTypes = [Selectable, Draggable, Resizable, Locked, Selected, Active, Visible];
246
+
247
+ // Save
248
+ const camera = engine.getCamera();
249
+ const doc = serializeWorld(engine.world, componentTypes, tagTypes, camera, []);
250
+ localStorage.setItem('canvas', JSON.stringify(doc));
251
+
252
+ // Load
253
+ const saved = JSON.parse(localStorage.getItem('canvas'));
254
+ deserializeWorld(engine.world, saved, componentTypes, tagTypes);
255
+ engine.markDirty();
185
256
  ```
186
257
 
258
+ ## Programmatic Control
259
+
260
+ ### Camera
261
+
262
+ ```tsx
263
+ engine.panTo(500, 300); // pan to world coordinates
264
+ engine.zoomTo(1.5); // set zoom level
265
+ engine.zoomToFit(); // fit all entities in viewport
266
+ engine.zoomToFit([id1, id2]); // fit specific entities
267
+ engine.markDirty(); // schedule a re-render
268
+ ```
269
+
270
+ ### Undo / Redo
271
+
272
+ ```tsx
273
+ engine.undo();
274
+ engine.redo();
275
+ engine.markDirty();
276
+ ```
277
+
278
+ ### Imperative Handle
279
+
280
+ Use a ref on `<InfiniteCanvas>` for imperative control from outside:
281
+
282
+ ```tsx
283
+ const canvasRef = useRef<InfiniteCanvasHandle>(null);
284
+
285
+ <InfiniteCanvas ref={canvasRef} engine={engine} widgets={widgets} />
286
+
287
+ // Later:
288
+ canvasRef.current?.zoomToFit();
289
+ canvasRef.current?.undo();
290
+ ```
291
+
292
+ ### Keyboard Shortcuts
293
+
294
+ Wire up shortcuts in your app (this pattern is from the playground):
295
+
296
+ ```tsx
297
+ useEffect(() => {
298
+ const onKeyDown = (e: KeyboardEvent) => {
299
+ const mod = e.metaKey || e.ctrlKey;
300
+ if (mod && !e.shiftKey && e.key === 'z') { e.preventDefault(); engine.undo(); engine.markDirty(); }
301
+ if (mod && e.shiftKey && e.key === 'z') { e.preventDefault(); engine.redo(); engine.markDirty(); }
302
+ if (e.key === 'Escape' && engine.getNavigationDepth() > 0) {
303
+ engine.exitContainer(); engine.markDirty();
304
+ }
305
+ if (e.key === 'Backspace' || e.key === 'Delete') {
306
+ const selected = engine.getSelectedEntities();
307
+ for (const id of selected) engine.destroyEntity(id);
308
+ if (selected.length > 0) engine.markDirty();
309
+ }
310
+ };
311
+ window.addEventListener('keydown', onKeyDown);
312
+ return () => window.removeEventListener('keydown', onKeyDown);
313
+ }, [engine]);
314
+ ```
315
+
316
+ ## Custom ECS Extensions
317
+
318
+ Define custom components and systems to extend the canvas:
319
+
320
+ ```tsx
321
+ import { defineComponent, defineSystem } from '@jamesyong42/infinite-canvas/ecs';
322
+ import { Visible } from '@jamesyong42/infinite-canvas';
323
+
324
+ const Health = defineComponent('Health', { hp: 100, maxHp: 100 });
325
+
326
+ const healthSystem = defineSystem({
327
+ name: 'health',
328
+ after: 'breakpoint',
329
+ execute: (world) => {
330
+ for (const id of world.queryChanged(Health)) {
331
+ const h = world.getComponent(id, Health);
332
+ if (h && h.hp <= 0) world.removeTag(id, Visible);
333
+ }
334
+ },
335
+ });
336
+
337
+ // Register with the engine
338
+ engine.registerSystem(healthSystem);
339
+ ```
340
+
341
+ Systems are topologically sorted based on `after` and `before` constraints, so you can insert custom logic at any point in the pipeline.
342
+
187
343
  ## Architecture
188
344
 
189
345
  ```
190
346
  @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)
347
+ +-- Main API (InfiniteCanvas, createLayoutEngine, hooks, components)
348
+ +-- /ecs (ECS primitives: defineComponent, defineSystem, World)
349
+ +-- /advanced (WebGL renderers, serialization, profiler, spatial index)
194
350
  ```
195
351
 
196
352
  ### Rendering Stack
197
353
 
198
354
  ```
199
355
  z:0 WebGL canvas (Three.js)
200
- ├── GridRenderer multi-level dot grid (SDF shader)
201
- └── SelectionRenderer outlines, handles, hover, snap guides (SDF shader)
356
+ +-- GridRenderer -- multi-level dot grid (SDF shader)
357
+ +-- SelectionRenderer -- outlines, handles, hover, snap guides (SDF shader)
202
358
 
203
359
  z:1 R3F canvas (React Three Fiber, lazy)
204
- └── WebGL widgets 3D content with synced orthographic camera
360
+ +-- WebGL widgets -- 3D content with synced orthographic camera
205
361
 
206
362
  z:2 DOM layer
207
- ├── WidgetSlots DOM widget content + pointer events
208
- └── SelectionOverlays invisible pointer event layer for WebGL widgets
363
+ +-- WidgetSlots -- DOM widget content + pointer events
364
+ +-- SelectionOverlays -- invisible pointer event layer for WebGL widgets
209
365
 
210
366
  z:3 UI chrome
211
- └── Panels, buttons, toggles
367
+ +-- Panels, buttons, toggles
212
368
  ```
213
369
 
214
370
  ### ECS Components
@@ -223,6 +379,10 @@ z:3 UI chrome
223
379
  | `ZIndex` | Rendering order |
224
380
  | `Parent` / `Children` | Hierarchy |
225
381
  | `Container` | Marks entity as enterable |
382
+ | `Hitbox` | Hit-test geometry |
383
+ | `InteractionRole` | Interaction behavior (drag, select, resize, etc.) |
384
+ | `HandleSet` | Child handle entity references |
385
+ | `CursorHint` | Cursor style on hover/active |
226
386
 
227
387
  ### ECS Tags
228
388
 
@@ -230,22 +390,13 @@ z:3 UI chrome
230
390
 
231
391
  ### Systems (execution order)
232
392
 
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 |
393
+ 1. `transformPropagate` -- Propagate transforms down hierarchy, compute WorldBounds
394
+ 2. `handleSync` -- Synchronize resize handle entities with parent widgets
395
+ 3. `hitboxWorldBounds` -- Compute world-space hitbox bounds
396
+ 4. `navigationFilter` -- Filter entities to active navigation layer
397
+ 5. `cull` -- Mark viewport-visible entities
398
+ 6. `breakpoint` -- Compute responsive breakpoints
399
+ 7. `sort` -- Z-index ordering
249
400
 
250
401
  ## Performance Profiling
251
402
 
@@ -264,6 +415,20 @@ console.log(stats.budgetUsed); // % of 16.67ms budget used
264
415
 
265
416
  All timing data integrates with Chrome DevTools Performance tab via the User Timing API (`performance.mark`/`performance.measure`).
266
417
 
418
+ ## SSR / Next.js
419
+
420
+ 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:
421
+
422
+ ```tsx
423
+ import dynamic from 'next/dynamic';
424
+
425
+ const Canvas = dynamic(() => import('./MyCanvas'), { ssr: false });
426
+ ```
427
+
428
+ ## Browser Support
429
+
430
+ Chrome 90+, Firefox 88+, Safari 14+, Edge 90+. Requires WebGL 2.
431
+
267
432
  ## Development
268
433
 
269
434
  ```bash
@@ -287,17 +452,9 @@ pnpm exec tsc --noEmit -p packages/infinite-canvas/tsconfig.json
287
452
 
288
453
  ## Tech Stack
289
454
 
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
455
+ - **React 18 / 19** -- Compatible with both versions
456
+ - **Three.js** -- WebGL rendering (grid, selection chrome, WebGL widgets via R3F)
457
+ - **RBush** -- Spatial indexing for hit testing and viewport culling
301
458
 
302
459
  ## Contributing
303
460