@jamesyong42/infinite-canvas 0.1.0 → 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
@@ -30,49 +30,53 @@ npm install three @react-three/fiber
30
30
 
31
31
  ```tsx
32
32
  import { useMemo } from 'react';
33
+ import type { DomWidget, EntityId } from '@jamesyong42/infinite-canvas';
33
34
  import { createLayoutEngine, InfiniteCanvas, useWidgetData } from '@jamesyong42/infinite-canvas';
35
+ import { z } from 'zod';
34
36
 
35
- function MyCard({ entityId }) {
36
- const data = useWidgetData(entityId);
37
+ const schema = z.object({ title: z.string().default('Card') });
38
+ type CardData = z.infer<typeof schema>;
39
+
40
+ function MyCardView({ entityId }: { entityId: EntityId }) {
41
+ const data = useWidgetData<CardData>(entityId);
37
42
  return <div style={{ padding: 16, background: 'white', borderRadius: 8 }}>{data.title}</div>;
38
43
  }
39
44
 
40
- const widgets = [
41
- { type: 'card', component: MyCard, defaultSize: { width: 250, height: 180 } },
42
- ];
45
+ const MyCard: DomWidget<CardData> = {
46
+ type: 'card',
47
+ schema,
48
+ defaultData: { title: 'Card' },
49
+ defaultSize: { width: 250, height: 180 },
50
+ component: MyCardView,
51
+ };
43
52
 
44
53
  export default function App() {
45
54
  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' },
55
+ const e = createLayoutEngine({
56
+ zoom: { min: 0.05, max: 8 },
57
+ widgets: [MyCard],
52
58
  });
59
+ e.spawn('card', { at: { x: 100, y: 100 }, data: { title: 'Hello World' } });
53
60
  return e;
54
61
  }, []);
55
62
 
56
- return (
57
- <InfiniteCanvas
58
- engine={engine}
59
- widgets={widgets}
60
- style={{ width: '100vw', height: '100vh' }}
61
- />
62
- );
63
+ return <InfiniteCanvas engine={engine} style={{ width: '100vw', height: '100vh' }} />;
63
64
  }
64
65
  ```
65
66
 
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.
68
+
66
69
  ## Package
67
70
 
68
- Everything ships in a single package: **`@jamesyong42/infinite-canvas`**. It exposes three entry points:
71
+ Everything ships in a single package: **`@jamesyong42/infinite-canvas`**. It exposes two entry points:
69
72
 
70
73
  | Import | Purpose |
71
74
  |--------|---------|
72
75
  | `@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
76
  | `@jamesyong42/infinite-canvas/advanced` | WebGL renderers, serialization, profiler, spatial index |
75
77
 
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
+
76
80
  ## Why This Library?
77
81
 
78
82
  | | Infinite Canvas | React Flow | Konva | Excalidraw |
@@ -105,8 +109,7 @@ Everything ships in a single package: **`@jamesyong42/infinite-canvas`**. It exp
105
109
 
106
110
  | Prop | Type | Description |
107
111
  |------|------|-------------|
108
- | `engine` | `LayoutEngine` | Engine instance (required) |
109
- | `widgets` | `WidgetDef[]` | Widget type definitions |
112
+ | `engine` | `LayoutEngine` | Engine instance (required) -- widgets and archetypes are registered on the engine, not passed as props |
110
113
  | `grid` | `Partial<GridConfig> \| false` | Grid configuration, or `false` to disable |
111
114
  | `selection` | `Partial<SelectionConfig>` | Selection style overrides |
112
115
  | `onSelectionChange` | `(ids: EntityId[]) => void` | Called when selected entities change |
@@ -118,10 +121,10 @@ Everything ships in a single package: **`@jamesyong42/infinite-canvas`**. It exp
118
121
 
119
122
  ## Widget Development
120
123
 
121
- Widgets are React components that receive an `entityId` prop 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.
122
125
 
123
126
  ```tsx
124
- import type { WidgetProps } from '@jamesyong42/infinite-canvas';
127
+ import type { DomWidget, EntityId } from '@jamesyong42/infinite-canvas';
125
128
  import {
126
129
  Transform2D,
127
130
  useBreakpoint,
@@ -130,33 +133,103 @@ import {
130
133
  useUpdateWidget,
131
134
  useWidgetData,
132
135
  } from '@jamesyong42/infinite-canvas';
136
+ import { z } from 'zod';
133
137
 
134
- function MyWidget({ entityId }: WidgetProps) {
135
- const data = useWidgetData(entityId); // custom widget data
136
- const breakpoint = useBreakpoint(entityId); // 'micro' | 'compact' | 'normal' | 'expanded' | 'detailed'
137
- const isSelected = useIsSelected(entityId); // selection state
138
- const transform = useComponent(entityId, Transform2D); // position/size
139
- const updateWidget = useUpdateWidget(entityId); // update widget data
138
+ const schema = z.object({
139
+ title: z.string().default('Widget'),
140
+ note: z.string().default(''),
141
+ });
142
+ type MyWidgetData = z.infer<typeof schema>;
143
+
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
140
150
 
141
151
  if (breakpoint === 'micro') return <div>...</div>; // minimal view
142
152
  if (breakpoint === 'compact') return <div>...</div>; // condensed view
143
153
  return <div>...</div>; // full view
144
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
+ };
163
+ ```
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' } });
145
198
  ```
146
199
 
147
- The `WidgetProps` interface provides `entityId`, `width`, `height`, and `zoom`.
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. |
148
210
 
149
211
  ## WebGL Widgets (R3F)
150
212
 
151
- Register widgets with `surface: 'webgl'` to render 3D content via React Three Fiber:
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):
152
214
 
153
215
  ```tsx
216
+ import type { EntityId, R3FWidget } from '@jamesyong42/infinite-canvas';
154
217
  import { useFrame } from '@react-three/fiber';
155
218
  import { useRef } from 'react';
156
-
157
- function My3DWidget({ entityId, width, height }) {
158
- const meshRef = useRef();
159
- useFrame((_, delta) => { meshRef.current.rotation.y += delta; });
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
+ });
160
233
  return (
161
234
  <mesh ref={meshRef}>
162
235
  <boxGeometry args={[width * 0.5, height * 0.5, 50]} />
@@ -165,15 +238,20 @@ function My3DWidget({ entityId, width, height }) {
165
238
  );
166
239
  }
167
240
 
168
- <InfiniteCanvas
169
- engine={engine}
170
- widgets={[
171
- { type: 'my-3d', surface: 'webgl', component: My3DWidget, defaultSize: { width: 250, height: 250 } },
172
- ]}
173
- />
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 } });
174
252
  ```
175
253
 
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.
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.
177
255
 
178
256
  ## Configuration
179
257
 
@@ -282,7 +360,7 @@ Use a ref on `<InfiniteCanvas>` for imperative control from outside:
282
360
  ```tsx
283
361
  const canvasRef = useRef<InfiniteCanvasHandle>(null);
284
362
 
285
- <InfiniteCanvas ref={canvasRef} engine={engine} widgets={widgets} />
363
+ <InfiniteCanvas ref={canvasRef} engine={engine} />
286
364
 
287
365
  // Later:
288
366
  canvasRef.current?.zoomToFit();
@@ -318,7 +396,7 @@ useEffect(() => {
318
396
  Define custom components and systems to extend the canvas:
319
397
 
320
398
  ```tsx
321
- import { defineComponent, defineSystem } from '@jamesyong42/infinite-canvas/ecs';
399
+ import { defineComponent, defineSystem } from '@jamesyong42/reactive-ecs';
322
400
  import { Visible } from '@jamesyong42/infinite-canvas';
323
401
 
324
402
  const Health = defineComponent('Health', { hp: 100, maxHp: 100 });