@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 +353 -118
- package/dist/SelectionRenderer-CeWSNZT8.d.cts +891 -0
- package/dist/SelectionRenderer-CeWSNZT8.d.ts +891 -0
- package/dist/advanced.cjs +83 -1486
- package/dist/advanced.cjs.map +1 -1
- package/dist/advanced.d.cts +31 -43
- package/dist/advanced.d.ts +31 -43
- package/dist/advanced.js +35 -105
- package/dist/advanced.js.map +1 -1
- package/dist/chunk-VSHXWTJH.cjs +3228 -0
- package/dist/chunk-VSHXWTJH.cjs.map +1 -0
- package/dist/chunk-Z6JQQOWL.js +3142 -0
- package/dist/chunk-Z6JQQOWL.js.map +1 -0
- package/dist/index.cjs +759 -3488
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +91 -119
- package/dist/index.d.ts +91 -119
- package/dist/index.js +518 -1568
- package/dist/index.js.map +1 -1
- package/package.json +32 -11
- package/dist/chunk-LQMLG3P5.js +0 -456
- package/dist/chunk-LQMLG3P5.js.map +0 -1
- package/dist/chunk-YVWTBG7H.js +0 -1447
- package/dist/chunk-YVWTBG7H.js.map +0 -1
- package/dist/context-BEyR4AhJ.d.ts +0 -441
- package/dist/context-tR7KjG_v.d.cts +0 -441
- package/dist/ecs.cjs +0 -487
- package/dist/ecs.cjs.map +0 -1
- package/dist/ecs.d.cts +0 -65
- package/dist/ecs.d.ts +0 -65
- package/dist/ecs.js +0 -17
- package/dist/ecs.js.map +0 -1
- package/dist/profiler-DKuXy4MW.d.cts +0 -137
- package/dist/profiler-DKuXy4MW.d.ts +0 -137
package/README.md
CHANGED
|
@@ -4,33 +4,21 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@jamesyong42/infinite-canvas)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
const
|
|
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
|
-
|
|
56
|
-
engine.addWidget({
|
|
45
|
+
const MyCard: DomWidget<CardData> = {
|
|
57
46
|
type: 'card',
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
function App() {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
widgets
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
import { useFrame } from '@react-three/fiber';
|
|
83
|
-
import { useRef } from 'react';
|
|
69
|
+
## Package
|
|
84
70
|
|
|
85
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
const data = useWidgetData(entityId);
|
|
122
|
-
const breakpoint = useBreakpoint(entityId);
|
|
123
|
-
const isSelected = useIsSelected(entityId);
|
|
124
|
-
const transform = useComponent(entityId, Transform2D);
|
|
125
|
-
const updateWidget = useUpdateWidget(entityId);
|
|
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
|
-
//
|
|
183
|
-
engine.
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
438
|
+
+-- WebGL widgets -- 3D content with synced orthographic camera
|
|
205
439
|
|
|
206
440
|
z:2 DOM layer
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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`
|
|
234
|
-
2. `
|
|
235
|
-
3. `
|
|
236
|
-
4. `
|
|
237
|
-
5. `
|
|
238
|
-
6. `
|
|
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
|
-
- **
|
|
291
|
-
- **
|
|
292
|
-
- **
|
|
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
|
|