@jamesyong42/infinite-canvas 1.2.0 → 1.3.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.
Files changed (56) hide show
  1. package/README.md +65 -0
  2. package/dist/advanced.cjs +61 -24
  3. package/dist/advanced.cjs.map +1 -1
  4. package/dist/advanced.d.cts +180 -64
  5. package/dist/advanced.d.cts.map +1 -1
  6. package/dist/advanced.d.mts +180 -64
  7. package/dist/advanced.d.mts.map +1 -1
  8. package/dist/advanced.mjs +29 -12
  9. package/dist/advanced.mjs.map +1 -1
  10. package/dist/devtools.cjs +22 -22
  11. package/dist/devtools.cjs.map +1 -1
  12. package/dist/devtools.d.cts +2 -2
  13. package/dist/devtools.d.cts.map +1 -1
  14. package/dist/devtools.d.mts +2 -2
  15. package/dist/devtools.d.mts.map +1 -1
  16. package/dist/devtools.mjs +2 -2
  17. package/dist/devtools.mjs.map +1 -1
  18. package/dist/{hooks-BwY7rRHg.mjs → ecs-3kimUV5Z.mjs} +238 -74
  19. package/dist/ecs-3kimUV5Z.mjs.map +1 -0
  20. package/dist/{hooks-DHShH86C.cjs → ecs-B4QrqfvQ.cjs} +320 -108
  21. package/dist/ecs-B4QrqfvQ.cjs.map +1 -0
  22. package/dist/hooks-CtP02JNt.cjs +3762 -0
  23. package/dist/hooks-CtP02JNt.cjs.map +1 -0
  24. package/dist/hooks-gsQDDE56.mjs +3494 -0
  25. package/dist/hooks-gsQDDE56.mjs.map +1 -0
  26. package/dist/index-3GY7T8JM.d.mts +480 -0
  27. package/dist/index-3GY7T8JM.d.mts.map +1 -0
  28. package/dist/index-B7B1tRPl.d.cts +480 -0
  29. package/dist/index-B7B1tRPl.d.cts.map +1 -0
  30. package/dist/index-DSdbSQ_t.d.cts +1451 -0
  31. package/dist/index-DSdbSQ_t.d.cts.map +1 -0
  32. package/dist/index-Dj9odADH.d.mts +1451 -0
  33. package/dist/index-Dj9odADH.d.mts.map +1 -0
  34. package/dist/index.cjs +3865 -643
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +315 -138
  37. package/dist/index.d.cts.map +1 -1
  38. package/dist/index.d.mts +315 -138
  39. package/dist/index.d.mts.map +1 -1
  40. package/dist/index.mjs +3767 -571
  41. package/dist/index.mjs.map +1 -1
  42. package/package.json +1 -1
  43. package/dist/SelectionRenderer-CR2PBQwx.d.cts +0 -105
  44. package/dist/SelectionRenderer-CR2PBQwx.d.cts.map +0 -1
  45. package/dist/SelectionRenderer-DlsBstAq.d.mts +0 -105
  46. package/dist/SelectionRenderer-DlsBstAq.d.mts.map +0 -1
  47. package/dist/WebGLWidgetLayer-BBMuwzHq.cjs +0 -3560
  48. package/dist/WebGLWidgetLayer-BBMuwzHq.cjs.map +0 -1
  49. package/dist/WebGLWidgetLayer-C3p1tnpm.mjs +0 -3375
  50. package/dist/WebGLWidgetLayer-C3p1tnpm.mjs.map +0 -1
  51. package/dist/engine-BfbvWXSk.d.mts +0 -982
  52. package/dist/engine-BfbvWXSk.d.mts.map +0 -1
  53. package/dist/engine-CCjuFMC-.d.cts +0 -982
  54. package/dist/engine-CCjuFMC-.d.cts.map +0 -1
  55. package/dist/hooks-BwY7rRHg.mjs.map +0 -1
  56. package/dist/hooks-DHShH86C.cjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,10 +1,2179 @@
1
- import { C as MoveCommand, E as createArchetypeRegistry, S as CommandBuffer, T as SetComponentCommand, _ as intersectsAABB, a as DEFAULT_GRID_CONFIG, b as worldBoundsToAABB, c as SelectionOverlaySlot, g as clamp, i as SelectionRenderer, m as isR3FWidget, o as GridRenderer, p as createWidgetRegistry, r as DEFAULT_SELECTION_CONFIG, s as WidgetSlot, t as WebGLWidgetLayer, u as createLayoutEngine, v as pointInAABB, w as ResizeCommand, x as worldToScreen, y as screenToWorld } from "./WebGLWidgetLayer-C3p1tnpm.mjs";
2
- import { A as Draggable, B as SelectionFrame, C as ViewportResource, D as Children, E as Card, F as Locked, G as WidgetData, H as Visible, I as Parent, K as WorldBounds, L as Resizable, M as HandleSet, N as Hitbox, O as Container, P as InteractionRole, R as Selectable, S as NavigationStackResource, T as Active, U as Widget, V as Transform2D, W as WidgetBreakpoint, _ as useWidgetResolver, a as useEntityTags, b as CardPresetsResource, c as useRegisteredTags, d as useTaggedEntities, f as ContainerRefProvider, g as useLayoutEngine, h as useContainerRef, i as useEntityComponents, j as Dragging, k as CursorHint, l as useResource, m as WidgetResolverProvider, n as useCamera, o as useQuery, p as EngineProvider, q as ZIndex, r as useComponent, s as useRegisteredComponents, t as useAllEntities, u as useTag, v as BreakpointConfigResource, w as ZoomConfigResource, x as CursorResource, y as CameraResource, z as Selected } from "./hooks-BwY7rRHg.mjs";
1
+ import { $ as WidgetBreakpoint, A as ContainerCamera, B as OverlapTarget, C as ViewportResource, D as CardOverlapHotPoint, E as Card, F as Dragging, G as Selected, H as PreDragLayer, I as InteractionRole, J as SnapTarget, K as SelectionFrame, L as Layer, M as Culled, N as CursorHint, O as Children, P as Draggable, Q as Widget, R as Locked, S as SpatialIndexResource, T as Active, U as Resizable, V as ParentFrame, W as Selectable, X as TransformTween, Y as Transform2D, Z as Visible, _ as CursorResource, a as useEntityTags, b as NavigationStackResource, c as useRegisteredTags, d as useTaggedEntities, et as WidgetData, f as EngineProvider, g as CardPresetsResource, h as CameraResource, i as useEntityComponents, j as ContainerChildren, k as Container, l as useResource, m as BreakpointConfigResource, n as useCamera, o as useQuery, p as useLayoutEngine, q as SnapSource, r as useComponent, s as useRegisteredComponents, t as useAllEntities, tt as ZIndex, u as useTag, v as DEFAULT_CARD_PRESET_SIZES, w as ZoomConfigResource, x as RootCameraResource, y as LayerOrderResource, z as OverlapCandidate } from "./ecs-3kimUV5Z.mjs";
2
+ import { B as Profiler, C as selectBand, F as useWidgetResolver, I as ContainerRefProvider, L as useContainerRef, M as inputGroupStart, N as inputLog, P as WidgetResolverProvider, R as computeSnapGuides, S as isOutOfBand, a as useWidgetInvalidate, c as SelectionOverlaySlot, d as DEFAULT_SNAP_GUIDE_CONFIG, f as DEFAULT_SELECTION_CONFIG, g as R3FManager, i as useWidgetAnimation, j as sharedGlowUniforms, l as CardChrome, m as DEFAULT_GRID_CONFIG, n as useSharedMaterial, o as useWidgetPhase, r as useSharedTexture, s as WidgetSlot, t as useSharedGeometry, u as WebGLManager, x as ZOOM_BANDS, z as SpatialIndex } from "./hooks-gsQDDE56.mjs";
3
+ import { SystemScheduler, createWorld, defineSystem } from "@jamesyong42/reactive-ecs";
3
4
  import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
- import { useFrame } from "@react-three/fiber";
6
- import { ExtrudeGeometry, Shape, Vector2 } from "three";
7
- //#region src/react/widget-hooks.ts
6
+ //#region src/ecs/archetype.ts
7
+ function createArchetypeRegistry(archetypes = []) {
8
+ const map = /* @__PURE__ */ new Map();
9
+ for (const a of archetypes) map.set(a.id, a);
10
+ return {
11
+ register(a) {
12
+ map.set(a.id, a);
13
+ },
14
+ get(id) {
15
+ return map.get(id) ?? null;
16
+ },
17
+ getAll() {
18
+ return [...map.values()];
19
+ }
20
+ };
21
+ }
22
+ //#endregion
23
+ //#region src/ecs/commands.ts
24
+ var CommandBuffer = class {
25
+ undoStack = [];
26
+ redoStack = [];
27
+ currentGroup = null;
28
+ /** Start grouping commands (e.g., on pointerdown). All commands until endGroup() are one undo step. */
29
+ beginGroup() {
30
+ if (this.currentGroup !== null) this.endGroup();
31
+ this.currentGroup = [];
32
+ }
33
+ /** Execute a command and record it for undo. */
34
+ execute(command, world) {
35
+ command.execute(world);
36
+ if (this.currentGroup) this.currentGroup.push(command);
37
+ else {
38
+ this.undoStack.push([command]);
39
+ this.redoStack.length = 0;
40
+ }
41
+ }
42
+ /** Close the current group — all commands since beginGroup() become one undo step. */
43
+ endGroup() {
44
+ if (this.currentGroup && this.currentGroup.length > 0) {
45
+ this.undoStack.push(this.currentGroup);
46
+ this.redoStack.length = 0;
47
+ }
48
+ this.currentGroup = null;
49
+ }
50
+ /** Undo the last command group. */
51
+ undo(world) {
52
+ if (this.currentGroup) this.endGroup();
53
+ const group = this.undoStack.pop();
54
+ if (!group) return false;
55
+ for (let i = group.length - 1; i >= 0; i--) group[i].undo(world);
56
+ this.redoStack.push(group);
57
+ return true;
58
+ }
59
+ /** Redo the last undone command group. */
60
+ redo(world) {
61
+ const group = this.redoStack.pop();
62
+ if (!group) return false;
63
+ for (const cmd of group) cmd.execute(world);
64
+ this.undoStack.push(group);
65
+ return true;
66
+ }
67
+ canUndo() {
68
+ return this.undoStack.length > 0 || this.currentGroup !== null && this.currentGroup.length > 0;
69
+ }
70
+ canRedo() {
71
+ return this.redoStack.length > 0;
72
+ }
73
+ clear() {
74
+ this.undoStack.length = 0;
75
+ this.redoStack.length = 0;
76
+ this.currentGroup = null;
77
+ }
78
+ get undoSize() {
79
+ return this.undoStack.length;
80
+ }
81
+ get redoSize() {
82
+ return this.redoStack.length;
83
+ }
84
+ };
85
+ var MoveCommand = class {
86
+ beforePositions = /* @__PURE__ */ new Map();
87
+ afterPositions = /* @__PURE__ */ new Map();
88
+ captured = false;
89
+ constructor(entityIds, dx, dy, transformType) {
90
+ this.entityIds = entityIds;
91
+ this.dx = dx;
92
+ this.dy = dy;
93
+ this.transformType = transformType;
94
+ }
95
+ execute(world) {
96
+ if (!this.captured) {
97
+ for (const id of this.entityIds) {
98
+ const t = world.getComponent(id, this.transformType);
99
+ if (t) {
100
+ this.beforePositions.set(id, {
101
+ x: t.x,
102
+ y: t.y
103
+ });
104
+ this.afterPositions.set(id, {
105
+ x: t.x + this.dx,
106
+ y: t.y + this.dy
107
+ });
108
+ }
109
+ }
110
+ this.captured = true;
111
+ }
112
+ for (const [id, pos] of this.afterPositions) world.setComponent(id, this.transformType, {
113
+ x: pos.x,
114
+ y: pos.y
115
+ });
116
+ }
117
+ undo(world) {
118
+ for (const [id, pos] of this.beforePositions) world.setComponent(id, this.transformType, {
119
+ x: pos.x,
120
+ y: pos.y
121
+ });
122
+ }
123
+ };
124
+ var ResizeCommand = class {
125
+ constructor(entityId, before, after, transformType) {
126
+ this.entityId = entityId;
127
+ this.before = before;
128
+ this.after = after;
129
+ this.transformType = transformType;
130
+ this.after = {
131
+ ...after,
132
+ width: Math.max(20, after.width),
133
+ height: Math.max(20, after.height)
134
+ };
135
+ }
136
+ execute(world) {
137
+ world.setComponent(this.entityId, this.transformType, this.after);
138
+ }
139
+ undo(world) {
140
+ world.setComponent(this.entityId, this.transformType, this.before);
141
+ }
142
+ };
143
+ /**
144
+ * Capture an entity's complete current state. Used by `ConsumeCommand`
145
+ * so `undo` can fully reconstitute a destroyed child. Relies on
146
+ * `world.getComponentsOf(entity)` / `world.getTagsOf(entity)` so no
147
+ * external type registries need to be threaded through.
148
+ */
149
+ function snapshotEntity(world, entity) {
150
+ const components = [];
151
+ for (const type of world.getComponentsOf(entity)) {
152
+ const data = world.getComponent(entity, type);
153
+ if (data !== void 0) components.push({
154
+ type,
155
+ data: structuredClone(data)
156
+ });
157
+ }
158
+ return {
159
+ entityId: entity,
160
+ components,
161
+ tags: [...world.getTagsOf(entity)]
162
+ };
163
+ }
164
+ /**
165
+ * Recreate an entity's state from a snapshot. Uses the same
166
+ * `EntityId` captured in the snapshot if the world allows
167
+ * reassignment; otherwise creates a fresh id and returns it.
168
+ */
169
+ function rehydrateEntity(world, snapshot) {
170
+ const id = world.createEntity();
171
+ for (const entry of snapshot.components) world.addComponent(id, entry.type, entry.data);
172
+ for (const tag of snapshot.tags) world.addTag(id, tag);
173
+ return id;
174
+ }
175
+ /**
176
+ * Drop-to-consume command (RFC-004 § Phase 4). Undo / redo are
177
+ * fully supported by:
178
+ * - capturing a full entity snapshot of the child at construction time,
179
+ * - delegating the forward mutation to the parent widget's
180
+ * `applyMutation` handler,
181
+ * - delegating the reverse mutation to `revertMutation`.
182
+ *
183
+ * Handlers are supplied at construction time (captured from the widget
184
+ * registry in the interaction runtime's pointerup path), so the
185
+ * command doesn't need to know about the registry shape itself.
186
+ */
187
+ var ConsumeCommand = class {
188
+ /**
189
+ * The entity id we currently target for destroy. Starts as the
190
+ * initial `childId` captured at construction; updated to the fresh
191
+ * id returned by `rehydrateEntity` on undo so that a subsequent
192
+ * `redo()` destroys the rehydrated entity (not a stale id from a
193
+ * previous execute cycle).
194
+ */
195
+ currentChildId;
196
+ constructor(parentId, childId, childSnapshot, mutation, applyMutation, revertMutation) {
197
+ this.parentId = parentId;
198
+ this.childId = childId;
199
+ this.childSnapshot = childSnapshot;
200
+ this.mutation = mutation;
201
+ this.applyMutation = applyMutation;
202
+ this.revertMutation = revertMutation;
203
+ this.currentChildId = childId;
204
+ }
205
+ execute(world) {
206
+ this.applyMutation?.(world, this.mutation);
207
+ if (this.applyMutation === void 0 && world.entityExists(this.currentChildId)) world.destroyEntity(this.currentChildId);
208
+ }
209
+ undo(world) {
210
+ this.revertMutation?.(world, this.mutation);
211
+ if (!world.entityExists(this.currentChildId)) this.currentChildId = rehydrateEntity(world, this.childSnapshot);
212
+ }
213
+ };
214
+ var SetComponentCommand = class {
215
+ constructor(entityId, type, before, after) {
216
+ this.entityId = entityId;
217
+ this.type = type;
218
+ this.before = before;
219
+ this.after = after;
220
+ }
221
+ execute(world) {
222
+ world.setComponent(this.entityId, this.type, this.after);
223
+ }
224
+ undo(world) {
225
+ world.setComponent(this.entityId, this.type, this.before);
226
+ }
227
+ };
228
+ //#endregion
229
+ //#region src/ecs/math.ts
230
+ /** Convert a Rect to AABB */
231
+ function rectToAABB(r) {
232
+ return {
233
+ minX: r.x,
234
+ minY: r.y,
235
+ maxX: r.x + r.width,
236
+ maxY: r.y + r.height
237
+ };
238
+ }
239
+ /** Convert AABB to Rect */
240
+ function aabbToRect(a) {
241
+ return {
242
+ x: a.minX,
243
+ y: a.minY,
244
+ width: a.maxX - a.minX,
245
+ height: a.maxY - a.minY
246
+ };
247
+ }
248
+ /** Test if two AABBs overlap */
249
+ function intersectsAABB(a, b) {
250
+ return a.maxX >= b.minX && a.minX <= b.maxX && a.maxY >= b.minY && a.minY <= b.maxY;
251
+ }
252
+ /** Test if a point is inside an AABB */
253
+ function pointInAABB(px, py, a) {
254
+ return px >= a.minX && px <= a.maxX && py >= a.minY && py <= a.maxY;
255
+ }
256
+ /** Convert screen coordinates to world coordinates */
257
+ function screenToWorld(screenX, screenY, camera) {
258
+ return {
259
+ x: screenX / camera.zoom + camera.x,
260
+ y: screenY / camera.zoom + camera.y
261
+ };
262
+ }
263
+ /** Convert world coordinates to screen coordinates */
264
+ function worldToScreen(worldX, worldY, camera) {
265
+ return {
266
+ x: (worldX - camera.x) * camera.zoom,
267
+ y: (worldY - camera.y) * camera.zoom
268
+ };
269
+ }
270
+ /** Clamp a value between min and max */
271
+ function clamp(value, min, max) {
272
+ return Math.max(min, Math.min(max, value));
273
+ }
274
+ //#endregion
275
+ //#region src/ecs/systems/breakpoint.ts
276
+ /**
277
+ * Compute breakpoints for visible widgets based on screen size.
278
+ * Fix #10: Always update screenWidth/screenHeight even if breakpoint tier doesn't change.
279
+ */
280
+ const breakpointSystem = defineSystem({
281
+ name: "breakpoint",
282
+ after: "cull",
283
+ execute: (world) => {
284
+ const camera = world.getResource(CameraResource);
285
+ const config = world.getResource(BreakpointConfigResource);
286
+ for (const entity of world.query(Widget, Visible)) {
287
+ const transform = world.getComponent(entity, Transform2D);
288
+ if (!transform) continue;
289
+ const screenWidth = transform.width * camera.zoom;
290
+ const screenHeight = transform.height * camera.zoom;
291
+ let bp;
292
+ if (screenWidth < config.micro) bp = "micro";
293
+ else if (screenWidth < config.compact) bp = "compact";
294
+ else if (screenWidth < config.normal) bp = "normal";
295
+ else if (screenWidth < config.expanded) bp = "expanded";
296
+ else bp = "detailed";
297
+ const existing = world.getComponent(entity, WidgetBreakpoint);
298
+ if (!existing) world.addComponent(entity, WidgetBreakpoint, {
299
+ current: bp,
300
+ screenWidth,
301
+ screenHeight
302
+ });
303
+ else {
304
+ const bpChanged = existing.current !== bp;
305
+ const sizeChanged = Math.round(existing.screenWidth) !== Math.round(screenWidth) || Math.round(existing.screenHeight) !== Math.round(screenHeight);
306
+ if (bpChanged || sizeChanged) world.setComponent(entity, WidgetBreakpoint, {
307
+ current: bp,
308
+ screenWidth,
309
+ screenHeight
310
+ });
311
+ }
312
+ }
313
+ }
314
+ });
315
+ //#endregion
316
+ //#region src/ecs/systems/card.ts
317
+ /**
318
+ * Stamp Transform2D width/height from Card.preset every tick. Manual
319
+ * writes to Transform2D.width/height on a card entity get overwritten
320
+ * — to change card size, update `Card.preset`. The spatial-index
321
+ * observer fires reactively on the Transform2D write, so no ordering
322
+ * constraint against other systems is needed.
323
+ */
324
+ const cardSystem = defineSystem({
325
+ name: "card",
326
+ execute: (world) => {
327
+ const resource = world.getResource(CardPresetsResource);
328
+ if (!resource) return;
329
+ const { presets } = resource;
330
+ for (const entity of world.query(Card, Transform2D)) {
331
+ const card = world.getComponent(entity, Card);
332
+ const transform = world.getComponent(entity, Transform2D);
333
+ if (!card || !transform) continue;
334
+ const size = presets[card.preset];
335
+ if (!size) continue;
336
+ if (transform.width !== size.width || transform.height !== size.height) world.setComponent(entity, Transform2D, {
337
+ width: size.width,
338
+ height: size.height
339
+ });
340
+ }
341
+ }
342
+ });
343
+ //#endregion
344
+ //#region src/ecs/systems/cull.ts
345
+ /**
346
+ * Viewport culling — for every `Active` entity, sets exactly one of `Visible`
347
+ * (intersects viewport+overscan) or `Culled` (outside it). Non-Active entities
348
+ * carry neither tag.
349
+ *
350
+ * The `Culled` tag is consumed by render layers that want to keep cached state
351
+ * without rendering — the R3F compositor (RFC-002) holds Culled widgets in its
352
+ * Cold pool and skips ticks/paints for them.
353
+ */
354
+ const cullSystem = defineSystem({
355
+ name: "cull",
356
+ after: "navigationFilter",
357
+ execute: (world) => {
358
+ const camera = world.getResource(CameraResource);
359
+ const viewport = world.getResource(ViewportResource);
360
+ if (viewport.width === 0 || viewport.height === 0) return;
361
+ const spatialIndex = world.getResource(SpatialIndexResource).instance;
362
+ const overscan = 200 / camera.zoom;
363
+ const vpWorldAABB = {
364
+ minX: camera.x - overscan,
365
+ minY: camera.y - overscan,
366
+ maxX: camera.x + viewport.width / camera.zoom + overscan,
367
+ maxY: camera.y + viewport.height / camera.zoom + overscan
368
+ };
369
+ for (const entity of world.queryTagged(Visible)) world.removeTag(entity, Visible);
370
+ for (const entity of world.queryTagged(Culled)) world.removeTag(entity, Culled);
371
+ const visibleIds = /* @__PURE__ */ new Set();
372
+ if (spatialIndex && spatialIndex.size > 0) {
373
+ const candidates = spatialIndex.search(vpWorldAABB);
374
+ for (const entry of candidates) if (world.hasTag(entry.entityId, Active)) {
375
+ world.addTag(entry.entityId, Visible);
376
+ visibleIds.add(entry.entityId);
377
+ }
378
+ } else for (const entity of world.queryTagged(Active)) {
379
+ const t = world.getComponent(entity, Transform2D);
380
+ if (t && intersectsAABB(rectToAABB(t), vpWorldAABB)) {
381
+ world.addTag(entity, Visible);
382
+ visibleIds.add(entity);
383
+ }
384
+ }
385
+ for (const entity of world.queryTagged(Active)) if (!visibleIds.has(entity)) world.addTag(entity, Culled);
386
+ }
387
+ });
388
+ //#endregion
389
+ //#region src/ecs/systems/drag-promote.ts
390
+ /**
391
+ * Promotes a dragged DOM card to the 'overlay' layer so it visually pops above
392
+ * its siblings; reverses on drag end. R3F cards opt out — the compositor
393
+ * handles their stacking via `uDraggedRect` clip + renderOrder bump
394
+ * (RFC-002), and a bare DOM widget without `Card` is a debug-style surface
395
+ * that shouldn't acquire card-shaped affordances.
396
+ *
397
+ * Diff signal: presence of `PreDragLayer` is the "currently promoted" flag.
398
+ * Dragging present, no PreDragLayer → promote (stash old layer, set overlay)
399
+ * PreDragLayer present, no Dragging → restore (write back stashed layer)
400
+ *
401
+ * RFC-010 Phase 1 — migrates the two `onTagAdded(Dragging)` /
402
+ * `onTagRemoved(Dragging)` observers out of `LayoutEngine.ts` into a
403
+ * `SystemScheduler` system. The `before: 'cull'` constraint is conservative
404
+ * and becomes implicit once RFC-010 Phase 2 stamps `phase: 'react'`.
405
+ */
406
+ const dragPromoteSystem = defineSystem({
407
+ name: "dragPromote",
408
+ before: "cull",
409
+ execute: (world) => {
410
+ for (const entity of world.queryTagged(Dragging)) {
411
+ if (world.hasComponent(entity, PreDragLayer)) continue;
412
+ if (!world.hasComponent(entity, Card)) continue;
413
+ if (world.getComponent(entity, Widget)?.surface === "webgl") continue;
414
+ const prev = world.getComponent(entity, Layer)?.name ?? "base";
415
+ world.addComponent(entity, PreDragLayer, { name: prev });
416
+ if (world.hasComponent(entity, Layer)) world.setComponent(entity, Layer, { name: "overlay" });
417
+ else world.addComponent(entity, Layer, { name: "overlay" });
418
+ }
419
+ for (const entity of world.query(PreDragLayer)) {
420
+ if (world.hasTag(entity, Dragging)) continue;
421
+ const stash = world.getComponent(entity, PreDragLayer);
422
+ if (!stash) continue;
423
+ world.setComponent(entity, Layer, { name: stash.name });
424
+ world.removeComponent(entity, PreDragLayer);
425
+ }
426
+ }
427
+ });
428
+ //#endregion
429
+ //#region src/ecs/systems/navigation-filter.ts
430
+ /**
431
+ * Reconcile `Active` for a single entity against the given nav frame.
432
+ * Exported so the engine can wire a reactive `ParentFrame` observer
433
+ * that keeps the consume / re-parent / undo paths in sync without
434
+ * waiting for a nav-stack change (RFC-004 § Phase 5).
435
+ */
436
+ function reconcileEntityActive(world, entity) {
437
+ const navStack = world.getResource(NavigationStackResource);
438
+ const activeContainer = navStack.frames[navStack.frames.length - 1].containerId;
439
+ const pf = world.getComponent(entity, ParentFrame);
440
+ const belongs = activeContainer === null ? pf === void 0 : pf?.id === activeContainer;
441
+ const isActive = world.hasTag(entity, Active);
442
+ if (belongs && !isActive) world.addTag(entity, Active);
443
+ else if (!belongs && isActive) world.removeTag(entity, Active);
444
+ }
445
+ /**
446
+ * Filter entities to the active navigation layer.
447
+ * Runs on nav-stack changes (full refilter) and incrementally whenever new
448
+ * Transform2D entities are added (so runtime spawns land in the active layer).
449
+ *
450
+ * Mid-session `ParentFrame` mutations (consume / undo) are handled out of
451
+ * band by a reactive observer in the engine — see
452
+ * {@link reconcileEntityActive}.
453
+ */
454
+ const navigationFilterSystem = defineSystem({
455
+ name: "navigationFilter",
456
+ execute: (world) => {
457
+ const navStack = world.getResource(NavigationStackResource);
458
+ const stackChanged = navStack.changed;
459
+ const newEntities = world.queryAdded(Transform2D);
460
+ if (!stackChanged && newEntities.length === 0) return;
461
+ const activeContainer = navStack.frames[navStack.frames.length - 1].containerId;
462
+ const belongsToCurrentFrame = (entity) => {
463
+ if (activeContainer === null) return !world.hasComponent(entity, ParentFrame);
464
+ return world.getComponent(entity, ParentFrame)?.id === activeContainer;
465
+ };
466
+ if (stackChanged) {
467
+ for (const entity of world.queryTagged(Active)) world.removeTag(entity, Active);
468
+ for (const entity of world.query(Transform2D)) if (belongsToCurrentFrame(entity)) world.addTag(entity, Active);
469
+ navStack.changed = false;
470
+ } else for (const entity of newEntities) if (belongsToCurrentFrame(entity) && !world.hasTag(entity, Active)) world.addTag(entity, Active);
471
+ }
472
+ });
473
+ //#endregion
474
+ //#region src/ecs/systems/sort.ts
475
+ /**
476
+ * Sort visible entities by z-index (handled in engine.tick()).
477
+ */
478
+ const sortSystem = defineSystem({
479
+ name: "sort",
480
+ after: "breakpoint",
481
+ execute: (_world) => {}
482
+ });
483
+ //#endregion
484
+ //#region src/ecs/systems/transform-tween.ts
485
+ /**
486
+ * Apply an easing curve to a linear progress value in `[0, 1]`.
487
+ *
488
+ * - `linear` — no curve (`p`).
489
+ * - `ease-out` — cubic ease-out (`1 - (1-p)^3`), default for fly-back.
490
+ * - `ease-in-out` — cubic ease-in-out; symmetric accelerate/decelerate.
491
+ * - `spring` — not yet implemented; falls back to `ease-out` for now.
492
+ *
493
+ * Input is clamped to `[0, 1]`; callers are expected to pass the tween's
494
+ * `elapsed / durationMs` before clamping themselves.
495
+ */
496
+ function applyEasing(p, easing) {
497
+ const t = p < 0 ? 0 : p > 1 ? 1 : p;
498
+ switch (easing) {
499
+ case "linear": return t;
500
+ case "ease-in-out": return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
501
+ case "ease-out":
502
+ case "spring": return 1 - (1 - t) ** 3;
503
+ }
504
+ }
505
+ /**
506
+ * Advance every active `TransformTween`, write the interpolated
507
+ * position back to the entity's `Transform2D`, and remove the tween
508
+ * component on completion (RFC-004 § Phase 2).
509
+ *
510
+ * No scheduler ordering constraint — the spatial-index observer fires
511
+ * reactively on every `Transform2D` change, so downstream consumers
512
+ * see the animated values within the same tick regardless of system
513
+ * order.
514
+ */
515
+ const transformTweenSystem = defineSystem({
516
+ name: "transformTween",
517
+ execute: (world) => {
518
+ const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
519
+ for (const entity of world.query(TransformTween)) {
520
+ const t = world.getComponent(entity, TransformTween);
521
+ const x2d = world.getComponent(entity, Transform2D);
522
+ if (!t) continue;
523
+ if (!x2d) {
524
+ world.removeComponent(entity, TransformTween);
525
+ continue;
526
+ }
527
+ const elapsed = nowMs - t.startMs;
528
+ if (elapsed >= t.durationMs) {
529
+ world.setComponent(entity, Transform2D, {
530
+ x: t.toX,
531
+ y: t.toY
532
+ });
533
+ world.removeComponent(entity, TransformTween);
534
+ continue;
535
+ }
536
+ const p = applyEasing(elapsed / t.durationMs, t.easing);
537
+ world.setComponent(entity, Transform2D, {
538
+ x: t.fromX + (t.toX - t.fromX) * p,
539
+ y: t.fromY + (t.toY - t.fromY) * p
540
+ });
541
+ }
542
+ }
543
+ });
544
+ //#endregion
545
+ //#region src/ecs/engine/interaction.ts
546
+ /**
547
+ * Hit-zone size for resize hotspots (full width, screen px). Deliberately
548
+ * larger than the visual handle size to give a generous clickable area.
549
+ * Do not reduce without a UX test.
550
+ *
551
+ * Private to `interaction.ts` post-RFC-005 — handle hotspots are not
552
+ * separate ECS entities, so this constant has no other consumer.
553
+ */
554
+ const HANDLE_HIT_SIZE_PX = 16;
555
+ /**
556
+ * Return the resize-handle hotspot a world-space point falls inside,
557
+ * if any. Corners are tested before edges so they win when hit zones
558
+ * overlap — this replicates today's `layer: 15` (corner) vs
559
+ * `layer: 10` (edge) priority encoded on handle entities.
560
+ *
561
+ * `zoom` converts the screen-pixel hit size to world units so the
562
+ * hotspot stays visually constant across zoom levels.
563
+ */
564
+ function detectResizeHandle(worldX, worldY, rect, zoom) {
565
+ const half = HANDLE_HIT_SIZE_PX / zoom / 2;
566
+ const xL = rect.x;
567
+ const xR = rect.x + rect.width;
568
+ const yT = rect.y;
569
+ const yB = rect.y + rect.height;
570
+ const corners = [
571
+ {
572
+ pos: "nw",
573
+ cx: xL,
574
+ cy: yT
575
+ },
576
+ {
577
+ pos: "ne",
578
+ cx: xR,
579
+ cy: yT
580
+ },
581
+ {
582
+ pos: "sw",
583
+ cx: xL,
584
+ cy: yB
585
+ },
586
+ {
587
+ pos: "se",
588
+ cx: xR,
589
+ cy: yB
590
+ }
591
+ ];
592
+ for (const c of corners) if (worldX >= c.cx - half && worldX <= c.cx + half && worldY >= c.cy - half && worldY <= c.cy + half) return c.pos;
593
+ const edges = [
594
+ {
595
+ pos: "n",
596
+ cx: (xL + xR) / 2,
597
+ cy: yT
598
+ },
599
+ {
600
+ pos: "s",
601
+ cx: (xL + xR) / 2,
602
+ cy: yB
603
+ },
604
+ {
605
+ pos: "w",
606
+ cx: xL,
607
+ cy: (yT + yB) / 2
608
+ },
609
+ {
610
+ pos: "e",
611
+ cx: xR,
612
+ cy: (yT + yB) / 2
613
+ }
614
+ ];
615
+ for (const e of edges) if (worldX >= e.cx - half && worldX <= e.cx + half && worldY >= e.cy - half && worldY <= e.cy + half) return e.pos;
616
+ return null;
617
+ }
618
+ /**
619
+ * Inline resize-handle hit test (RFC-005).
620
+ *
621
+ * Returns a `{ entityId: widgetId, role: resize-with-handle-pos }`
622
+ * result when the pointer falls inside one of the 8 hotspots around
623
+ * the single selected `Resizable` widget. Gated on exactly-one
624
+ * selected Resizable so that multi-select drag doesn't accidentally
625
+ * latch onto a resize hotspot — matches the historical
626
+ * handle-spawning rule.
627
+ */
628
+ function findInlineResizeHit(world, worldX, worldY, zoom) {
629
+ let selected = null;
630
+ let count = 0;
631
+ for (const e of world.queryTagged(Resizable)) if (world.hasTag(e, Selected)) {
632
+ selected = e;
633
+ if (++count > 1) return null;
634
+ }
635
+ if (!selected) return null;
636
+ const t = world.getComponent(selected, Transform2D);
637
+ if (!t) return null;
638
+ const handle = detectResizeHandle(worldX, worldY, t, zoom);
639
+ if (!handle) return null;
640
+ return {
641
+ entityId: selected,
642
+ role: {
643
+ layer: 15,
644
+ role: {
645
+ type: "resize",
646
+ handle
647
+ }
648
+ }
649
+ };
650
+ }
651
+ /**
652
+ * Map a resize-handle position to the CSS cursor value. Used by the
653
+ * cursor system in place of the per-handle-entity `CursorHint` reads
654
+ * the old model used.
655
+ */
656
+ function cursorForHandle(handle) {
657
+ return `${handle}-resize`;
658
+ }
659
+ /**
660
+ * The pointer state machine, hit testing, selection logic, and the
661
+ * root-container cursor resolution.
662
+ *
663
+ * Kept as one cohesive unit because every branch of the state machine needs
664
+ * access to the same closed-over state (inputState, hoveredEntity, snap
665
+ * result). Splitting further would require threading state refs through
666
+ * every callee, which hurts readability more than it helps.
667
+ */
668
+ function createInteractionRuntime(ctx) {
669
+ const { world, spatialIndex, commandBuffer, markDirty, notifySelectionChanged } = ctx;
670
+ let inputState = { mode: "idle" };
671
+ let hoveredEntity = null;
672
+ /**
673
+ * Cached handle position the pointer is currently over, iff the
674
+ * hovered entity is the selected `Resizable` widget. Updated by the
675
+ * idle-branch hover path in `handlePointerMove`; read by
676
+ * `runCursorSystem` to pick the right directional cursor without
677
+ * consulting a handle entity's `CursorHint` (RFC-005).
678
+ */
679
+ let hoveredHandle = null;
680
+ let currentSnap = {
681
+ snapDx: 0,
682
+ snapDy: 0,
683
+ guides: [],
684
+ spacings: []
685
+ };
686
+ let overlapCandidates = /* @__PURE__ */ new Set();
687
+ let overlapTarget = null;
688
+ function hitTest(screenX, screenY) {
689
+ const camera = world.getResource(CameraResource);
690
+ const worldPos = screenToWorld(screenX, screenY, camera);
691
+ const inlineHit = findInlineResizeHit(world, worldPos.x, worldPos.y, camera.zoom);
692
+ if (inlineHit) return inlineHit;
693
+ const candidates = spatialIndex.searchPoint(worldPos.x, worldPos.y, 0);
694
+ const interactable = [];
695
+ for (const c of candidates) {
696
+ if (!world.hasTag(c.entityId, Active)) continue;
697
+ const role = world.getComponent(c.entityId, InteractionRole);
698
+ if (!role) continue;
699
+ interactable.push({
700
+ entityId: c.entityId,
701
+ role
702
+ });
703
+ }
704
+ if (interactable.length === 0) return null;
705
+ interactable.sort((a, b) => {
706
+ if (b.role.layer !== a.role.layer) return b.role.layer - a.role.layer;
707
+ const zA = world.getComponent(a.entityId, ZIndex)?.value ?? 0;
708
+ return (world.getComponent(b.entityId, ZIndex)?.value ?? 0) - zA;
709
+ });
710
+ return interactable[0];
711
+ }
712
+ function selectEntity(entity, additive) {
713
+ if (!world.hasTag(entity, Selectable)) return;
714
+ if (additive) if (world.hasTag(entity, Selected)) world.removeTag(entity, Selected);
715
+ else world.addTag(entity, Selected);
716
+ else {
717
+ for (const e of world.queryTagged(Selected)) if (e !== entity) world.removeTag(e, Selected);
718
+ world.addTag(entity, Selected);
719
+ }
720
+ notifySelectionChanged();
721
+ }
722
+ function clearSelection() {
723
+ const selected = world.queryTagged(Selected);
724
+ if (selected.length > 0) {
725
+ for (const e of selected) world.removeTag(e, Selected);
726
+ notifySelectionChanged();
727
+ }
728
+ }
729
+ /**
730
+ * RFC-004 § Phase 3 — contract match between a dragged card (child)
731
+ * and a candidate parent. Returns true iff their `provides` /
732
+ * `accepts` arrays intersect AND the optional `canAccept` gate on
733
+ * the parent's widget type returns truthy.
734
+ */
735
+ function contractsMatch(childId, parentId) {
736
+ const childCard = world.getComponent(childId, Card);
737
+ const parentCard = world.getComponent(parentId, Card);
738
+ if (!childCard || !parentCard) return false;
739
+ if (childCard.provides.length === 0 || parentCard.accepts.length === 0) return false;
740
+ let intersects = false;
741
+ for (const p of childCard.provides) if (parentCard.accepts.includes(p)) {
742
+ intersects = true;
743
+ break;
744
+ }
745
+ if (!intersects) return false;
746
+ const gate = ctx.getWidgetInteraction?.(world.getComponent(parentId, Widget)?.type ?? "")?.canAccept;
747
+ if (!gate) return true;
748
+ return gate({
749
+ parent: parentId,
750
+ child: childId,
751
+ world
752
+ });
753
+ }
754
+ /**
755
+ * RFC-004 § Phase 3 — the overlap detection pass.
756
+ *
757
+ * Called from `handlePointerMove` whenever a Card-tagged entity is
758
+ * being dragged. Produces three pieces of visible state:
759
+ *
760
+ * - `OverlapCandidate` tag + `CardOverlapHotPoint` component on every
761
+ * other card whose AABB intersects the dragged card's AABB.
762
+ * `CardOverlapHotPoint.{x,y}` are the intersection centroid in the
763
+ * overlapped card's local (0..1) frame. Layer 1 of the visual state.
764
+ *
765
+ * - `OverlapTarget` tag on at most one card — the closest by centre
766
+ * distance — iff contracts match and any `canAccept` gate passes.
767
+ * Layer 2.
768
+ *
769
+ * `strength` is set to 1 on entry and 0 on exit; CSS / shader
770
+ * consumers own the actual fade transition (opacity transition at
771
+ * the rendering layer). Keeps the ECS pass cheap and stateless.
772
+ */
773
+ function updateCardOverlap(draggedId) {
774
+ if (!world.hasComponent(draggedId, Card)) {
775
+ clearOverlapState();
776
+ return;
777
+ }
778
+ const draggedT = world.getComponent(draggedId, Transform2D);
779
+ if (!draggedT) {
780
+ clearOverlapState();
781
+ return;
782
+ }
783
+ const dxMin = draggedT.x;
784
+ const dyMin = draggedT.y;
785
+ const dxMax = draggedT.x + draggedT.width;
786
+ const dyMax = draggedT.y + draggedT.height;
787
+ const dCenterX = draggedT.x + draggedT.width / 2;
788
+ const dCenterY = draggedT.y + draggedT.height / 2;
789
+ const next = /* @__PURE__ */ new Set();
790
+ const hits = spatialIndex.search({
791
+ minX: dxMin,
792
+ minY: dyMin,
793
+ maxX: dxMax,
794
+ maxY: dyMax
795
+ });
796
+ for (const entry of hits) {
797
+ if (entry.entityId === draggedId) continue;
798
+ if (!world.hasComponent(entry.entityId, Card)) continue;
799
+ if (!world.hasTag(entry.entityId, Active)) continue;
800
+ next.add(entry.entityId);
801
+ }
802
+ for (const prev of overlapCandidates) if (!next.has(prev)) {
803
+ world.removeTag(prev, OverlapCandidate);
804
+ world.removeComponent(prev, CardOverlapHotPoint);
805
+ }
806
+ for (const c of next) if (!overlapCandidates.has(c)) {
807
+ world.addTag(c, OverlapCandidate);
808
+ world.addComponent(c, CardOverlapHotPoint, {
809
+ x: .5,
810
+ y: .5,
811
+ strength: 0
812
+ });
813
+ }
814
+ let primary = null;
815
+ let bestDist = Number.POSITIVE_INFINITY;
816
+ let bestZ = Number.NEGATIVE_INFINITY;
817
+ let bestId = Number.POSITIVE_INFINITY;
818
+ for (const c of next) {
819
+ const ct = world.getComponent(c, Transform2D);
820
+ if (!ct) continue;
821
+ const cxMin = ct.x;
822
+ const cyMin = ct.y;
823
+ const cxMax = ct.x + ct.width;
824
+ const cyMax = ct.y + ct.height;
825
+ const ix = (Math.max(dxMin, cxMin) + Math.min(dxMax, cxMax)) / 2;
826
+ const iy = (Math.max(dyMin, cyMin) + Math.min(dyMax, cyMax)) / 2;
827
+ const hotX = ct.width > 0 ? (ix - ct.x) / ct.width : .5;
828
+ const hotY = ct.height > 0 ? (iy - ct.y) / ct.height : .5;
829
+ world.setComponent(c, CardOverlapHotPoint, {
830
+ x: hotX < 0 ? 0 : hotX > 1 ? 1 : hotX,
831
+ y: hotY < 0 ? 0 : hotY > 1 ? 1 : hotY,
832
+ strength: 1
833
+ });
834
+ const ccx = ct.x + ct.width / 2;
835
+ const ccy = ct.y + ct.height / 2;
836
+ const d = Math.hypot(ccx - dCenterX, ccy - dCenterY);
837
+ const z = world.getComponent(c, ZIndex)?.value ?? 0;
838
+ if (d < bestDist || d === bestDist && z > bestZ || d === bestDist && z === bestZ && c < bestId) {
839
+ bestDist = d;
840
+ bestZ = z;
841
+ bestId = c;
842
+ primary = c;
843
+ }
844
+ }
845
+ overlapCandidates = next;
846
+ const nextTarget = primary !== null && contractsMatch(draggedId, primary) ? primary : null;
847
+ if (nextTarget !== overlapTarget) {
848
+ if (overlapTarget !== null) world.removeTag(overlapTarget, OverlapTarget);
849
+ if (nextTarget !== null) world.addTag(nextTarget, OverlapTarget);
850
+ overlapTarget = nextTarget;
851
+ }
852
+ }
853
+ /**
854
+ * RFC-004 § Phase 3 — tear down all overlap state. Called on drag
855
+ * end (commit / cancel) and when the dragged entity stops being a
856
+ * Card (e.g. a non-card entity enters the drag state machine).
857
+ */
858
+ function clearOverlapState() {
859
+ for (const c of overlapCandidates) {
860
+ world.removeTag(c, OverlapCandidate);
861
+ world.removeComponent(c, CardOverlapHotPoint);
862
+ }
863
+ if (overlapTarget !== null) world.removeTag(overlapTarget, OverlapTarget);
864
+ overlapCandidates = /* @__PURE__ */ new Set();
865
+ overlapTarget = null;
866
+ }
867
+ /**
868
+ * RFC-004 § Phase 4 — fly-back completion poll. Called by the engine's
869
+ * frame runner after `scheduler.execute(world)` each tick. The tween
870
+ * system runs first, interpolates `Transform2D` toward the starting
871
+ * position, and auto-removes its `TransformTween` component on
872
+ * completion. When we see the tween component gone from the primary
873
+ * flying-back entity, the animation is done: remove `Dragging`,
874
+ * restore `ZIndex`, transition to idle.
875
+ */
876
+ function runFlyBackSystem() {
877
+ if (inputState.mode !== "flyingBack") return;
878
+ if (world.entityExists(inputState.entityId) && world.hasComponent(inputState.entityId, TransformTween)) return;
879
+ for (const e of inputState.startPositions.keys()) {
880
+ if (!world.entityExists(e)) continue;
881
+ if (world.hasTag(e, Dragging)) world.removeTag(e, Dragging);
882
+ }
883
+ for (const [entity, originalZ] of inputState.originalZIndices) {
884
+ if (!world.entityExists(entity)) continue;
885
+ world.setComponent(entity, ZIndex, { value: originalZ });
886
+ }
887
+ inputState = { mode: "idle" };
888
+ markDirty();
889
+ }
890
+ /**
891
+ * Derive the root-container cursor from input state + hover.
892
+ * Writes to CursorResource. Called from the frame runner after systems.
893
+ */
894
+ function runCursorSystem() {
895
+ let cursor = "default";
896
+ switch (inputState.mode) {
897
+ case "idle":
898
+ case "marquee":
899
+ if (hoveredHandle !== null) cursor = cursorForHandle(hoveredHandle);
900
+ else if (hoveredEntity !== null) cursor = world.getComponent(hoveredEntity, CursorHint)?.hover ?? "default";
901
+ break;
902
+ case "tracking":
903
+ cursor = world.getComponent(inputState.entityId, CursorHint)?.hover ?? "default";
904
+ break;
905
+ case "dragging":
906
+ cursor = world.getComponent(inputState.entityId, CursorHint)?.active ?? "grabbing";
907
+ break;
908
+ case "resizing":
909
+ cursor = cursorForHandle(inputState.handle);
910
+ break;
911
+ }
912
+ world.setResource(CursorResource, { cursor });
913
+ }
914
+ /**
915
+ * Transition to `dragging` mode. Snapshots ZIndex + Transform2D for every
916
+ * `Selected` entity, elevates them to `maxZ + 1`, tags `Dragging`, opens a
917
+ * command group. Caller is responsible for `Selected` set being correct.
918
+ */
919
+ function _beginDrag(entity, startWorldX, startWorldY) {
920
+ const originalZIndices = /* @__PURE__ */ new Map();
921
+ let maxZ = 0;
922
+ for (const e of world.queryTagged(Active)) {
923
+ const z = world.getComponent(e, ZIndex);
924
+ if (z && z.value > maxZ) maxZ = z.value;
925
+ }
926
+ for (const e of world.queryTagged(Selected)) {
927
+ const z = world.getComponent(e, ZIndex);
928
+ originalZIndices.set(e, z?.value ?? 0);
929
+ world.setComponent(e, ZIndex, { value: maxZ + 1 });
930
+ }
931
+ const startPositions = /* @__PURE__ */ new Map();
932
+ for (const e of world.queryTagged(Selected)) {
933
+ const t = world.getComponent(e, Transform2D);
934
+ if (t) startPositions.set(e, {
935
+ x: t.x,
936
+ y: t.y
937
+ });
938
+ }
939
+ for (const e of startPositions.keys()) world.addTag(e, Dragging);
940
+ commandBuffer.beginGroup();
941
+ inputState = {
942
+ mode: "dragging",
943
+ entityId: entity,
944
+ startWorldX,
945
+ startWorldY,
946
+ startPositions,
947
+ originalZIndices
948
+ };
949
+ markDirty();
950
+ }
951
+ /** Apply drag math + snap + overlap update for the current cursor world position. */
952
+ function _updateDrag(worldX, worldY) {
953
+ if (inputState.mode !== "dragging") return;
954
+ const camera = world.getResource(CameraResource);
955
+ const totalDx = worldX - inputState.startWorldX;
956
+ const totalDy = worldY - inputState.startWorldY;
957
+ const firstId = inputState.startPositions.keys().next().value;
958
+ if (ctx.getSnapEnabled() && firstId !== void 0 && world.hasTag(firstId, SnapSource)) {
959
+ const draggedIds = new Set(inputState.startPositions.keys());
960
+ const firstStart = inputState.startPositions.get(firstId);
961
+ const firstT = world.getComponent(firstId, Transform2D);
962
+ if (firstT && firstStart) {
963
+ const draggedBounds = {
964
+ x: firstStart.x + totalDx,
965
+ y: firstStart.y + totalDy,
966
+ width: firstT.width,
967
+ height: firstT.height
968
+ };
969
+ const refs = [];
970
+ for (const entity of world.queryTagged(SnapTarget)) {
971
+ if (draggedIds.has(entity)) continue;
972
+ if (!world.hasTag(entity, Active)) continue;
973
+ const rt = world.getComponent(entity, Transform2D);
974
+ if (rt) refs.push({
975
+ x: rt.x,
976
+ y: rt.y,
977
+ width: rt.width,
978
+ height: rt.height
979
+ });
980
+ }
981
+ currentSnap = computeSnapGuides(draggedBounds, refs, ctx.getSnapThreshold() / camera.zoom);
982
+ }
983
+ } else currentSnap = {
984
+ snapDx: 0,
985
+ snapDy: 0,
986
+ guides: [],
987
+ spacings: []
988
+ };
989
+ const finalDx = totalDx + currentSnap.snapDx;
990
+ const finalDy = totalDy + currentSnap.snapDy;
991
+ for (const [e, start] of inputState.startPositions) world.setComponent(e, Transform2D, {
992
+ x: start.x + finalDx,
993
+ y: start.y + finalDy
994
+ });
995
+ updateCardOverlap(inputState.entityId);
996
+ markDirty();
997
+ }
998
+ /**
999
+ * End an active drag. `cancelled: false` runs the commit/fly-back/consume
1000
+ * decision tree (RFC-004 § Phase 4); `cancelled: true` rolls back state
1001
+ * and discards the open command group.
1002
+ */
1003
+ function _endDrag(cancelled) {
1004
+ const prevState = inputState;
1005
+ if (prevState.mode !== "dragging") return;
1006
+ if (cancelled) {
1007
+ for (const [e, start] of prevState.startPositions) if (world.entityExists(e)) world.setComponent(e, Transform2D, {
1008
+ x: start.x,
1009
+ y: start.y
1010
+ });
1011
+ commandBuffer.endGroup();
1012
+ for (const e of prevState.startPositions.keys()) if (world.hasTag(e, Dragging)) world.removeTag(e, Dragging);
1013
+ for (const [entity, originalZ] of prevState.originalZIndices) if (world.entityExists(entity)) world.setComponent(entity, ZIndex, { value: originalZ });
1014
+ clearOverlapState();
1015
+ currentSnap = {
1016
+ snapDx: 0,
1017
+ snapDy: 0,
1018
+ guides: [],
1019
+ spacings: []
1020
+ };
1021
+ inputState = { mode: "idle" };
1022
+ markDirty();
1023
+ return;
1024
+ }
1025
+ const draggedId = prevState.entityId;
1026
+ const draggedHasCard = world.hasComponent(draggedId, Card);
1027
+ const hadOverlap = overlapCandidates.size > 0;
1028
+ const target = overlapTarget;
1029
+ const shouldConsume = draggedHasCard && target !== null;
1030
+ if (draggedHasCard && hadOverlap && target === null) {
1031
+ const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
1032
+ for (const [e, start] of prevState.startPositions) {
1033
+ const cur = world.getComponent(e, Transform2D);
1034
+ if (!cur) continue;
1035
+ world.addComponent(e, TransformTween, {
1036
+ fromX: cur.x,
1037
+ fromY: cur.y,
1038
+ toX: start.x,
1039
+ toY: start.y,
1040
+ startMs: nowMs,
1041
+ durationMs: 250,
1042
+ easing: "ease-out",
1043
+ kind: "flyback"
1044
+ });
1045
+ }
1046
+ commandBuffer.endGroup();
1047
+ currentSnap = {
1048
+ snapDx: 0,
1049
+ snapDy: 0,
1050
+ guides: [],
1051
+ spacings: []
1052
+ };
1053
+ clearOverlapState();
1054
+ inputState = {
1055
+ mode: "flyingBack",
1056
+ entityId: draggedId,
1057
+ startPositions: prevState.startPositions,
1058
+ originalZIndices: prevState.originalZIndices
1059
+ };
1060
+ markDirty();
1061
+ return;
1062
+ }
1063
+ for (const e of prevState.startPositions.keys()) if (world.hasTag(e, Dragging)) world.removeTag(e, Dragging);
1064
+ for (const [entity, originalZ] of prevState.originalZIndices) world.setComponent(entity, ZIndex, { value: originalZ });
1065
+ const entityIds = [...prevState.startPositions.keys()];
1066
+ let totalDx = 0;
1067
+ let totalDy = 0;
1068
+ let movedSomething = false;
1069
+ if (entityIds.length > 0) {
1070
+ const firstId = entityIds[0];
1071
+ const start = prevState.startPositions.get(firstId);
1072
+ const current = world.getComponent(firstId, Transform2D);
1073
+ if (current && start) {
1074
+ totalDx = current.x - start.x;
1075
+ totalDy = current.y - start.y;
1076
+ if (totalDx !== 0 || totalDy !== 0) {
1077
+ for (const [e, s] of prevState.startPositions) world.setComponent(e, Transform2D, {
1078
+ x: s.x,
1079
+ y: s.y
1080
+ });
1081
+ movedSomething = true;
1082
+ }
1083
+ }
1084
+ }
1085
+ let consumeSnapshot;
1086
+ let consumeHandlers;
1087
+ let consumeMutation;
1088
+ let shouldEmitConsume = false;
1089
+ if (shouldConsume && target !== null) {
1090
+ const parentType = world.getComponent(target, Widget)?.type ?? "";
1091
+ consumeHandlers = ctx.getWidgetInteraction?.(parentType);
1092
+ const result = consumeHandlers?.onReceiveChild?.({
1093
+ parent: target,
1094
+ child: draggedId,
1095
+ world
1096
+ }) ?? { consume: true };
1097
+ if (result.consume) {
1098
+ consumeSnapshot = snapshotEntity(world, draggedId);
1099
+ consumeMutation = result.mutation;
1100
+ shouldEmitConsume = true;
1101
+ }
1102
+ }
1103
+ if (movedSomething) commandBuffer.execute(new MoveCommand(entityIds, totalDx, totalDy, Transform2D), world);
1104
+ if (shouldEmitConsume && target !== null && consumeSnapshot) {
1105
+ commandBuffer.execute(new ConsumeCommand(target, draggedId, consumeSnapshot, consumeMutation, consumeHandlers?.applyMutation, consumeHandlers?.revertMutation), world);
1106
+ if (world.hasTag(draggedId, Selected)) {
1107
+ world.removeTag(draggedId, Selected);
1108
+ notifySelectionChanged();
1109
+ }
1110
+ }
1111
+ commandBuffer.endGroup();
1112
+ currentSnap = {
1113
+ snapDx: 0,
1114
+ snapDy: 0,
1115
+ guides: [],
1116
+ spacings: []
1117
+ };
1118
+ clearOverlapState();
1119
+ inputState = { mode: "idle" };
1120
+ markDirty();
1121
+ }
1122
+ function _beginResize(entity, handle, startWorldX, startWorldY) {
1123
+ const t = world.getComponent(entity, Transform2D);
1124
+ if (!t) return false;
1125
+ commandBuffer.beginGroup();
1126
+ inputState = {
1127
+ mode: "resizing",
1128
+ entityId: entity,
1129
+ handle,
1130
+ startWorldX,
1131
+ startWorldY,
1132
+ startBounds: {
1133
+ x: t.x,
1134
+ y: t.y,
1135
+ width: t.width,
1136
+ height: t.height
1137
+ }
1138
+ };
1139
+ markDirty();
1140
+ return true;
1141
+ }
1142
+ function _updateResize(worldX, worldY) {
1143
+ if (inputState.mode !== "resizing") return;
1144
+ const dx = worldX - inputState.startWorldX;
1145
+ const dy = worldY - inputState.startWorldY;
1146
+ const { x, y, width: w, height: h } = inputState.startBounds;
1147
+ const handle = inputState.handle;
1148
+ let newX = x;
1149
+ let newY = y;
1150
+ let newW = w;
1151
+ let newH = h;
1152
+ if (handle.includes("e")) newW = Math.max(20, w + dx);
1153
+ if (handle.includes("w")) {
1154
+ const clampedW = Math.max(20, w - dx);
1155
+ newX = x + w - clampedW;
1156
+ newW = clampedW;
1157
+ }
1158
+ if (handle.includes("s")) newH = Math.max(20, h + dy);
1159
+ if (handle.includes("n")) {
1160
+ const clampedH = Math.max(20, h - dy);
1161
+ newY = y + h - clampedH;
1162
+ newH = clampedH;
1163
+ }
1164
+ world.setComponent(inputState.entityId, Transform2D, {
1165
+ x: newX,
1166
+ y: newY,
1167
+ width: newW,
1168
+ height: newH
1169
+ });
1170
+ markDirty();
1171
+ }
1172
+ function _endResize(cancelled) {
1173
+ const prevState = inputState;
1174
+ if (prevState.mode !== "resizing") return;
1175
+ if (cancelled) {
1176
+ world.setComponent(prevState.entityId, Transform2D, prevState.startBounds);
1177
+ commandBuffer.endGroup();
1178
+ inputState = { mode: "idle" };
1179
+ markDirty();
1180
+ return;
1181
+ }
1182
+ const t = world.getComponent(prevState.entityId, Transform2D);
1183
+ if (t) {
1184
+ const finalBounds = {
1185
+ x: t.x,
1186
+ y: t.y,
1187
+ width: t.width,
1188
+ height: t.height
1189
+ };
1190
+ const sb = prevState.startBounds;
1191
+ world.setComponent(prevState.entityId, Transform2D, sb);
1192
+ commandBuffer.execute(new ResizeCommand(prevState.entityId, sb, finalBounds, Transform2D), world);
1193
+ }
1194
+ commandBuffer.endGroup();
1195
+ inputState = { mode: "idle" };
1196
+ markDirty();
1197
+ }
1198
+ function _beginMarquee(startWorldX, startWorldY) {
1199
+ inputState = {
1200
+ mode: "marquee",
1201
+ startWorldX,
1202
+ startWorldY
1203
+ };
1204
+ markDirty();
1205
+ }
1206
+ function _updateMarquee(_worldX, _worldY) {}
1207
+ function _endMarquee() {
1208
+ if (inputState.mode !== "marquee") return;
1209
+ inputState = { mode: "idle" };
1210
+ markDirty();
1211
+ }
1212
+ /**
1213
+ * Cancel any active drag/resize/flyingBack state. Used by both the
1214
+ * legacy `handlePointerCancel` and as the implementation of the
1215
+ * new public `endDrag(_, { cancelled: true })` / `endResize(_, …)`.
1216
+ */
1217
+ function _cancelAll() {
1218
+ if (inputState.mode === "dragging") _endDrag(true);
1219
+ else if (inputState.mode === "resizing") _endResize(true);
1220
+ else if (inputState.mode === "flyingBack") {
1221
+ for (const [e, start] of inputState.startPositions) {
1222
+ if (!world.entityExists(e)) continue;
1223
+ if (world.hasComponent(e, TransformTween)) world.removeComponent(e, TransformTween);
1224
+ if (world.hasTag(e, Dragging)) world.removeTag(e, Dragging);
1225
+ world.setComponent(e, Transform2D, {
1226
+ x: start.x,
1227
+ y: start.y
1228
+ });
1229
+ }
1230
+ for (const [entity, originalZ] of inputState.originalZIndices) {
1231
+ if (!world.entityExists(entity)) continue;
1232
+ world.setComponent(entity, ZIndex, { value: originalZ });
1233
+ }
1234
+ currentSnap = {
1235
+ snapDx: 0,
1236
+ snapDy: 0,
1237
+ guides: [],
1238
+ spacings: []
1239
+ };
1240
+ inputState = { mode: "idle" };
1241
+ markDirty();
1242
+ } else if (inputState.mode === "marquee") _endMarquee();
1243
+ else if (inputState.mode === "tracking") {
1244
+ inputState = { mode: "idle" };
1245
+ markDirty();
1246
+ }
1247
+ }
1248
+ function handlePointerDown(screenX, screenY, _button, modifiers) {
1249
+ const startWorld = screenToWorld(screenX, screenY, world.getResource(CameraResource));
1250
+ if (inputState.mode === "flyingBack") {
1251
+ const hit = hitTest(screenX, screenY);
1252
+ const target = hit?.entityId === inputState.entityId ? hit.entityId : null;
1253
+ if (target !== null) {
1254
+ world.removeComponent(target, TransformTween);
1255
+ const newStartPositions = /* @__PURE__ */ new Map();
1256
+ for (const e of inputState.startPositions.keys()) {
1257
+ const cur = world.getComponent(e, Transform2D);
1258
+ if (cur) newStartPositions.set(e, {
1259
+ x: cur.x,
1260
+ y: cur.y
1261
+ });
1262
+ if (world.hasComponent(e, TransformTween)) world.removeComponent(e, TransformTween);
1263
+ }
1264
+ commandBuffer.beginGroup();
1265
+ inputState = {
1266
+ mode: "dragging",
1267
+ entityId: target,
1268
+ startWorldX: startWorld.x,
1269
+ startWorldY: startWorld.y,
1270
+ startPositions: newStartPositions,
1271
+ originalZIndices: inputState.originalZIndices
1272
+ };
1273
+ markDirty();
1274
+ return { action: "capture-drag" };
1275
+ }
1276
+ }
1277
+ const hit = hitTest(screenX, screenY);
1278
+ if (!hit) {
1279
+ clearSelection();
1280
+ _beginMarquee(startWorld.x, startWorld.y);
1281
+ return { action: "capture-marquee" };
1282
+ }
1283
+ switch (hit.role.role.type) {
1284
+ case "resize":
1285
+ if (!_beginResize(hit.entityId, hit.role.role.handle, startWorld.x, startWorld.y)) return { action: "passthrough" };
1286
+ return {
1287
+ action: "capture-resize",
1288
+ handle: hit.role.role.handle
1289
+ };
1290
+ case "drag":
1291
+ selectEntity(hit.entityId, modifiers.shift);
1292
+ if (world.hasTag(hit.entityId, Draggable)) inputState = {
1293
+ mode: "tracking",
1294
+ entityId: hit.entityId,
1295
+ startScreenX: screenX,
1296
+ startScreenY: screenY
1297
+ };
1298
+ markDirty();
1299
+ return { action: "passthrough-track-drag" };
1300
+ case "select":
1301
+ selectEntity(hit.entityId, modifiers.shift);
1302
+ markDirty();
1303
+ return { action: "passthrough" };
1304
+ default: return { action: "passthrough" };
1305
+ }
1306
+ }
1307
+ function handlePointerMove(screenX, screenY, _modifiers) {
1308
+ const w = screenToWorld(screenX, screenY, world.getResource(CameraResource));
1309
+ if (inputState.mode === "tracking") {
1310
+ const dx = screenX - inputState.startScreenX;
1311
+ const dy = screenY - inputState.startScreenY;
1312
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
1313
+ _beginDrag(inputState.entityId, w.x, w.y);
1314
+ return { action: "capture-drag" };
1315
+ }
1316
+ return { action: "passthrough" };
1317
+ }
1318
+ if (inputState.mode === "dragging") {
1319
+ _updateDrag(w.x, w.y);
1320
+ return { action: "capture-drag" };
1321
+ }
1322
+ if (inputState.mode === "resizing") {
1323
+ const handle = inputState.handle;
1324
+ _updateResize(w.x, w.y);
1325
+ return {
1326
+ action: "capture-resize",
1327
+ handle
1328
+ };
1329
+ }
1330
+ if (inputState.mode === "marquee") {
1331
+ _updateMarquee(w.x, w.y);
1332
+ return { action: "capture-marquee" };
1333
+ }
1334
+ if (inputState.mode === "idle") {
1335
+ const hit = hitTest(screenX, screenY);
1336
+ const hoverTarget = hit ? hit.entityId : null;
1337
+ const hoverHandle = hit?.role.role.type === "resize" ? hit.role.role.handle : null;
1338
+ if (hoverTarget !== hoveredEntity || hoverHandle !== hoveredHandle) {
1339
+ hoveredEntity = hoverTarget;
1340
+ hoveredHandle = hoverHandle;
1341
+ markDirty();
1342
+ }
1343
+ }
1344
+ return { action: "passthrough" };
1345
+ }
1346
+ function handlePointerUp() {
1347
+ const mode = inputState.mode;
1348
+ if (mode === "dragging") _endDrag(false);
1349
+ else if (mode === "resizing") _endResize(false);
1350
+ else if (mode === "marquee") _endMarquee();
1351
+ else if (mode === "tracking") inputState = { mode: "idle" };
1352
+ return { action: "passthrough" };
1353
+ }
1354
+ function handlePointerCancel() {
1355
+ _cancelAll();
1356
+ }
1357
+ function beginDrag(_entity, worldX, worldY) {
1358
+ _beginDrag(_entity, worldX, worldY);
1359
+ }
1360
+ function updateDrag(_entity, worldX, worldY) {
1361
+ _updateDrag(worldX, worldY);
1362
+ }
1363
+ function endDrag(_entity, opts) {
1364
+ _endDrag(opts.cancelled);
1365
+ }
1366
+ function beginResize(entity, handle, worldX, worldY) {
1367
+ return _beginResize(entity, handle, worldX, worldY);
1368
+ }
1369
+ function updateResize(_entity, worldX, worldY) {
1370
+ _updateResize(worldX, worldY);
1371
+ }
1372
+ function endResize(_entity, opts) {
1373
+ _endResize(opts.cancelled);
1374
+ }
1375
+ function beginMarquee(worldX, worldY) {
1376
+ _beginMarquee(worldX, worldY);
1377
+ }
1378
+ function updateMarquee(worldX, worldY) {
1379
+ _updateMarquee(worldX, worldY);
1380
+ }
1381
+ function endMarquee() {
1382
+ _endMarquee();
1383
+ }
1384
+ function isMarqueeActive() {
1385
+ return inputState.mode === "marquee";
1386
+ }
1387
+ function getDraggingEntity() {
1388
+ return inputState.mode === "dragging" ? inputState.entityId : null;
1389
+ }
1390
+ function isResizing() {
1391
+ return inputState.mode === "resizing";
1392
+ }
1393
+ function getResizingEntity() {
1394
+ return inputState.mode === "resizing" ? inputState.entityId : null;
1395
+ }
1396
+ function setHoveredEntity(entity) {
1397
+ if (entity === hoveredEntity) return;
1398
+ hoveredEntity = entity;
1399
+ hoveredHandle = null;
1400
+ markDirty();
1401
+ }
1402
+ /**
1403
+ * Update hover state from a screen-space pointer position. Runs the
1404
+ * full hit-test so both `hoveredEntity` and `hoveredHandle` (RFC-005
1405
+ * resize hotspot under the cursor, if any) reflect the current pixel.
1406
+ *
1407
+ * Gated on `idle` mode — hover doesn't refresh during drag / resize /
1408
+ * marquee / fly-back (matches the v1 `handlePointerMove` idle branch).
1409
+ *
1410
+ * Called from the InputManager pipeline's `move` engine handler on
1411
+ * every pointermove: HoverRecognizer's `hover-enter` / `hover-leave`
1412
+ * events only fire on entity transitions, but the cursor needs to
1413
+ * change between resize handles within the same entity, so this path
1414
+ * runs the hit-test per move.
1415
+ */
1416
+ function updateHover(screenX, screenY) {
1417
+ if (inputState.mode !== "idle") return;
1418
+ const hit = hitTest(screenX, screenY);
1419
+ const target = hit ? hit.entityId : null;
1420
+ const handle = hit?.role.role.type === "resize" ? hit.role.role.handle : null;
1421
+ if (target === hoveredEntity && handle === hoveredHandle) return;
1422
+ hoveredEntity = target;
1423
+ hoveredHandle = handle;
1424
+ markDirty();
1425
+ }
1426
+ /**
1427
+ * Cancel any active interaction state (drag / resize / marquee /
1428
+ * fly-back / tracking). Public surface for the InputManager pipeline's
1429
+ * `cancel` engine handler — `endDrag(.., {cancelled:true})` only handles
1430
+ * `dragging`, but a native `pointercancel` can land in any of the other
1431
+ * mid-gesture modes. `_cancelAll` covers them all.
1432
+ */
1433
+ function cancelInteraction() {
1434
+ _cancelAll();
1435
+ }
1436
+ return {
1437
+ handlePointerDown,
1438
+ handlePointerMove,
1439
+ handlePointerUp,
1440
+ handlePointerCancel,
1441
+ runCursorSystem,
1442
+ runFlyBackSystem,
1443
+ selectEntity,
1444
+ clearSelection,
1445
+ getHoveredEntity: () => hoveredEntity,
1446
+ setHoveredEntity,
1447
+ updateHover,
1448
+ getSnapGuides: () => currentSnap.guides,
1449
+ getEqualSpacing: () => currentSnap.spacings,
1450
+ /**
1451
+ * Topmost interactable entity under a screen-space point, or null
1452
+ * if nothing's there. Same hit-test the pointer state machine
1453
+ * uses, exposed for callers that need to resolve coords to an
1454
+ * entity without entering the state machine — used by
1455
+ * `installEngineHandlers` (tap, drag-start, double-tap routing) and
1456
+ * the InputManager dispatch loop (surface routing for R3F widgets).
1457
+ */
1458
+ pickAt: (screenX, screenY) => hitTest(screenX, screenY)?.entityId ?? null,
1459
+ beginDrag,
1460
+ updateDrag,
1461
+ endDrag,
1462
+ cancelInteraction,
1463
+ beginResize,
1464
+ updateResize,
1465
+ endResize,
1466
+ isResizing,
1467
+ getResizingEntity,
1468
+ /**
1469
+ * Rich variant of `pickAt` — returns the entity AND its
1470
+ * `InteractionRoleData` (role + layer) at a screen-space point.
1471
+ * Used by `installEngineHandlers` to role-branch on `drag-start`
1472
+ * (resize → `beginResize`, drag → `beginDrag`, etc.). External
1473
+ * callers that only need the entity id should use `pickAt`.
1474
+ */
1475
+ hitTest,
1476
+ beginMarquee,
1477
+ updateMarquee,
1478
+ endMarquee,
1479
+ isMarqueeActive,
1480
+ getDraggingEntity
1481
+ };
1482
+ }
1483
+ //#endregion
1484
+ //#region src/ecs/engine/widget-binding.ts
1485
+ function createWidgetRegistry(defs = []) {
1486
+ const map = /* @__PURE__ */ new Map();
1487
+ for (const def of defs) map.set(def.type, def);
1488
+ return {
1489
+ register(def) {
1490
+ map.set(def.type, def);
1491
+ },
1492
+ get(type) {
1493
+ return map.get(type) ?? null;
1494
+ },
1495
+ getAll() {
1496
+ return [...map.values()];
1497
+ }
1498
+ };
1499
+ }
1500
+ //#endregion
1501
+ //#region src/ecs/engine/LayoutEngine.ts
1502
+ /**
1503
+ * Creates a new LayoutEngine instance with the given configuration.
1504
+ * This is the main entry point for the infinite canvas library.
1505
+ */
1506
+ function createLayoutEngine(config) {
1507
+ const world = createWorld();
1508
+ const scheduler = new SystemScheduler();
1509
+ const spatialIndex = new SpatialIndex();
1510
+ const profiler = new Profiler();
1511
+ scheduler.profiler = profiler;
1512
+ world.setResource(SpatialIndexResource, { instance: spatialIndex });
1513
+ const commandBuffer = new CommandBuffer();
1514
+ const widgetRegistry = createWidgetRegistry();
1515
+ const archetypeRegistry = createArchetypeRegistry();
1516
+ if (config?.zoom) world.setResource(ZoomConfigResource, config.zoom);
1517
+ if (config?.breakpoints) world.setResource(BreakpointConfigResource, config.breakpoints);
1518
+ if (config?.cardPresets) {
1519
+ const current = world.getResource(CardPresetsResource);
1520
+ world.setResource(CardPresetsResource, {
1521
+ presets: {
1522
+ ...current.presets,
1523
+ ...config.cardPresets.presets
1524
+ },
1525
+ gap: config.cardPresets.gap ?? current.gap
1526
+ });
1527
+ }
1528
+ let snapEnabled = config?.snap?.enabled ?? true;
1529
+ let snapThreshold = config?.snap?.threshold ?? 5;
1530
+ let snapGuidesVisible = config?.snap?.guidesVisible ?? true;
1531
+ scheduler.register(cardSystem);
1532
+ scheduler.register(transformTweenSystem);
1533
+ scheduler.register(navigationFilterSystem);
1534
+ scheduler.register(cullSystem);
1535
+ scheduler.register(breakpointSystem);
1536
+ scheduler.register(sortSystem);
1537
+ scheduler.register(dragPromoteSystem);
1538
+ const unsubscribers = [];
1539
+ unsubscribers.push(world.onComponentChanged(Transform2D, (entityId, _prev, t) => {
1540
+ if (t) spatialIndex.upsert(entityId, rectToAABB(t));
1541
+ }));
1542
+ unsubscribers.push(world.onEntityDestroyed((entity) => {
1543
+ spatialIndex.remove(entity);
1544
+ }));
1545
+ unsubscribers.push(world.onComponentChanged(Container, (entityId, prev, next) => {
1546
+ if (prev === void 0 && next !== void 0) {
1547
+ if (!world.hasComponent(entityId, ContainerCamera)) world.addComponent(entityId, ContainerCamera, {
1548
+ x: 0,
1549
+ y: 0,
1550
+ zoom: 1
1551
+ });
1552
+ }
1553
+ }));
1554
+ unsubscribers.push(world.onComponentChanged(ParentFrame, (entityId) => {
1555
+ reconcileEntityActive(world, entityId);
1556
+ markDirtyInternal();
1557
+ }));
1558
+ function refreshInteractionRole(entity) {
1559
+ const current = world.getComponent(entity, InteractionRole);
1560
+ if (current && current.role.type !== "drag" && current.role.type !== "select" && current.role.type !== "canvas") return;
1561
+ const hasDraggable = world.hasTag(entity, Draggable);
1562
+ const hasSelectable = world.hasTag(entity, Selectable);
1563
+ const desiredRole = hasDraggable ? { type: "drag" } : hasSelectable ? { type: "select" } : null;
1564
+ if (desiredRole === null) {
1565
+ if (current) world.removeComponent(entity, InteractionRole);
1566
+ if (world.hasComponent(entity, CursorHint)) world.removeComponent(entity, CursorHint);
1567
+ return;
1568
+ }
1569
+ if (!current) world.addComponent(entity, InteractionRole, {
1570
+ layer: 5,
1571
+ role: desiredRole
1572
+ });
1573
+ else if (current.role.type !== desiredRole.type) world.setComponent(entity, InteractionRole, { role: desiredRole });
1574
+ if (desiredRole.type === "drag" && !world.hasComponent(entity, CursorHint)) world.addComponent(entity, CursorHint, {
1575
+ hover: "grab",
1576
+ active: "grabbing"
1577
+ });
1578
+ }
1579
+ unsubscribers.push(world.onTagAdded(Draggable, refreshInteractionRole));
1580
+ unsubscribers.push(world.onTagRemoved(Draggable, refreshInteractionRole));
1581
+ unsubscribers.push(world.onTagAdded(Selectable, refreshInteractionRole));
1582
+ unsubscribers.push(world.onTagRemoved(Selectable, refreshInteractionRole));
1583
+ if (config?.widgets) for (const w of config.widgets) widgetRegistry.register(w);
1584
+ if (config?.archetypes) for (const a of config.archetypes) archetypeRegistry.register(a);
1585
+ world.setResource(NavigationStackResource, { changed: true });
1586
+ let dirty = false;
1587
+ let cameraChangedThisTick = false;
1588
+ let selectionChangedThisTick = false;
1589
+ let prevVisible = /* @__PURE__ */ new Set();
1590
+ let currentVisible = [];
1591
+ let frameChanges = {
1592
+ positionsChanged: [],
1593
+ breakpointsChanged: [],
1594
+ zIndicesChanged: [],
1595
+ entered: [],
1596
+ exited: [],
1597
+ cameraChanged: false,
1598
+ navigationChanged: false,
1599
+ selectionChanged: false,
1600
+ layersChanged: false
1601
+ };
1602
+ function markDirtyInternal() {
1603
+ dirty = true;
1604
+ }
1605
+ const interaction = createInteractionRuntime({
1606
+ world,
1607
+ spatialIndex,
1608
+ commandBuffer,
1609
+ markDirty: markDirtyInternal,
1610
+ notifySelectionChanged: () => {
1611
+ selectionChangedThisTick = true;
1612
+ },
1613
+ getSnapEnabled: () => snapEnabled,
1614
+ getSnapThreshold: () => snapThreshold,
1615
+ getWidgetInteraction: (type) => widgetRegistry.get(type)?.interaction
1616
+ });
1617
+ const engine = {
1618
+ world,
1619
+ createEntity(inits) {
1620
+ const entity = world.createEntity();
1621
+ if (inits) for (const init of inits) {
1622
+ const type = init[0];
1623
+ if (type.__kind === "tag") world.addTag(entity, type);
1624
+ else world.addComponent(entity, type, init[1] ?? {});
1625
+ }
1626
+ markDirtyInternal();
1627
+ return entity;
1628
+ },
1629
+ spawn(id, opts = {}) {
1630
+ const archetype = archetypeRegistry.get(id);
1631
+ const widgetTypeId = archetype?.widget ?? id;
1632
+ const widget = widgetRegistry.get(widgetTypeId);
1633
+ const surface = widget?.surface ?? "dom";
1634
+ const defaultData = widget?.defaultData ?? {};
1635
+ const defaultSize = archetype?.defaultSize ?? widget?.defaultSize ?? {
1636
+ width: 100,
1637
+ height: 100
1638
+ };
1639
+ const position = opts.at ?? {
1640
+ x: 0,
1641
+ y: 0
1642
+ };
1643
+ const size = opts.size ?? defaultSize;
1644
+ const data = {
1645
+ ...defaultData,
1646
+ ...opts.data
1647
+ };
1648
+ const inits = [
1649
+ [Transform2D, {
1650
+ x: position.x,
1651
+ y: position.y,
1652
+ width: size.width,
1653
+ height: size.height,
1654
+ rotation: opts.rotation ?? 0
1655
+ }],
1656
+ [Widget, {
1657
+ surface,
1658
+ type: widgetTypeId
1659
+ }],
1660
+ [WidgetData, { data }],
1661
+ [ZIndex, { value: opts.zIndex ?? 0 }]
1662
+ ];
1663
+ if (archetype?.components) for (const init of archetype.components) inits.push(init);
1664
+ if (opts.parent !== void 0) inits.push([ParentFrame, { id: opts.parent }]);
1665
+ const interactiveConfig = archetype?.interactive;
1666
+ const caps = interactiveConfig === false ? {
1667
+ selectable: false,
1668
+ draggable: false,
1669
+ resizable: false,
1670
+ selectionFrame: false,
1671
+ snapSource: false,
1672
+ snapTarget: false
1673
+ } : interactiveConfig === void 0 || interactiveConfig === true ? {
1674
+ selectable: true,
1675
+ draggable: true,
1676
+ resizable: true,
1677
+ selectionFrame: true,
1678
+ snapSource: true,
1679
+ snapTarget: true
1680
+ } : (() => {
1681
+ const selectable = interactiveConfig.selectable ?? false;
1682
+ return {
1683
+ selectable,
1684
+ draggable: interactiveConfig.draggable ?? false,
1685
+ resizable: interactiveConfig.resizable ?? false,
1686
+ selectionFrame: interactiveConfig.selectionFrame ?? selectable,
1687
+ snapSource: interactiveConfig.snapSource ?? false,
1688
+ snapTarget: interactiveConfig.snapTarget ?? false
1689
+ };
1690
+ })();
1691
+ if (caps.selectable) inits.push([Selectable]);
1692
+ if (caps.draggable) inits.push([Draggable]);
1693
+ if (caps.resizable) inits.push([Resizable]);
1694
+ if (caps.selectionFrame) inits.push([SelectionFrame]);
1695
+ if (caps.snapSource) inits.push([SnapSource]);
1696
+ if (caps.snapTarget) inits.push([SnapTarget]);
1697
+ if (archetype?.tags) for (const tag of archetype.tags) inits.push([tag]);
1698
+ const entity = engine.createEntity(inits);
1699
+ if (opts.parent !== void 0 && world.hasComponent(opts.parent, ContainerChildren)) {
1700
+ const current = world.getComponent(opts.parent, ContainerChildren);
1701
+ if (current && !current.ids.includes(entity)) world.setComponent(opts.parent, ContainerChildren, { ids: [...current.ids, entity] });
1702
+ }
1703
+ return entity;
1704
+ },
1705
+ spawnAtCameraCenter(id, opts = {}) {
1706
+ const camera = world.getResource(CameraResource);
1707
+ const viewport = world.getResource(ViewportResource);
1708
+ const centerX = camera.x + viewport.width / (2 * camera.zoom);
1709
+ const centerY = camera.y + viewport.height / (2 * camera.zoom);
1710
+ const archetype = archetypeRegistry.get(id);
1711
+ const widget = widgetRegistry.get(archetype?.widget ?? id);
1712
+ const size = opts.size ?? archetype?.defaultSize ?? widget?.defaultSize ?? {
1713
+ width: 100,
1714
+ height: 100
1715
+ };
1716
+ return engine.spawn(id, {
1717
+ ...opts,
1718
+ at: {
1719
+ x: centerX - size.width / 2,
1720
+ y: centerY - size.height / 2
1721
+ }
1722
+ });
1723
+ },
1724
+ registerWidget(widget) {
1725
+ widgetRegistry.register(widget);
1726
+ },
1727
+ getWidget(type) {
1728
+ return widgetRegistry.get(type);
1729
+ },
1730
+ getWidgets() {
1731
+ return widgetRegistry.getAll();
1732
+ },
1733
+ registerArchetype(archetype) {
1734
+ archetypeRegistry.register(archetype);
1735
+ },
1736
+ getArchetype(id) {
1737
+ return archetypeRegistry.get(id);
1738
+ },
1739
+ destroyEntity(id) {
1740
+ spatialIndex.remove(id);
1741
+ world.destroyEntity(id);
1742
+ markDirtyInternal();
1743
+ },
1744
+ get(entity, type) {
1745
+ return world.getComponent(entity, type);
1746
+ },
1747
+ set(entity, type, data) {
1748
+ world.setComponent(entity, type, data);
1749
+ markDirtyInternal();
1750
+ },
1751
+ has(entity, type) {
1752
+ if (type.__kind === "tag") return world.hasTag(entity, type);
1753
+ return world.hasComponent(entity, type);
1754
+ },
1755
+ addComponent(entity, type, data) {
1756
+ world.addComponent(entity, type, data ?? type.defaults);
1757
+ markDirtyInternal();
1758
+ },
1759
+ removeComponent(entity, type) {
1760
+ world.removeComponent(entity, type);
1761
+ markDirtyInternal();
1762
+ },
1763
+ addTag(entity, type) {
1764
+ world.addTag(entity, type);
1765
+ markDirtyInternal();
1766
+ },
1767
+ removeTag(entity, type) {
1768
+ world.removeTag(entity, type);
1769
+ markDirtyInternal();
1770
+ },
1771
+ getSchemaFor(entity) {
1772
+ const w = world.getComponent(entity, Widget);
1773
+ if (!w) return void 0;
1774
+ return widgetRegistry.get(w.type)?.schema;
1775
+ },
1776
+ registerSystem(system) {
1777
+ scheduler.register(system);
1778
+ },
1779
+ removeSystem(name) {
1780
+ scheduler.remove(name);
1781
+ },
1782
+ getCamera() {
1783
+ return world.getResource(CameraResource);
1784
+ },
1785
+ panBy(dx, dy) {
1786
+ const camera = world.getResource(CameraResource);
1787
+ camera.x -= dx / camera.zoom;
1788
+ camera.y -= dy / camera.zoom;
1789
+ cameraChangedThisTick = true;
1790
+ markDirtyInternal();
1791
+ },
1792
+ panTo(worldX, worldY) {
1793
+ const camera = world.getResource(CameraResource);
1794
+ const viewport = world.getResource(ViewportResource);
1795
+ camera.x = worldX - viewport.width / (2 * camera.zoom);
1796
+ camera.y = worldY - viewport.height / (2 * camera.zoom);
1797
+ cameraChangedThisTick = true;
1798
+ markDirtyInternal();
1799
+ },
1800
+ zoomAtPoint(screenX, screenY, delta) {
1801
+ const camera = world.getResource(CameraResource);
1802
+ const zoomConfig = world.getResource(ZoomConfigResource);
1803
+ const worldBefore = screenToWorld(screenX, screenY, camera);
1804
+ const newZoom = clamp(camera.zoom * (1 + delta), zoomConfig.min, zoomConfig.max);
1805
+ camera.zoom = newZoom;
1806
+ camera.x = worldBefore.x - screenX / newZoom;
1807
+ camera.y = worldBefore.y - screenY / newZoom;
1808
+ cameraChangedThisTick = true;
1809
+ markDirtyInternal();
1810
+ },
1811
+ zoomTo(zoom) {
1812
+ const camera = world.getResource(CameraResource);
1813
+ const zoomConfig = world.getResource(ZoomConfigResource);
1814
+ const viewport = world.getResource(ViewportResource);
1815
+ const centerWorldX = camera.x + viewport.width / (2 * camera.zoom);
1816
+ const centerWorldY = camera.y + viewport.height / (2 * camera.zoom);
1817
+ camera.zoom = clamp(zoom, zoomConfig.min, zoomConfig.max);
1818
+ camera.x = centerWorldX - viewport.width / (2 * camera.zoom);
1819
+ camera.y = centerWorldY - viewport.height / (2 * camera.zoom);
1820
+ cameraChangedThisTick = true;
1821
+ markDirtyInternal();
1822
+ },
1823
+ /**
1824
+ * Toggle the camera's `gesturing` flag. Called by gesture handlers
1825
+ * (wheel debounced, touch pinch / pan start+end) so render layers
1826
+ * can defer expensive work — e.g. the R3F compositor skips zoom-band
1827
+ * repaints while gesturing is true so a continuous pinch doesn't
1828
+ * trigger a repaint storm across every visible widget.
1829
+ */
1830
+ setGesturing(active) {
1831
+ const camera = world.getResource(CameraResource);
1832
+ if (camera.gesturing === active) return;
1833
+ camera.gesturing = active;
1834
+ cameraChangedThisTick = true;
1835
+ markDirtyInternal();
1836
+ },
1837
+ zoomToFit(entityIds, padding = 50) {
1838
+ const viewport = world.getResource(ViewportResource);
1839
+ if (viewport.width === 0) return;
1840
+ const entities = entityIds ?? world.queryTagged(Active);
1841
+ if (entities.length === 0) return;
1842
+ let minX = Number.POSITIVE_INFINITY;
1843
+ let minY = Number.POSITIVE_INFINITY;
1844
+ let maxX = Number.NEGATIVE_INFINITY;
1845
+ let maxY = Number.NEGATIVE_INFINITY;
1846
+ for (const e of entities) {
1847
+ const t = world.getComponent(e, Transform2D);
1848
+ if (!t) continue;
1849
+ minX = Math.min(minX, t.x);
1850
+ minY = Math.min(minY, t.y);
1851
+ maxX = Math.max(maxX, t.x + t.width);
1852
+ maxY = Math.max(maxY, t.y + t.height);
1853
+ }
1854
+ if (!Number.isFinite(minX)) return;
1855
+ const contentWidth = maxX - minX + padding * 2;
1856
+ const contentHeight = maxY - minY + padding * 2;
1857
+ const zoomConfig = world.getResource(ZoomConfigResource);
1858
+ const zoom = clamp(Math.min(viewport.width / contentWidth, viewport.height / contentHeight), zoomConfig.min, zoomConfig.max);
1859
+ const camera = world.getResource(CameraResource);
1860
+ camera.zoom = zoom;
1861
+ camera.x = minX - padding - (viewport.width / zoom - contentWidth) / 2;
1862
+ camera.y = minY - padding - (viewport.height / zoom - contentHeight) / 2;
1863
+ cameraChangedThisTick = true;
1864
+ markDirtyInternal();
1865
+ },
1866
+ setViewport(width, height, dpr) {
1867
+ world.setResource(ViewportResource, {
1868
+ width,
1869
+ height,
1870
+ dpr: dpr ?? 1
1871
+ });
1872
+ markDirtyInternal();
1873
+ },
1874
+ execute(command) {
1875
+ commandBuffer.execute(command, world);
1876
+ markDirtyInternal();
1877
+ },
1878
+ beginCommandGroup() {
1879
+ commandBuffer.beginGroup();
1880
+ },
1881
+ endCommandGroup() {
1882
+ commandBuffer.endGroup();
1883
+ },
1884
+ undo() {
1885
+ const did = commandBuffer.undo(world);
1886
+ if (did) markDirtyInternal();
1887
+ return did;
1888
+ },
1889
+ redo() {
1890
+ const did = commandBuffer.redo(world);
1891
+ if (did) markDirtyInternal();
1892
+ return did;
1893
+ },
1894
+ canUndo() {
1895
+ return commandBuffer.canUndo();
1896
+ },
1897
+ canRedo() {
1898
+ return commandBuffer.canRedo();
1899
+ },
1900
+ handlePointerDown(screenX, screenY, button, modifiers) {
1901
+ return interaction.handlePointerDown(screenX, screenY, button, modifiers);
1902
+ },
1903
+ handlePointerMove(screenX, screenY, modifiers) {
1904
+ return interaction.handlePointerMove(screenX, screenY, modifiers);
1905
+ },
1906
+ handlePointerUp() {
1907
+ return interaction.handlePointerUp();
1908
+ },
1909
+ handlePointerCancel() {
1910
+ interaction.handlePointerCancel();
1911
+ },
1912
+ pickAt(screenX, screenY) {
1913
+ return interaction.pickAt(screenX, screenY);
1914
+ },
1915
+ hitTest(screenX, screenY) {
1916
+ return interaction.hitTest(screenX, screenY);
1917
+ },
1918
+ beginDrag(entity, worldX, worldY) {
1919
+ interaction.beginDrag(entity, worldX, worldY);
1920
+ },
1921
+ updateDrag(entity, worldX, worldY) {
1922
+ interaction.updateDrag(entity, worldX, worldY);
1923
+ },
1924
+ endDrag(entity, opts) {
1925
+ interaction.endDrag(entity, opts);
1926
+ },
1927
+ getDraggingEntity() {
1928
+ return interaction.getDraggingEntity();
1929
+ },
1930
+ cancelInteraction() {
1931
+ interaction.cancelInteraction();
1932
+ },
1933
+ beginResize(entity, handle, worldX, worldY) {
1934
+ return interaction.beginResize(entity, handle, worldX, worldY);
1935
+ },
1936
+ updateResize(entity, worldX, worldY) {
1937
+ interaction.updateResize(entity, worldX, worldY);
1938
+ },
1939
+ endResize(entity, opts) {
1940
+ interaction.endResize(entity, opts);
1941
+ },
1942
+ isResizing() {
1943
+ return interaction.isResizing();
1944
+ },
1945
+ getResizingEntity() {
1946
+ return interaction.getResizingEntity();
1947
+ },
1948
+ beginMarquee(worldX, worldY) {
1949
+ interaction.beginMarquee(worldX, worldY);
1950
+ },
1951
+ updateMarquee(worldX, worldY) {
1952
+ interaction.updateMarquee(worldX, worldY);
1953
+ },
1954
+ endMarquee() {
1955
+ interaction.endMarquee();
1956
+ },
1957
+ isMarqueeActive() {
1958
+ return interaction.isMarqueeActive();
1959
+ },
1960
+ getSelectedEntities() {
1961
+ return world.queryTagged(Selected);
1962
+ },
1963
+ selectEntity(entity, additive) {
1964
+ interaction.selectEntity(entity, additive);
1965
+ },
1966
+ clearSelection() {
1967
+ interaction.clearSelection();
1968
+ },
1969
+ getHoveredEntity() {
1970
+ return interaction.getHoveredEntity();
1971
+ },
1972
+ setHoveredEntity(entity) {
1973
+ interaction.setHoveredEntity(entity);
1974
+ },
1975
+ updateHover(screenX, screenY) {
1976
+ interaction.updateHover(screenX, screenY);
1977
+ },
1978
+ getSnapGuides() {
1979
+ return interaction.getSnapGuides();
1980
+ },
1981
+ getEqualSpacing() {
1982
+ return interaction.getEqualSpacing();
1983
+ },
1984
+ setSnapEnabled(on) {
1985
+ snapEnabled = on;
1986
+ markDirtyInternal();
1987
+ },
1988
+ setSnapThreshold(worldPx) {
1989
+ snapThreshold = worldPx;
1990
+ markDirtyInternal();
1991
+ },
1992
+ getSnapGuidesVisible() {
1993
+ return snapGuidesVisible;
1994
+ },
1995
+ setSnapGuidesVisible(on) {
1996
+ snapGuidesVisible = on;
1997
+ markDirtyInternal();
1998
+ },
1999
+ enterContainer(entity) {
2000
+ if (!world.hasComponent(entity, Container)) return;
2001
+ const navStack = world.getResource(NavigationStackResource);
2002
+ const camera = world.getResource(CameraResource);
2003
+ const outgoing = navStack.frames[navStack.frames.length - 1].containerId;
2004
+ if (outgoing === null) world.setResource(RootCameraResource, {
2005
+ x: camera.x,
2006
+ y: camera.y,
2007
+ zoom: camera.zoom
2008
+ });
2009
+ else world.setComponent(outgoing, ContainerCamera, {
2010
+ x: camera.x,
2011
+ y: camera.y,
2012
+ zoom: camera.zoom
2013
+ });
2014
+ navStack.frames.push({ containerId: entity });
2015
+ navStack.changed = true;
2016
+ const incoming = world.getComponent(entity, ContainerCamera) ?? {
2017
+ x: 0,
2018
+ y: 0,
2019
+ zoom: 1
2020
+ };
2021
+ camera.x = incoming.x;
2022
+ camera.y = incoming.y;
2023
+ camera.zoom = incoming.zoom;
2024
+ interaction.clearSelection();
2025
+ cameraChangedThisTick = true;
2026
+ markDirtyInternal();
2027
+ },
2028
+ exitContainer() {
2029
+ const navStack = world.getResource(NavigationStackResource);
2030
+ if (navStack.frames.length <= 1) return;
2031
+ const camera = world.getResource(CameraResource);
2032
+ const outgoing = navStack.frames[navStack.frames.length - 1].containerId;
2033
+ if (outgoing !== null) world.setComponent(outgoing, ContainerCamera, {
2034
+ x: camera.x,
2035
+ y: camera.y,
2036
+ zoom: camera.zoom
2037
+ });
2038
+ navStack.frames.pop();
2039
+ navStack.changed = true;
2040
+ const parent = navStack.frames[navStack.frames.length - 1].containerId;
2041
+ const incoming = parent === null ? world.getResource(RootCameraResource) : world.getComponent(parent, ContainerCamera) ?? {
2042
+ x: 0,
2043
+ y: 0,
2044
+ zoom: 1
2045
+ };
2046
+ camera.x = incoming.x;
2047
+ camera.y = incoming.y;
2048
+ camera.zoom = incoming.zoom;
2049
+ interaction.clearSelection();
2050
+ cameraChangedThisTick = true;
2051
+ markDirtyInternal();
2052
+ },
2053
+ getActiveContainer() {
2054
+ const navStack = world.getResource(NavigationStackResource);
2055
+ return navStack.frames[navStack.frames.length - 1].containerId;
2056
+ },
2057
+ getNavigationDepth() {
2058
+ return world.getResource(NavigationStackResource).frames.length - 1;
2059
+ },
2060
+ markDirty() {
2061
+ markDirtyInternal();
2062
+ },
2063
+ profiler,
2064
+ tick() {
2065
+ profiler.beginFrame(world.currentTick);
2066
+ const navigationChangedThisTick = world.getResource(NavigationStackResource)?.changed ?? false;
2067
+ scheduler.execute(world);
2068
+ interaction.runFlyBackSystem();
2069
+ interaction.runCursorSystem();
2070
+ profiler.beginVisibility();
2071
+ const newVisible = [];
2072
+ const newVisibleSet = /* @__PURE__ */ new Set();
2073
+ for (const entity of world.query(Widget, Visible)) {
2074
+ const t = world.getComponent(entity, Transform2D);
2075
+ const widget = world.getComponent(entity, Widget);
2076
+ const bp = world.getComponent(entity, WidgetBreakpoint);
2077
+ const zIdx = world.getComponent(entity, ZIndex);
2078
+ if (!t || !widget) continue;
2079
+ newVisibleSet.add(entity);
2080
+ newVisible.push({
2081
+ entityId: entity,
2082
+ x: t.x,
2083
+ y: t.y,
2084
+ width: t.width,
2085
+ height: t.height,
2086
+ breakpoint: bp?.current ?? "normal",
2087
+ zIndex: zIdx?.value ?? 0,
2088
+ surface: widget.surface,
2089
+ widgetType: widget.type
2090
+ });
2091
+ }
2092
+ newVisible.sort((a, b) => a.zIndex - b.zIndex);
2093
+ profiler.endVisibility();
2094
+ const entered = [];
2095
+ const exited = [];
2096
+ for (const entity of newVisibleSet) if (!prevVisible.has(entity)) entered.push(entity);
2097
+ for (const entity of prevVisible) if (!newVisibleSet.has(entity)) exited.push(entity);
2098
+ frameChanges = {
2099
+ positionsChanged: world.queryChanged(Transform2D),
2100
+ breakpointsChanged: world.queryChanged(WidgetBreakpoint),
2101
+ zIndicesChanged: world.queryChanged(ZIndex),
2102
+ entered,
2103
+ exited,
2104
+ cameraChanged: cameraChangedThisTick,
2105
+ navigationChanged: navigationChangedThisTick,
2106
+ selectionChanged: selectionChangedThisTick,
2107
+ layersChanged: world.queryChanged(Layer).length > 0
2108
+ };
2109
+ currentVisible = newVisible;
2110
+ prevVisible = newVisibleSet;
2111
+ cameraChangedThisTick = false;
2112
+ selectionChangedThisTick = false;
2113
+ profiler.endFrame(world.entityCount, newVisible.length);
2114
+ world.clearDirty();
2115
+ world.incrementTick();
2116
+ world.emitFrame();
2117
+ dirty = false;
2118
+ for (const _ of world.query(TransformTween)) {
2119
+ dirty = true;
2120
+ break;
2121
+ }
2122
+ },
2123
+ flushIfDirty() {
2124
+ if (!dirty) return false;
2125
+ engine.tick();
2126
+ return true;
2127
+ },
2128
+ getVisibleEntities() {
2129
+ return currentVisible;
2130
+ },
2131
+ getFrameChanges() {
2132
+ return frameChanges;
2133
+ },
2134
+ getSpatialIndex() {
2135
+ return spatialIndex;
2136
+ },
2137
+ onFrame(handler) {
2138
+ return world.onFrame(handler);
2139
+ },
2140
+ destroy() {
2141
+ for (const unsub of unsubscribers) unsub();
2142
+ unsubscribers.length = 0;
2143
+ commandBuffer.clear();
2144
+ profiler.setEnabled(false);
2145
+ profiler.clear();
2146
+ spatialIndex.clear();
2147
+ }
2148
+ };
2149
+ return engine;
2150
+ }
2151
+ //#endregion
2152
+ //#region src/ecs/hierarchy.ts
2153
+ /**
2154
+ * Walks the `ParentFrame` chain from `descendant` upward and returns
2155
+ * true iff `candidate` appears on that chain (inclusive check excluded:
2156
+ * an entity is not considered its own ancestor).
2157
+ *
2158
+ * Used by `CardContainer`'s default `canAccept` as a cycle-guard so
2159
+ * dragging a container onto one of its own descendants is rejected
2160
+ * before any mutation fires (RFC-004 § Phase 5). Cheap — the chain is
2161
+ * typically 0–3 deep. Capped at a sane depth to defend against
2162
+ * pathological / malformed worlds.
2163
+ */
2164
+ function isFrameAncestorOf(world, candidate, descendant) {
2165
+ if (candidate === descendant) return false;
2166
+ let current = descendant;
2167
+ for (let i = 0; i < 64; i++) {
2168
+ const pf = world.getComponent(current, ParentFrame);
2169
+ if (!pf) return false;
2170
+ if (pf.id === candidate) return true;
2171
+ current = pf.id;
2172
+ }
2173
+ return false;
2174
+ }
2175
+ //#endregion
2176
+ //#region src/react/hooks/widget.ts
8
2177
  /**
9
2178
  * Returns the custom data attached to a widget entity.
10
2179
  * Use the generic parameter for type safety: `useWidgetData<MyData>(entityId)`. Re-renders when data changes.
@@ -48,242 +2217,1269 @@ function useUpdateWidget(entityId) {
48
2217
  }, [engine, entityId]);
49
2218
  }
50
2219
  //#endregion
51
- //#region src/react/card.tsx
52
- /**
53
- * Built-in preset sizes, matching `CardPresetsResource` defaults.
54
- * Used by `createCardWidget` to set `defaultSize` at widget-registration
55
- * time (before the engine is constructed).
56
- */
57
- const DEFAULT_CARD_PRESET_SIZES$1 = {
58
- small: {
59
- width: 155,
60
- height: 155
61
- },
62
- medium: {
63
- width: 329,
64
- height: 155
65
- },
66
- large: {
67
- width: 329,
68
- height: 345
69
- },
70
- xl: {
71
- width: 329,
72
- height: 535
2220
+ //#region src/react/input/adapters/ClickAdapter.ts
2221
+ /**
2222
+ * Click / dblclick / contextmenu adapter (RFC-008 v6).
2223
+ *
2224
+ * The browser fires click / dblclick / contextmenu through a native event
2225
+ * channel that's distinct from `pointerdown` / `pointerup` — they're
2226
+ * synthesised after the pointer cycle completes (down → up with little
2227
+ * movement, double-click within a short window, right-click). Pre-v6 the
2228
+ * canvas had no adapter for these: R3F registered its own listeners on
2229
+ * the container via `EventManager.connect`, and the InputManager pipeline
2230
+ * never saw them. Two side effects:
2231
+ *
2232
+ * 1. A click on a mesh that called `setPointerCapture` skipped the
2233
+ * `tap` recognizer (capture-claim) so the engine's selection logic
2234
+ * didn't run — the widget didn't select.
2235
+ * 2. Clicks couldn't be observed by recognizers / handlers / external
2236
+ * listeners through the manager, only via direct R3F mesh handlers.
2237
+ *
2238
+ * v6 makes click / dblclick / contextmenu first-class `InputEvent`s. The
2239
+ * adapter dispatches them through `InputManager.dispatch`, which routes
2240
+ * to the surface router (R3FRouter for webgl widgets — invokes the
2241
+ * mesh's `onClick` etc.) and fires engine handlers (which run
2242
+ * `selectEntity` for click, `enterContainer` for dblclick).
2243
+ *
2244
+ * `preventDefault` discipline:
2245
+ * - `click` / `dblclick`: never. Browser focus / activation must run.
2246
+ * - `contextmenu`: always. Canvas isn't a place for the browser context
2247
+ * menu.
2248
+ *
2249
+ * Native-interactive skip mirrors `PointerAdapter`: a click landing on a
2250
+ * `<button>` / `<input>` / etc. inside a DOM widget is suppressed at the
2251
+ * adapter so the widget body doesn't get re-selected over the user's
2252
+ * intent (typing, button activation, etc.). Authors who want canvas-level
2253
+ * coexistence with their own native interactive call
2254
+ * `e.stopPropagation()` so the click never reaches the container.
2255
+ */
2256
+ var ClickAdapter = class {
2257
+ attach(container, manager) {
2258
+ const make = (type, e) => {
2259
+ const rect = container.getBoundingClientRect();
2260
+ const screen = {
2261
+ x: e.clientX - rect.left,
2262
+ y: e.clientY - rect.top
2263
+ };
2264
+ const camera = manager.engine.getCamera();
2265
+ const world = screenToWorld(screen.x, screen.y, camera);
2266
+ const button = e.button ?? null;
2267
+ return {
2268
+ type,
2269
+ source: clickSource(e),
2270
+ pointerId: pointerIdOf(e),
2271
+ primary: true,
2272
+ screen,
2273
+ world,
2274
+ button,
2275
+ modifiers: {
2276
+ shift: e.shiftKey,
2277
+ ctrl: e.ctrlKey,
2278
+ alt: e.altKey,
2279
+ meta: e.metaKey
2280
+ },
2281
+ timestamp: e.timeStamp,
2282
+ native: e
2283
+ };
2284
+ };
2285
+ const onClick = (e) => {
2286
+ const target = e.target;
2287
+ const targetTag = target ? `${target.tagName}${target.id ? `#${target.id}` : ""}` : "null";
2288
+ if (target?.closest("button, input, textarea, select, [contenteditable]")) {
2289
+ inputLog("Adapter", `click → SKIPPED (native interactive)`, { target: targetTag });
2290
+ return;
2291
+ }
2292
+ inputLog("Adapter", `click → InputManager`, {
2293
+ screen: {
2294
+ x: e.clientX - container.getBoundingClientRect().left,
2295
+ y: e.clientY - container.getBoundingClientRect().top
2296
+ },
2297
+ target: targetTag
2298
+ });
2299
+ manager.dispatch(make("click", e));
2300
+ };
2301
+ const onDblClick = (e) => {
2302
+ if (e.target?.closest("button, input, textarea, select, [contenteditable]")) return;
2303
+ inputLog("Adapter", `dblclick → InputManager`);
2304
+ manager.dispatch(make("dblclick", e));
2305
+ };
2306
+ const onContextMenu = (e) => {
2307
+ e.preventDefault();
2308
+ if (e.target?.closest("button, input, textarea, select, [contenteditable]")) return;
2309
+ inputLog("Adapter", `contextmenu → InputManager`);
2310
+ manager.dispatch(make("contextmenu", e));
2311
+ };
2312
+ container.addEventListener("click", onClick);
2313
+ container.addEventListener("dblclick", onDblClick);
2314
+ container.addEventListener("contextmenu", onContextMenu);
2315
+ return () => {
2316
+ container.removeEventListener("click", onClick);
2317
+ container.removeEventListener("dblclick", onDblClick);
2318
+ container.removeEventListener("contextmenu", onContextMenu);
2319
+ };
73
2320
  }
74
2321
  };
2322
+ function pointerIdOf(e) {
2323
+ const pe = e;
2324
+ return typeof pe.pointerId === "number" ? pe.pointerId : 0;
2325
+ }
2326
+ function clickSource(e) {
2327
+ switch (e.pointerType) {
2328
+ case "mouse": return "mouse";
2329
+ case "pen": return "pen";
2330
+ case "touch": return "touch";
2331
+ default: return "mouse";
2332
+ }
2333
+ }
2334
+ //#endregion
2335
+ //#region src/react/input/adapters/PointerAdapter.ts
75
2336
  /**
76
- * Visual chrome for an iOS-style card: rounded corners, hairline ring,
77
- * soft drop shadow, and a subtle lift (scale + stronger shadow) while
78
- * the entity carries the `Dragging` tag.
2337
+ * Native pointer-event adapter (RFC-008). Listens for pointerdown / move /
2338
+ * up / cancel / pointerleave on the canvas container, normalises into
2339
+ * `InputEvent`s, and dispatches via `manager.dispatch(...)`.
79
2340
  *
80
- * Uses CSS transitions no animation library dependency.
2341
+ * Single-finger and multi-finger touch flow through this adapter via the
2342
+ * browser's touch-to-pointer synthesis (`touch-action: none` on the
2343
+ * container is what makes synthesis reliable). PinchRecognizer counts
2344
+ * simultaneous active touch-source pointer IDs from these events.
2345
+ *
2346
+ * `preventDefault` discipline:
2347
+ * - Pointer events: never. Bubble must continue so widget React handlers
2348
+ * fire and so widgets can call `setPointerCapture` to claim drags.
2349
+ * - `contextmenu`: handled by `ClickAdapter`, not here.
2350
+ *
2351
+ * `pointerdown` on a native interactive target (button, input, etc.) is
2352
+ * suppressed at the adapter so a button click inside a DOM widget can't
2353
+ * spawn a marquee or drag. Move / up / cancel still flow so hover chrome
2354
+ * stays accurate.
2355
+ *
2356
+ * `pointerleave` is dispatched as its own InputEvent type so engine hover
2357
+ * chrome can clear when the cursor exits the canvas — without an inline
2358
+ * listener outside the InputManager pipeline (RFC-008 v6 unification).
81
2359
  */
82
- function CardFrame({ entityId, children, className, style }) {
83
- const dragging = useTag(entityId, Dragging);
84
- return /* @__PURE__ */ jsx("div", {
85
- className,
86
- style: {
87
- width: "100%",
88
- height: "100%",
89
- borderRadius: "21.67px",
90
- overflow: "hidden",
91
- boxShadow: dragging ? "0 30px 60px rgba(0,0,0,0.22), 0 0 0 1px rgba(0,0,0,0.06)" : "0 20px 40px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05)",
92
- transform: dragging ? "scale(1.05)" : "scale(1)",
93
- transformOrigin: "center center",
94
- transition: "transform 180ms cubic-bezier(0.2, 0.9, 0.3, 1.2), box-shadow 180ms cubic-bezier(0.2, 0.9, 0.3, 1.2)",
95
- willChange: dragging ? "transform, box-shadow" : void 0,
96
- ...style
97
- },
98
- children
99
- });
2360
+ var PointerAdapter = class {
2361
+ attach(container, manager) {
2362
+ const lastByPointerId = /* @__PURE__ */ new Map();
2363
+ const make = (type, e) => {
2364
+ const rect = container.getBoundingClientRect();
2365
+ const screen = {
2366
+ x: e.clientX - rect.left,
2367
+ y: e.clientY - rect.top
2368
+ };
2369
+ const camera = manager.engine.getCamera();
2370
+ const world = screenToWorld(screen.x, screen.y, camera);
2371
+ const last = lastByPointerId.get(e.pointerId);
2372
+ const delta = last ? {
2373
+ x: screen.x - last.x,
2374
+ y: screen.y - last.y
2375
+ } : void 0;
2376
+ const button = type === "down" || type === "up" ? e.button ?? null : null;
2377
+ return {
2378
+ type,
2379
+ source: pointerSource(e),
2380
+ pointerId: e.pointerId,
2381
+ primary: e.isPrimary,
2382
+ screen,
2383
+ world,
2384
+ delta,
2385
+ button,
2386
+ modifiers: {
2387
+ shift: e.shiftKey,
2388
+ ctrl: e.ctrlKey,
2389
+ alt: e.altKey,
2390
+ meta: e.metaKey
2391
+ },
2392
+ timestamp: e.timeStamp,
2393
+ native: e
2394
+ };
2395
+ };
2396
+ const onDown = (e) => {
2397
+ const rect = container.getBoundingClientRect();
2398
+ const screen = {
2399
+ x: e.clientX - rect.left,
2400
+ y: e.clientY - rect.top
2401
+ };
2402
+ lastByPointerId.set(e.pointerId, screen);
2403
+ const target = e.target;
2404
+ const targetTag = target ? `${target.tagName}${target.id ? `#${target.id}` : ""}` : "null";
2405
+ if (target?.closest("button, input, textarea, select, [contenteditable]")) {
2406
+ inputLog("Adapter", `pointerdown → SKIPPED (native interactive)`, {
2407
+ pointerId: e.pointerId,
2408
+ target: targetTag,
2409
+ source: e.pointerType
2410
+ });
2411
+ return;
2412
+ }
2413
+ inputLog("Adapter", `pointerdown id=${e.pointerId} → InputManager`, {
2414
+ pointerId: e.pointerId,
2415
+ screen,
2416
+ button: e.button,
2417
+ source: e.pointerType,
2418
+ target: targetTag
2419
+ });
2420
+ const event = make("down", e);
2421
+ manager.dispatch({
2422
+ ...event,
2423
+ delta: void 0
2424
+ });
2425
+ };
2426
+ const onMove = (e) => {
2427
+ const event = make("move", e);
2428
+ lastByPointerId.set(e.pointerId, {
2429
+ x: event.screen.x,
2430
+ y: event.screen.y
2431
+ });
2432
+ inputLog("Adapter", `pointermove id=${e.pointerId} → InputManager`, {
2433
+ type: "move",
2434
+ pointerId: e.pointerId,
2435
+ screen: event.screen
2436
+ });
2437
+ manager.dispatch(event);
2438
+ };
2439
+ const onUp = (e) => {
2440
+ inputLog("Adapter", `pointerup id=${e.pointerId} → InputManager`, {
2441
+ pointerId: e.pointerId,
2442
+ source: e.pointerType
2443
+ });
2444
+ manager.dispatch(make("up", e));
2445
+ lastByPointerId.delete(e.pointerId);
2446
+ };
2447
+ const onCancel = (e) => {
2448
+ inputLog("Adapter", `pointercancel id=${e.pointerId} → InputManager`, { pointerId: e.pointerId });
2449
+ manager.dispatch(make("cancel", e));
2450
+ lastByPointerId.delete(e.pointerId);
2451
+ };
2452
+ const onLeave = (e) => {
2453
+ inputLog("Adapter", `pointerleave id=${e.pointerId} → InputManager`, {
2454
+ pointerId: e.pointerId,
2455
+ source: e.pointerType
2456
+ });
2457
+ manager.dispatch(make("pointerleave", e));
2458
+ lastByPointerId.delete(e.pointerId);
2459
+ };
2460
+ container.addEventListener("pointerdown", onDown);
2461
+ container.addEventListener("pointermove", onMove);
2462
+ container.addEventListener("pointerup", onUp);
2463
+ container.addEventListener("pointercancel", onCancel);
2464
+ container.addEventListener("pointerleave", onLeave);
2465
+ return () => {
2466
+ container.removeEventListener("pointerdown", onDown);
2467
+ container.removeEventListener("pointermove", onMove);
2468
+ container.removeEventListener("pointerup", onUp);
2469
+ container.removeEventListener("pointercancel", onCancel);
2470
+ container.removeEventListener("pointerleave", onLeave);
2471
+ lastByPointerId.clear();
2472
+ };
2473
+ }
2474
+ };
2475
+ function pointerSource(e) {
2476
+ switch (e.pointerType) {
2477
+ case "mouse": return "mouse";
2478
+ case "pen": return "pen";
2479
+ case "touch": return "touch";
2480
+ default: return "mouse";
2481
+ }
100
2482
  }
2483
+ //#endregion
2484
+ //#region src/react/input/adapters/WheelAdapter.ts
101
2485
  /**
102
- * Returns a paired widget + archetype for an iOS-style card. Register both
103
- * with `createLayoutEngine({ widgets: [card.widget], archetypes: [card.archetype] })`
104
- * (or via `engine.registerWidget` / `engine.registerArchetype`) and spawn with
105
- * `engine.spawn('your-card-type', { at, data })`.
2486
+ * Wheel adapter (RFC-008). Listens for native wheel events on the canvas
2487
+ * container, normalises into `InputEvent`s with a `wheelDelta` payload,
2488
+ * and dispatches via `manager.dispatch(...)`.
106
2489
  *
107
- * The produced widget is non-resizable (Selectable + Draggable only), wrapped
108
- * in `<CardFrame>`, and spawns with a `Card` component so `cardSystem` enforces
109
- * the preset size each tick.
2490
+ * Always calls `preventDefault()` we always own canvas pan/zoom from
2491
+ * wheel input. Widgets that want internal scroll content call
2492
+ * `e.stopPropagation()` on `wheel` from inside their React tree, which
2493
+ * halts the bubble before it reaches this listener.
2494
+ *
2495
+ * `wheelDelta.pinch` is true when ctrl or meta is held — browsers
2496
+ * translate trackpad pinch into ctrl+wheel events; the engine wheel
2497
+ * handler interprets pinch as zoom and plain wheel as pan.
110
2498
  */
111
- function createCardWidget(opts) {
112
- const defaultSize = DEFAULT_CARD_PRESET_SIZES$1[opts.size];
113
- const Render = opts.render;
114
- const Component = ({ entityId }) => {
115
- return /* @__PURE__ */ jsx(CardFrame, {
116
- entityId,
117
- children: /* @__PURE__ */ jsx(Render, {
118
- entityId,
119
- data: useWidgetData(entityId)
120
- })
2499
+ var WheelAdapter = class {
2500
+ attach(container, manager) {
2501
+ const onWheel = (e) => {
2502
+ e.preventDefault();
2503
+ const rect = container.getBoundingClientRect();
2504
+ const screen = {
2505
+ x: e.clientX - rect.left,
2506
+ y: e.clientY - rect.top
2507
+ };
2508
+ const camera = manager.engine.getCamera();
2509
+ const event = {
2510
+ type: "wheel",
2511
+ source: "wheel",
2512
+ pointerId: 0,
2513
+ primary: true,
2514
+ screen,
2515
+ world: screenToWorld(screen.x, screen.y, camera),
2516
+ wheelDelta: {
2517
+ dx: e.deltaX,
2518
+ dy: e.deltaY,
2519
+ pinch: e.ctrlKey || e.metaKey
2520
+ },
2521
+ modifiers: {
2522
+ shift: e.shiftKey,
2523
+ ctrl: e.ctrlKey,
2524
+ alt: e.altKey,
2525
+ meta: e.metaKey
2526
+ },
2527
+ timestamp: e.timeStamp,
2528
+ native: e
2529
+ };
2530
+ inputLog("Adapter", `wheel → InputManager`, {
2531
+ type: "wheel",
2532
+ screen,
2533
+ dx: e.deltaX,
2534
+ dy: e.deltaY,
2535
+ pinch: e.ctrlKey || e.metaKey
2536
+ });
2537
+ manager.dispatch(event);
2538
+ };
2539
+ container.addEventListener("wheel", onWheel, { passive: false });
2540
+ return () => {
2541
+ container.removeEventListener("wheel", onWheel);
2542
+ };
2543
+ }
2544
+ };
2545
+ //#endregion
2546
+ //#region src/react/input/constants.ts
2547
+ /** Double-tap zoom level cycle. */
2548
+ const ZOOM_TARGETS = [1, 2];
2549
+ /** Wheel deltaY → zoom delta multiplier. Matches today's wheel `useEffect`. */
2550
+ const WHEEL_ZOOM_FACTOR = .01;
2551
+ //#endregion
2552
+ //#region src/react/input/InputManager.ts
2553
+ /**
2554
+ * RFC-008 input pipeline core. Adapters dispatch normalised InputEvents;
2555
+ * routers deliver to widget surfaces; engine handlers and recognizers
2556
+ * react.
2557
+ *
2558
+ * Lifecycle:
2559
+ * 1. Construct: `new InputManager(engine, container, [adapter, ...])`.
2560
+ * 2. Register handlers / recognizers / routers as needed.
2561
+ * 3. `attach()` — mounts adapters; returns detacher.
2562
+ * 4. Dispatch flows through automatically as native events arrive.
2563
+ * 5. Detacher tears down adapters and clears the gesturing debounce.
2564
+ */
2565
+ var InputManager = class {
2566
+ handlers = /* @__PURE__ */ new Map();
2567
+ recognizers = [];
2568
+ routers = /* @__PURE__ */ new Map();
2569
+ gesturingClearTimer = null;
2570
+ constructor(engine, container, adapters) {
2571
+ this.engine = engine;
2572
+ this.container = container;
2573
+ this.adapters = adapters;
2574
+ }
2575
+ attach() {
2576
+ const detachers = this.adapters.map((a) => a.attach(this.container, this));
2577
+ return () => {
2578
+ for (const d of detachers) try {
2579
+ d();
2580
+ } catch (err) {
2581
+ console.error("[InputManager] adapter detach threw", err);
2582
+ }
2583
+ if (this.gesturingClearTimer !== null) {
2584
+ clearTimeout(this.gesturingClearTimer);
2585
+ this.gesturingClearTimer = null;
2586
+ }
2587
+ for (const r of this.recognizers) try {
2588
+ r.reset?.();
2589
+ } catch (err) {
2590
+ console.error("[InputManager] recognizer reset threw", err);
2591
+ }
2592
+ };
2593
+ }
2594
+ on(type, handler) {
2595
+ let set = this.handlers.get(type);
2596
+ if (!set) {
2597
+ set = /* @__PURE__ */ new Set();
2598
+ this.handlers.set(type, set);
2599
+ }
2600
+ set.add(handler);
2601
+ return () => {
2602
+ const s = this.handlers.get(type);
2603
+ if (s) {
2604
+ s.delete(handler);
2605
+ if (s.size === 0) this.handlers.delete(type);
2606
+ }
2607
+ };
2608
+ }
2609
+ addRecognizer(r) {
2610
+ this.recognizers.push(r);
2611
+ return () => {
2612
+ const i = this.recognizers.indexOf(r);
2613
+ if (i !== -1) this.recognizers.splice(i, 1);
2614
+ };
2615
+ }
2616
+ setRouter(router) {
2617
+ this.routers.set(router.surface, router);
2618
+ return () => {
2619
+ if (this.routers.get(router.surface) === router) this.routers.delete(router.surface);
2620
+ };
2621
+ }
2622
+ dispatch(event) {
2623
+ const closeGroup = inputGroupStart(`dispatch ${event.type} id=${event.pointerId}`);
2624
+ inputLog("InputManager", `dispatch ${event.type}`, {
2625
+ type: event.type,
2626
+ source: event.source,
2627
+ pointerId: event.pointerId,
2628
+ screen: event.screen,
2629
+ world: event.world
2630
+ });
2631
+ if ((event.type === "down" || event.type === "move" || event.type === "up" || event.type === "cancel" || event.type === "click" || event.type === "dblclick" || event.type === "contextmenu") && event.source !== "synthetic" && this.routers.size > 0) {
2632
+ const entityId = this.engine.pickAt(event.screen.x, event.screen.y);
2633
+ if (entityId !== null) {
2634
+ const surface = this.surfaceOf(entityId);
2635
+ const router = surface !== null ? this.routers.get(surface) : void 0;
2636
+ inputLog("InputManager", `routing → ${surface ?? "none"} entity=${entityId}`, {
2637
+ type: event.type,
2638
+ entityId,
2639
+ surface,
2640
+ hasRouter: !!router
2641
+ });
2642
+ if (router) try {
2643
+ router.route(event, entityId);
2644
+ } catch (err) {
2645
+ console.error("[InputManager] router threw", err);
2646
+ }
2647
+ } else inputLog("InputManager", `routing → empty space (pickAt null)`, {
2648
+ type: event.type,
2649
+ screen: event.screen
2650
+ });
2651
+ }
2652
+ let claimed = false;
2653
+ if (event.source !== "synthetic") {
2654
+ for (const router of this.routers.values()) if (router.isPointerClaimed?.(event.pointerId)) {
2655
+ claimed = true;
2656
+ inputLog("InputManager", `claim check → CLAIMED by ${router.surface} router (recognizers will skip)`, {
2657
+ type: event.type,
2658
+ pointerId: event.pointerId
2659
+ });
2660
+ break;
2661
+ }
2662
+ }
2663
+ const handlers = this.handlers.get(event.type);
2664
+ if (handlers && handlers.size > 0) {
2665
+ inputLog("InputManager", `handlers for ${event.type}: ${handlers.size} → firing`, {
2666
+ type: event.type,
2667
+ count: handlers.size
2668
+ });
2669
+ for (const h of handlers) try {
2670
+ h(event);
2671
+ } catch (err) {
2672
+ console.error("[InputManager] handler threw", err);
2673
+ }
2674
+ }
2675
+ if (!claimed) for (const r of this.recognizers) try {
2676
+ r.observe(event, this);
2677
+ } catch (err) {
2678
+ console.error("[InputManager] recognizer threw", err);
2679
+ }
2680
+ else inputLog("InputManager", `recognizers skipped (pointer claimed)`, {
2681
+ type: event.type,
2682
+ pointerId: event.pointerId
2683
+ });
2684
+ closeGroup();
2685
+ }
2686
+ pickAt(screen) {
2687
+ return this.engine.pickAt(screen.x, screen.y);
2688
+ }
2689
+ notifyGesturing() {
2690
+ this.engine.setGesturing(true);
2691
+ if (this.gesturingClearTimer !== null) clearTimeout(this.gesturingClearTimer);
2692
+ this.gesturingClearTimer = setTimeout(() => {
2693
+ this.engine.setGesturing(false);
2694
+ this.gesturingClearTimer = null;
2695
+ }, 200);
2696
+ }
2697
+ /** Resolve an entity's rendering surface via the `Widget` component. */
2698
+ surfaceOf(entityId) {
2699
+ const w = this.engine.get(entityId, Widget);
2700
+ if (!w) return null;
2701
+ if (w.surface === "dom" || w.surface === "webgl" || w.surface === "webview") return w.surface;
2702
+ return null;
2703
+ }
2704
+ };
2705
+ //#endregion
2706
+ //#region src/react/input/installEngineHandlers.ts
2707
+ /**
2708
+ * RFC-008 § Engine handler registration. Wires the engine's input
2709
+ * primitives (`beginDrag`, `selectEntity`, camera ops, hover state, etc.)
2710
+ * to the synthetic events emitted by recognizers and the raw events
2711
+ * emitted by adapters. Returns a teardown that removes every handler.
2712
+ *
2713
+ * The `container` is the canvas-container DOM element. Pointer-capture
2714
+ * for drags is anchored on it so the gesture survives the cursor leaving
2715
+ * the container's bounds.
2716
+ */
2717
+ function installEngineHandlers(manager, engine, container) {
2718
+ let lastPinchCenter = null;
2719
+ const offs = [];
2720
+ offs.push(manager.on("wheel", (e) => {
2721
+ const w = e.wheelDelta;
2722
+ if (!w) return;
2723
+ if (w.pinch) engine.zoomAtPoint(e.screen.x, e.screen.y, -w.dy * WHEEL_ZOOM_FACTOR);
2724
+ else engine.panBy(-w.dx, -w.dy);
2725
+ manager.notifyGesturing();
2726
+ }));
2727
+ offs.push(manager.on("pinch-start", (e) => {
2728
+ const g = e.gesture;
2729
+ lastPinchCenter = {
2730
+ x: g.center.x,
2731
+ y: g.center.y
2732
+ };
2733
+ manager.notifyGesturing();
2734
+ }));
2735
+ offs.push(manager.on("pinch-update", (e) => {
2736
+ const g = e.gesture;
2737
+ engine.zoomAtPoint(g.center.x, g.center.y, g.scale - 1);
2738
+ if (lastPinchCenter) engine.panBy(g.center.x - lastPinchCenter.x, g.center.y - lastPinchCenter.y);
2739
+ lastPinchCenter = {
2740
+ x: g.center.x,
2741
+ y: g.center.y
2742
+ };
2743
+ manager.notifyGesturing();
2744
+ }));
2745
+ offs.push(manager.on("pinch-end", () => {
2746
+ lastPinchCenter = null;
2747
+ }));
2748
+ offs.push(manager.on("pan-update", (e) => {
2749
+ const g = e.gesture;
2750
+ engine.panBy(g.delta.x, g.delta.y);
2751
+ manager.notifyGesturing();
2752
+ }));
2753
+ offs.push(manager.on("click", (e) => {
2754
+ if (e.button !== 0 && e.button !== null) return;
2755
+ const entity = engine.pickAt(e.screen.x, e.screen.y);
2756
+ if (entity !== null) {
2757
+ inputLog("Engine", `click on entity ${entity} → selectEntity (shift=${e.modifiers.shift})`);
2758
+ engine.selectEntity(entity, e.modifiers.shift);
2759
+ } else {
2760
+ inputLog("Engine", `click on empty space → clearSelection`);
2761
+ engine.clearSelection();
2762
+ }
2763
+ }));
2764
+ offs.push(manager.on("dblclick", (e) => {
2765
+ const entity = engine.pickAt(e.screen.x, e.screen.y);
2766
+ if (entity !== null) {
2767
+ inputLog("Engine", `dblclick on entity ${entity} → enterContainer`);
2768
+ engine.enterContainer(entity);
2769
+ return;
2770
+ }
2771
+ const camera = engine.getCamera();
2772
+ const target = camera.zoom < .9 ? ZOOM_TARGETS[0] : camera.zoom < 1.8 ? ZOOM_TARGETS[1] : ZOOM_TARGETS[0];
2773
+ inputLog("Engine", `dblclick on empty → zoomAtPoint target=${target}x`);
2774
+ engine.zoomAtPoint(e.screen.x, e.screen.y, (target - camera.zoom) / camera.zoom);
2775
+ }));
2776
+ offs.push(manager.on("drag-start", (e) => {
2777
+ const hit = engine.hitTest(e.screen.x, e.screen.y);
2778
+ container.setPointerCapture(e.pointerId);
2779
+ if (!hit) {
2780
+ if (e.source === "touch") {
2781
+ inputLog("Engine", `drag-start on empty (touch) → defer to PanRecognizer`, { pointerId: e.pointerId });
2782
+ return;
2783
+ }
2784
+ inputLog("Engine", `drag-start on empty (mouse/pen) → beginMarquee`, { pointerId: e.pointerId });
2785
+ engine.clearSelection();
2786
+ engine.beginMarquee(e.world.x, e.world.y);
2787
+ return;
2788
+ }
2789
+ if (hit.role.role.type === "resize") {
2790
+ inputLog("Engine", `drag-start on resize handle → beginResize ${hit.role.role.handle}`, {
2791
+ entityId: hit.entityId,
2792
+ handle: hit.role.role.handle
2793
+ });
2794
+ engine.beginResize(hit.entityId, hit.role.role.handle, e.world.x, e.world.y);
2795
+ return;
2796
+ }
2797
+ if (!engine.getSelectedEntities().includes(hit.entityId)) engine.selectEntity(hit.entityId, e.modifiers.shift);
2798
+ inputLog("Engine", `drag-start on entity → beginDrag ${hit.entityId}`, {
2799
+ entityId: hit.entityId,
2800
+ role: hit.role.role.type,
2801
+ shift: e.modifiers.shift
121
2802
  });
2803
+ engine.beginDrag(hit.entityId, e.world.x, e.world.y);
2804
+ }));
2805
+ offs.push(manager.on("drag-update", (e) => {
2806
+ if (engine.isMarqueeActive()) {
2807
+ engine.updateMarquee(e.world.x, e.world.y);
2808
+ return;
2809
+ }
2810
+ const resizing = engine.getResizingEntity();
2811
+ if (resizing !== null) {
2812
+ engine.updateResize(resizing, e.world.x, e.world.y);
2813
+ return;
2814
+ }
2815
+ const dragging = engine.getDraggingEntity();
2816
+ if (dragging !== null) engine.updateDrag(dragging, e.world.x, e.world.y);
2817
+ }));
2818
+ offs.push(manager.on("drag-end", (e) => {
2819
+ if (engine.isMarqueeActive()) {
2820
+ inputLog("Engine", `drag-end → endMarquee`);
2821
+ engine.endMarquee();
2822
+ } else {
2823
+ const resizing = engine.getResizingEntity();
2824
+ if (resizing !== null) {
2825
+ inputLog("Engine", `drag-end → endResize ${resizing} (commit)`);
2826
+ engine.endResize(resizing, { cancelled: false });
2827
+ } else {
2828
+ const dragging = engine.getDraggingEntity();
2829
+ if (dragging !== null) {
2830
+ inputLog("Engine", `drag-end → endDrag ${dragging} (commit)`);
2831
+ engine.endDrag(dragging, { cancelled: false });
2832
+ }
2833
+ }
2834
+ }
2835
+ if (container.hasPointerCapture(e.pointerId)) container.releasePointerCapture(e.pointerId);
2836
+ }));
2837
+ offs.push(manager.on("cancel", (e) => {
2838
+ inputLog("Engine", `cancel → cancelInteraction (covers all mid-gesture modes)`, { pointerId: e.pointerId });
2839
+ engine.cancelInteraction();
2840
+ if (container.hasPointerCapture(e.pointerId)) container.releasePointerCapture(e.pointerId);
2841
+ }));
2842
+ offs.push(manager.on("move", (e) => {
2843
+ engine.updateHover(e.screen.x, e.screen.y);
2844
+ }));
2845
+ offs.push(manager.on("pointerleave", () => {
2846
+ inputLog("Engine", `pointerleave → setHoveredEntity(null)`);
2847
+ engine.setHoveredEntity(null);
2848
+ }));
2849
+ return () => {
2850
+ for (const off of offs) off();
2851
+ lastPinchCenter = null;
122
2852
  };
2853
+ }
2854
+ //#endregion
2855
+ //#region src/react/input/synthetic.ts
2856
+ /**
2857
+ * Helper for constructing recognizer-emitted synthetic events. The
2858
+ * recognizer copies pointerId / modifiers / coords from the base raw
2859
+ * event, sets `source: 'synthetic'`, and attaches a `gesture` payload.
2860
+ */
2861
+ function makeSynthetic(type, base, gesture, overrides) {
123
2862
  return {
124
- widget: {
125
- type: opts.type,
126
- schema: opts.schema,
127
- defaultData: opts.defaultData,
128
- defaultSize,
129
- component: Component
130
- },
131
- archetype: {
132
- id: opts.type,
133
- widget: opts.type,
134
- components: [[Card, { preset: opts.size }]],
135
- interactive: {
136
- selectable: true,
137
- draggable: true,
138
- resizable: false,
139
- selectionFrame: false
140
- },
141
- defaultSize
2863
+ type,
2864
+ source: "synthetic",
2865
+ pointerId: base.pointerId,
2866
+ primary: base.primary,
2867
+ screen: overrides?.screen ?? base.screen,
2868
+ world: overrides?.world ?? base.world,
2869
+ delta: overrides?.delta,
2870
+ modifiers: base.modifiers,
2871
+ timestamp: base.timestamp,
2872
+ gesture
2873
+ };
2874
+ }
2875
+ /** Squared distance — avoids a sqrt for threshold comparisons. */
2876
+ function distSq(a, b) {
2877
+ const dx = a.x - b.x;
2878
+ const dy = a.y - b.y;
2879
+ return dx * dx + dy * dy;
2880
+ }
2881
+ /** Returns true if the linear distance between two points exceeds `threshold` px. */
2882
+ function exceedsThreshold(a, b, threshold) {
2883
+ return distSq(a, b) > threshold * threshold;
2884
+ }
2885
+ //#endregion
2886
+ //#region src/react/input/recognizers/DoubleTapRecognizer.ts
2887
+ /**
2888
+ * Double-tap recognizer (RFC-008). Observes the `tap` events emitted by
2889
+ * `TapRecognizer`. When two consecutive taps land within
2890
+ * `DOUBLE_TAP_WINDOW_MS` and `DOUBLE_TAP_DIST_PX`, emits a synthetic
2891
+ * `'double-tap'`.
2892
+ *
2893
+ * The first tap continues to fire normally — handlers that listen to
2894
+ * `'tap'` (e.g., engine selection) react to it. The second tap fires
2895
+ * both a `'tap'` AND a `'double-tap'`. Engine handlers can decide which
2896
+ * to respond to based on what they care about.
2897
+ */
2898
+ var DoubleTapRecognizer = class {
2899
+ last = null;
2900
+ observe(event, manager) {
2901
+ if (event.type !== "tap" || event.gesture?.kind !== "tap" || event.gesture.count !== 1) return;
2902
+ const now = event.timestamp;
2903
+ const here = event.screen;
2904
+ if (this.last !== null && now - this.last.time <= 300 && !exceedsThreshold(here, this.last.at, 30)) {
2905
+ inputLog("Recognizer", `DoubleTapRecognizer: 2nd tap within window → double-tap`, {
2906
+ pointerId: event.pointerId,
2907
+ dtMs: now - this.last.time
2908
+ });
2909
+ manager.dispatch(makeSynthetic("double-tap", event, {
2910
+ kind: "tap",
2911
+ count: 2
2912
+ }));
2913
+ this.last = null;
2914
+ return;
2915
+ }
2916
+ this.last = {
2917
+ at: here,
2918
+ time: now
2919
+ };
2920
+ }
2921
+ reset() {
2922
+ this.last = null;
2923
+ }
2924
+ };
2925
+ //#endregion
2926
+ //#region src/react/input/recognizers/DragRecognizer.ts
2927
+ /**
2928
+ * Drag recognizer (RFC-008). Per-pointerId state on `down`. On `move`
2929
+ * past the source-appropriate dead zone, emits a synthetic `drag-start`
2930
+ * and transitions to `dragging`. Subsequent moves emit `drag-update`s
2931
+ * with screen + world deltas. `up` or `cancel` after dragging emits
2932
+ * `drag-end`.
2933
+ *
2934
+ * Single-finger only: PinchRecognizer dispatches a synthetic `cancel`
2935
+ * when a 2nd touch lands, which DragRecognizer observes and uses to
2936
+ * abort tracking for the first finger.
2937
+ *
2938
+ * Note: synthetic `drag-update` events carry the SCREEN delta in
2939
+ * `gesture.delta` (not world delta) — the engine handler needs world
2940
+ * coords to update entity positions, but it gets those from the raw
2941
+ * `move` event's `world`. The gesture's `delta` is informational, not
2942
+ * used by the engine drag handler.
2943
+ */
2944
+ var DragRecognizer = class {
2945
+ tracking = /* @__PURE__ */ new Map();
2946
+ observe(event, manager) {
2947
+ switch (event.type) {
2948
+ case "down":
2949
+ if (event.button !== 0 && event.button !== null) return;
2950
+ if (this.tracking.size > 0) return;
2951
+ this.tracking.set(event.pointerId, {
2952
+ downAt: {
2953
+ screen: event.screen,
2954
+ world: event.world
2955
+ },
2956
+ last: {
2957
+ screen: event.screen,
2958
+ world: event.world
2959
+ },
2960
+ status: "tracking"
2961
+ });
2962
+ return;
2963
+ case "move": {
2964
+ const t = this.tracking.get(event.pointerId);
2965
+ if (!t) return;
2966
+ if (t.status === "tracking") {
2967
+ const dz = event.source === "touch" ? 8 : 4;
2968
+ if (!exceedsThreshold(event.screen, t.downAt.screen, dz)) return;
2969
+ t.status = "dragging";
2970
+ inputLog("Recognizer", `DragRecognizer: dead-zone crossed → drag-start`, {
2971
+ pointerId: event.pointerId,
2972
+ source: event.source,
2973
+ deadZone: dz
2974
+ });
2975
+ manager.dispatch(makeSynthetic("drag-start", event, {
2976
+ kind: "drag",
2977
+ phase: "start",
2978
+ total: {
2979
+ x: event.screen.x - t.downAt.screen.x,
2980
+ y: event.screen.y - t.downAt.screen.y
2981
+ },
2982
+ delta: {
2983
+ x: event.screen.x - t.last.screen.x,
2984
+ y: event.screen.y - t.last.screen.y
2985
+ }
2986
+ }));
2987
+ t.last = {
2988
+ screen: event.screen,
2989
+ world: event.world
2990
+ };
2991
+ return;
2992
+ }
2993
+ manager.dispatch(makeSynthetic("drag-update", event, {
2994
+ kind: "drag",
2995
+ phase: "update",
2996
+ total: {
2997
+ x: event.screen.x - t.downAt.screen.x,
2998
+ y: event.screen.y - t.downAt.screen.y
2999
+ },
3000
+ delta: {
3001
+ x: event.screen.x - t.last.screen.x,
3002
+ y: event.screen.y - t.last.screen.y
3003
+ }
3004
+ }));
3005
+ t.last = {
3006
+ screen: event.screen,
3007
+ world: event.world
3008
+ };
3009
+ return;
3010
+ }
3011
+ case "up": {
3012
+ const t = this.tracking.get(event.pointerId);
3013
+ if (!t) return;
3014
+ this.tracking.delete(event.pointerId);
3015
+ if (t.status !== "dragging") return;
3016
+ inputLog("Recognizer", `DragRecognizer: up after dragging → drag-end`, { pointerId: event.pointerId });
3017
+ manager.dispatch(makeSynthetic("drag-end", event, {
3018
+ kind: "drag",
3019
+ phase: "end",
3020
+ total: {
3021
+ x: event.screen.x - t.downAt.screen.x,
3022
+ y: event.screen.y - t.downAt.screen.y
3023
+ },
3024
+ delta: {
3025
+ x: event.screen.x - t.last.screen.x,
3026
+ y: event.screen.y - t.last.screen.y
3027
+ }
3028
+ }));
3029
+ return;
3030
+ }
3031
+ case "cancel":
3032
+ this.tracking.delete(event.pointerId);
3033
+ return;
3034
+ }
3035
+ }
3036
+ reset() {
3037
+ this.tracking.clear();
3038
+ }
3039
+ };
3040
+ //#endregion
3041
+ //#region src/react/input/recognizers/HoverRecognizer.ts
3042
+ /**
3043
+ * Hover recognizer (RFC-008). Observes `move` events; tracks the last
3044
+ * leaf entity under each pointer via `manager.pickAt`. On change, emits
3045
+ * `hover-leave` (with the previous entity in `gesture.entityId`) and
3046
+ * `hover-enter` (with the new entity).
3047
+ *
3048
+ * Hover is informational — recognizers and handlers downstream cannot
3049
+ * "consume" it. Engine hover chrome (selection ring, cursor hint) is
3050
+ * driven entirely by these events.
3051
+ *
3052
+ * Pen + mouse hover normally; touch fires hover only while a finger is
3053
+ * pressed, so touch-source hover events fire as a side-effect of drag
3054
+ * tracking. Authors who want touch hover-on-hover should listen for
3055
+ * `move` events themselves.
3056
+ */
3057
+ var HoverRecognizer = class {
3058
+ lastByPointer = /* @__PURE__ */ new Map();
3059
+ observe(event, manager) {
3060
+ switch (event.type) {
3061
+ case "move": {
3062
+ const current = manager.pickAt(event.screen);
3063
+ const prev = this.lastByPointer.get(event.pointerId) ?? null;
3064
+ if (current === prev) return;
3065
+ inputLog("Recognizer", `HoverRecognizer: entity changed ${prev} → ${current}`, {
3066
+ pointerId: event.pointerId,
3067
+ prev,
3068
+ current
3069
+ });
3070
+ if (prev !== null) manager.dispatch(makeSynthetic("hover-leave", event, {
3071
+ kind: "hover",
3072
+ entityId: prev
3073
+ }));
3074
+ this.lastByPointer.set(event.pointerId, current);
3075
+ if (current !== null) manager.dispatch(makeSynthetic("hover-enter", event, {
3076
+ kind: "hover",
3077
+ entityId: current
3078
+ }));
3079
+ return;
3080
+ }
3081
+ case "up":
3082
+ case "cancel": {
3083
+ const prev = this.lastByPointer.get(event.pointerId);
3084
+ this.lastByPointer.delete(event.pointerId);
3085
+ if (prev != null) manager.dispatch(makeSynthetic("hover-leave", event, {
3086
+ kind: "hover",
3087
+ entityId: prev
3088
+ }));
3089
+ return;
3090
+ }
3091
+ }
3092
+ }
3093
+ reset() {
3094
+ this.lastByPointer.clear();
3095
+ }
3096
+ };
3097
+ //#endregion
3098
+ //#region src/react/input/recognizers/PanRecognizer.ts
3099
+ /**
3100
+ * Pan recognizer (RFC-008). Single-finger touch on empty space only —
3101
+ * `engine.pickAt(world) === null` at down-time. Emits `pan-update` with
3102
+ * screen deltas. The engine pan handler translates them into camera
3103
+ * `panBy` calls.
3104
+ *
3105
+ * Pinch + pan run simultaneously when two fingers are down on empty
3106
+ * space (matches iOS Maps): PinchRecognizer cancels this recognizer's
3107
+ * tracking via synthetic `cancel`, then a fresh PanRecognizer state
3108
+ * doesn't restart until both fingers release and a new single-finger
3109
+ * touch begins on empty space.
3110
+ *
3111
+ * Mouse / pen drags on empty space are NOT pans here — they're marquee
3112
+ * selection, handled by the engine drag handler responding to
3113
+ * DragRecognizer's `drag-start` for empty-space hits.
3114
+ */
3115
+ var PanRecognizer = class {
3116
+ tracking = /* @__PURE__ */ new Map();
3117
+ observe(event, manager) {
3118
+ switch (event.type) {
3119
+ case "down":
3120
+ if (event.source !== "touch") return;
3121
+ if (event.button !== 0 && event.button !== null) return;
3122
+ if (manager.pickAt(event.screen) !== null) return;
3123
+ this.tracking.set(event.pointerId, {
3124
+ downAt: event.screen,
3125
+ last: event.screen,
3126
+ status: "tracking"
3127
+ });
3128
+ return;
3129
+ case "move": {
3130
+ const t = this.tracking.get(event.pointerId);
3131
+ if (!t) return;
3132
+ if (t.status === "tracking") {
3133
+ if (!exceedsThreshold(event.screen, t.downAt, 8)) return;
3134
+ t.status = "panning";
3135
+ t.last = event.screen;
3136
+ inputLog("Recognizer", `PanRecognizer: dead-zone crossed → panning (touch empty-space)`, { pointerId: event.pointerId });
3137
+ return;
3138
+ }
3139
+ const delta = {
3140
+ x: event.screen.x - t.last.x,
3141
+ y: event.screen.y - t.last.y
3142
+ };
3143
+ t.last = event.screen;
3144
+ manager.dispatch(makeSynthetic("pan-update", event, {
3145
+ kind: "pan",
3146
+ delta
3147
+ }));
3148
+ return;
3149
+ }
3150
+ case "up":
3151
+ case "cancel":
3152
+ this.tracking.delete(event.pointerId);
3153
+ return;
3154
+ }
3155
+ }
3156
+ reset() {
3157
+ this.tracking.clear();
3158
+ }
3159
+ };
3160
+ //#endregion
3161
+ //#region src/react/input/recognizers/PinchRecognizer.ts
3162
+ /**
3163
+ * Pinch recognizer (RFC-008). Counts simultaneous active touch-source
3164
+ * pointers; emits `pinch-start` when count reaches 2, `pinch-update` on
3165
+ * either finger move, `pinch-end` when count drops below 2.
3166
+ *
3167
+ * On `pinch-start`, dispatches a synthetic `cancel` for any tracked
3168
+ * single-finger gesture so DragRecognizer / TapRecognizer / PanRecognizer
3169
+ * can abort cleanly. This is the cancel-then-pinch ordering — engine
3170
+ * drag (if active) runs `endDrag(entity, { cancelled: true })` before
3171
+ * pinch math takes over.
3172
+ *
3173
+ * Coexists with PanRecognizer: empty-space pan (single finger that
3174
+ * already crossed dead zone before the 2nd finger arrives) gets
3175
+ * cancelled by the synthetic `cancel`. After `pinch-end`, the user must
3176
+ * lift and re-place to start a new gesture (matches iOS).
3177
+ */
3178
+ var PinchRecognizer = class {
3179
+ /** Active touch-source pointers and their latest positions (screen). */
3180
+ active = /* @__PURE__ */ new Map();
3181
+ state = null;
3182
+ observe(event, manager) {
3183
+ if (event.source !== "touch") return;
3184
+ switch (event.type) {
3185
+ case "down":
3186
+ this.active.set(event.pointerId, event.screen);
3187
+ if (this.state === null && this.active.size === 2) {
3188
+ const ids = [...this.active.keys()];
3189
+ const camera = manager.engine.getCamera();
3190
+ for (const id of ids) {
3191
+ const screen = this.active.get(id);
3192
+ manager.dispatch({
3193
+ type: "cancel",
3194
+ source: "synthetic",
3195
+ pointerId: id,
3196
+ primary: false,
3197
+ screen,
3198
+ world: screenToWorld(screen.x, screen.y, camera),
3199
+ modifiers: event.modifiers,
3200
+ timestamp: event.timestamp
3201
+ });
3202
+ }
3203
+ const positions = [...this.active.values()];
3204
+ const dist = pointDistance(positions[0], positions[1]);
3205
+ const center = midpoint(positions[0], positions[1]);
3206
+ this.state = {
3207
+ pointerIds: ids,
3208
+ last: {
3209
+ dist,
3210
+ center
3211
+ }
3212
+ };
3213
+ inputLog("Recognizer", `PinchRecognizer: 2nd touch → pinch-start (cancel sent for both)`, { pointerIds: ids });
3214
+ manager.dispatch(makeSynthetic("pinch-start", event, {
3215
+ kind: "pinch",
3216
+ phase: "start",
3217
+ scale: 1,
3218
+ center
3219
+ }));
3220
+ }
3221
+ return;
3222
+ case "move": {
3223
+ if (this.state === null || !this.active.has(event.pointerId)) return;
3224
+ this.active.set(event.pointerId, event.screen);
3225
+ const a = this.active.get(this.state.pointerIds[0]);
3226
+ const b = this.active.get(this.state.pointerIds[1]);
3227
+ if (!a || !b) return;
3228
+ const dist = pointDistance(a, b);
3229
+ const center = midpoint(a, b);
3230
+ const scale = this.state.last.dist > 0 ? dist / this.state.last.dist : 1;
3231
+ this.state.last = {
3232
+ dist,
3233
+ center
3234
+ };
3235
+ manager.dispatch(makeSynthetic("pinch-update", event, {
3236
+ kind: "pinch",
3237
+ phase: "update",
3238
+ scale,
3239
+ center
3240
+ }, { screen: center }));
3241
+ return;
3242
+ }
3243
+ case "up":
3244
+ case "cancel":
3245
+ this.active.delete(event.pointerId);
3246
+ if (this.state !== null && this.active.size < 2) {
3247
+ inputLog("Recognizer", `PinchRecognizer: finger lifted → pinch-end`, { remainingActive: this.active.size });
3248
+ manager.dispatch(makeSynthetic("pinch-end", event, {
3249
+ kind: "pinch",
3250
+ phase: "end",
3251
+ scale: 1,
3252
+ center: this.state.last.center
3253
+ }));
3254
+ this.state = null;
3255
+ }
3256
+ return;
142
3257
  }
3258
+ }
3259
+ reset() {
3260
+ this.active.clear();
3261
+ this.state = null;
3262
+ }
3263
+ };
3264
+ function pointDistance(a, b) {
3265
+ const dx = a.x - b.x;
3266
+ const dy = a.y - b.y;
3267
+ return Math.sqrt(dx * dx + dy * dy);
3268
+ }
3269
+ function midpoint(a, b) {
3270
+ return {
3271
+ x: (a.x + b.x) / 2,
3272
+ y: (a.y + b.y) / 2
143
3273
  };
144
3274
  }
145
3275
  //#endregion
146
- //#region src/react/geometry-card.tsx
147
- /** Must match {@link CardPresetsResource} defaults. */
148
- const DEFAULT_CARD_PRESET_SIZES = {
149
- small: {
150
- width: 155,
151
- height: 155
152
- },
153
- medium: {
154
- width: 329,
155
- height: 155
156
- },
157
- large: {
158
- width: 329,
159
- height: 345
160
- },
161
- xl: {
162
- width: 329,
163
- height: 535
3276
+ //#region src/react/input/recognizers/TapRecognizer.ts
3277
+ /**
3278
+ * Tap recognizer (RFC-008). Per-pointerId state on `down`; emits a
3279
+ * synthetic `'tap'` (count 1) on `up` within `TAP_WINDOW_MS` and the
3280
+ * source-appropriate dead zone.
3281
+ *
3282
+ * Pairwise relationships:
3283
+ * - Cancels its pending tap on observed `drag-start` or `pinch-start`
3284
+ * for the same pointerId.
3285
+ * - Cancels its pending tap on observed `cancel`.
3286
+ */
3287
+ var TapRecognizer = class {
3288
+ pending = /* @__PURE__ */ new Map();
3289
+ observe(event, manager) {
3290
+ switch (event.type) {
3291
+ case "down":
3292
+ if (event.button !== 0 && event.button !== null) return;
3293
+ this.pending.set(event.pointerId, {
3294
+ downAt: event.screen,
3295
+ time: event.timestamp
3296
+ });
3297
+ return;
3298
+ case "up": {
3299
+ const p = this.pending.get(event.pointerId);
3300
+ if (!p) return;
3301
+ this.pending.delete(event.pointerId);
3302
+ const elapsed = event.timestamp - p.time;
3303
+ const dz = event.source === "touch" ? 8 : 4;
3304
+ if (elapsed <= 250 && !exceedsThreshold(event.screen, p.downAt, dz)) {
3305
+ inputLog("Recognizer", `TapRecognizer: up within tap window → tap`, {
3306
+ pointerId: event.pointerId,
3307
+ elapsedMs: elapsed
3308
+ });
3309
+ manager.dispatch(makeSynthetic("tap", event, {
3310
+ kind: "tap",
3311
+ count: 1
3312
+ }));
3313
+ }
3314
+ return;
3315
+ }
3316
+ case "cancel":
3317
+ case "drag-start":
3318
+ case "pinch-start":
3319
+ this.pending.delete(event.pointerId);
3320
+ return;
3321
+ }
3322
+ }
3323
+ reset() {
3324
+ this.pending.clear();
164
3325
  }
165
3326
  };
3327
+ //#endregion
3328
+ //#region src/react/input/routers/R3FRouter.ts
166
3329
  /**
167
- * Pure-three rounded-rect extrude geometry avoids a drei dependency.
168
- * Rounded corners match the DOM CardFrame radius (21.67 px).
169
- */
170
- function makeRoundedCardGeometry(width, height, radius, depth) {
171
- const shape = new Shape();
172
- const r = Math.min(radius, Math.min(width, height) / 2);
173
- const x = -width / 2;
174
- const y = -height / 2;
175
- shape.moveTo(x, y + r);
176
- shape.lineTo(x, y + height - r);
177
- shape.quadraticCurveTo(x, y + height, x + r, y + height);
178
- shape.lineTo(x + width - r, y + height);
179
- shape.quadraticCurveTo(x + width, y + height, x + width, y + height - r);
180
- shape.lineTo(x + width, y + r);
181
- shape.quadraticCurveTo(x + width, y, x + width - r, y);
182
- shape.lineTo(x + r, y);
183
- shape.quadraticCurveTo(x, y, x, y + r);
184
- return new ExtrudeGeometry(shape, {
185
- depth,
186
- bevelEnabled: true,
187
- bevelSegments: 3,
188
- bevelSize: .6,
189
- bevelThickness: .6
190
- });
191
- }
192
- function CardBack({ width, height, color, roughness, metalness }) {
193
- return /* @__PURE__ */ jsx("mesh", {
194
- geometry: useMemo(() => makeRoundedCardGeometry(width, height, 21.67, 3), [width, height]),
195
- position: [
196
- 0,
197
- 0,
198
- -6
199
- ],
200
- receiveShadow: true,
201
- children: /* @__PURE__ */ jsx("meshStandardMaterial", {
202
- color,
203
- roughness,
204
- metalness
205
- })
206
- });
207
- }
3330
+ * Map `InputEventType`s to the R3F handler name that drives R3F's
3331
+ * mesh-dispatch machinery. Pointer types feed R3F's hover-diff + capture
3332
+ * pipeline; click family feeds R3F's click synthesis (`onClick`,
3333
+ * `onDoubleClick`, `onContextMenu`). v6 routes clicks through this table
3334
+ * so the InputManager pipeline is the single source for both engine and
3335
+ * widget no parallel `connect` listener registration on the container.
3336
+ */
3337
+ const HANDLER_BY_TYPE = {
3338
+ down: "onPointerDown",
3339
+ move: "onPointerMove",
3340
+ up: "onPointerUp",
3341
+ cancel: "onPointerCancel",
3342
+ click: "onClick",
3343
+ dblclick: "onDoubleClick",
3344
+ contextmenu: "onContextMenu"
3345
+ };
208
3346
  /**
209
- * Returns a paired R3F widget + archetype for a card-shaped 3D widget.
210
- * Behaves like {@link createCardWidget} — fixed preset size, non-resizable,
211
- * no engine-drawn selection frame, and lifts on drag (scale + z) — but
212
- * renders a three.js scene instead of DOM content.
3347
+ * RFC-008 v5 `WidgetSurfaceRouter` for the WebGL surface.
213
3348
  *
214
- * Lighting: this helper adds no lights. Declare your own in the `geometry`
215
- * component (typically a local `pointLight` scoped with `distance`).
3349
+ * Invoked by `InputManager.dispatch` whenever a raw pointer event falls
3350
+ * over an entity whose `Widget.surface === 'webgl'`. Looks up the matching
3351
+ * R3F mesh-dispatch handler and invokes it with the underlying native
3352
+ * event, letting R3F run its raycast → bubble → handler pipeline (with
3353
+ * `compute` + `filter` from `createR3FEventManager` targeting the right
3354
+ * per-widget scene).
3355
+ *
3356
+ * The InputManager dispatches the router BEFORE engine handlers, so widget
3357
+ * mesh handlers that call `setPointerCapture` (claiming exclusive ownership
3358
+ * of the gesture) take effect before the engine's drag/marquee logic could
3359
+ * react. Mesh handlers that call `e.stopPropagation()` halt R3F's bubble
3360
+ * but NOT the engine's handlers — those listen on the InputManager, not on
3361
+ * R3F's bubble. This is the coexistence model: R3F handles widget-internal
3362
+ * logic, engine handles canvas-level logic, both react to the same event
3363
+ * unless a widget explicitly claims it (RFC-008 § Default coexistence).
216
3364
  */
217
- function createGeometryCardWidget(opts) {
218
- const defaultSize = DEFAULT_CARD_PRESET_SIZES[opts.size];
219
- const Render = opts.geometry;
220
- const backgroundConfig = opts.background ?? "card";
221
- const resolvedBack = backgroundConfig === "transparent" ? null : backgroundConfig === "card" ? {
222
- color: "#1C1C1E",
223
- roughness: .55,
224
- metalness: 0
225
- } : {
226
- color: backgroundConfig.color,
227
- roughness: backgroundConfig.roughness ?? .55,
228
- metalness: backgroundConfig.metalness ?? 0
229
- };
230
- const Component = ({ entityId, width, height }) => {
231
- const data = useWidgetData(entityId);
232
- const dragging = useTag(entityId, Dragging);
233
- const groupRef = useRef(null);
234
- useFrame(() => {
235
- const g = groupRef.current;
236
- if (!g) return;
237
- const targetScale = dragging ? 1.05 : 1;
238
- const targetZ = dragging ? 8 : 0;
239
- const s = g.scale.x;
240
- g.scale.setScalar(s + (targetScale - s) * .2);
241
- g.position.z += (targetZ - g.position.z) * .2;
242
- });
243
- return /* @__PURE__ */ jsxs("group", {
244
- ref: groupRef,
245
- children: [resolvedBack && /* @__PURE__ */ jsx(CardBack, {
246
- width,
247
- height,
248
- color: resolvedBack.color,
249
- roughness: resolvedBack.roughness,
250
- metalness: resolvedBack.metalness
251
- }), /* @__PURE__ */ jsx(Render, {
252
- entityId,
253
- data,
254
- width,
255
- height
256
- })]
3365
+ var R3FRouter = class {
3366
+ surface = "webgl";
3367
+ /**
3368
+ * @param getEventManager Returns the R3F event manager whose `handlers`
3369
+ * we invoke. Wrapped in a getter because R3F creates the manager
3370
+ * inside the React component tree, so the router (typically constructed
3371
+ * alongside the InputManager) needs late-bound access.
3372
+ */
3373
+ constructor(getEventManager) {
3374
+ this.getEventManager = getEventManager;
3375
+ }
3376
+ route(event, entityId) {
3377
+ if (!event.native) return;
3378
+ const handlerName = HANDLER_BY_TYPE[event.type];
3379
+ if (!handlerName) return;
3380
+ const handler = this.getEventManager()?.handlers?.[handlerName];
3381
+ if (!handler) {
3382
+ inputLog("Router", `R3FRouter: no R3F manager / handler for ${handlerName}`, {
3383
+ type: event.type,
3384
+ entityId
3385
+ });
3386
+ return;
3387
+ }
3388
+ inputLog("Router", `R3FRouter R3F.${handlerName} for entity ${entityId}`, {
3389
+ type: event.type,
3390
+ entityId,
3391
+ handlerName
257
3392
  });
258
- };
3393
+ handler(event.native);
3394
+ }
3395
+ isPointerClaimed(pointerId) {
3396
+ const claimed = this.getEventManager()?.isPointerCaptured?.(pointerId) ?? false;
3397
+ if (claimed) inputLog("Router", `R3FRouter: pointer ${pointerId} CLAIMED via setPointerCapture`, { pointerId });
3398
+ return claimed;
3399
+ }
3400
+ };
3401
+ //#endregion
3402
+ //#region src/react/widgets/overlap-glow.ts
3403
+ /**
3404
+ * Overlap glow — visual config for the drag-over highlight applied to
3405
+ * cards (DOM `CardChrome` + R3F `CompositionMaterial`).
3406
+ *
3407
+ * Single-layer design: a soft radial gradient at the hot point in a
3408
+ * neutral color, blended normally over the card content. Deliberately
3409
+ * simple — no backdrop-filter, no saturation/contrast tricks, no rim,
3410
+ * no bloom. Three tunables: color, alpha (per state), falloff radius.
3411
+ *
3412
+ * Flows two ways from `<InfiniteCanvas overlapGlow={...} />`:
3413
+ * 1. CSS custom properties on the canvas container — read by
3414
+ * `CardChrome` via `var(--ic-glow-…, fallback)` for DOM widgets.
3415
+ * 2. Shared shader uniforms — every `CompositionMaterial` instance
3416
+ * references the same uniform objects, so mutating
3417
+ * {@link sharedGlowUniforms} updates all R3F cards at once.
3418
+ *
3419
+ * Each `[candidate, target]` pair holds the value used while the card
3420
+ * is just an overlap candidate vs. when the drop will actually consume.
3421
+ */
3422
+ const DEFAULT_OVERLAP_GLOW_CONFIG = {
3423
+ glowColor: [
3424
+ .5,
3425
+ .5,
3426
+ .5
3427
+ ],
3428
+ glowAlpha: [.25, .45],
3429
+ glowSize: [60, 80],
3430
+ rimColor: [
3431
+ .5,
3432
+ .5,
3433
+ .5
3434
+ ],
3435
+ rimWidth: 1.5,
3436
+ rimAlpha: [.55, .85],
3437
+ rimRadius: 600
3438
+ };
3439
+ function rgbTriplet(color) {
3440
+ const [r, g, b] = color;
3441
+ return `${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}`;
3442
+ }
3443
+ /** Write the config as CSS custom properties on `target`. CardChrome reads them. */
3444
+ function applyOverlapGlowVars(target, c) {
3445
+ const s = target.style;
3446
+ s.setProperty("--ic-glow-color", rgbTriplet(c.glowColor));
3447
+ s.setProperty("--ic-glow-alpha-c", String(c.glowAlpha[0]));
3448
+ s.setProperty("--ic-glow-alpha-t", String(c.glowAlpha[1]));
3449
+ s.setProperty("--ic-glow-size-c", `${c.glowSize[0]}px`);
3450
+ s.setProperty("--ic-glow-size-t", `${c.glowSize[1]}px`);
3451
+ s.setProperty("--ic-rim-color", rgbTriplet(c.rimColor));
3452
+ s.setProperty("--ic-rim-width", `${c.rimWidth}px`);
3453
+ s.setProperty("--ic-rim-alpha-c", String(c.rimAlpha[0]));
3454
+ s.setProperty("--ic-rim-alpha-t", String(c.rimAlpha[1]));
3455
+ s.setProperty("--ic-rim-radius", `${c.rimRadius}px`);
3456
+ }
3457
+ /** Push the same config into the shared shader uniforms (mutates in place). */
3458
+ function applyOverlapGlowShaderUniforms(c) {
3459
+ sharedGlowUniforms.uGlowColor.value.set(c.glowColor[0], c.glowColor[1], c.glowColor[2]);
3460
+ sharedGlowUniforms.uGlowAlpha.value.set(c.glowAlpha[0], c.glowAlpha[1]);
3461
+ sharedGlowUniforms.uGlowFalloff.value.set(c.glowSize[0] / 200, c.glowSize[1] / 200);
3462
+ sharedGlowUniforms.uRimColor.value.set(c.rimColor[0], c.rimColor[1], c.rimColor[2]);
3463
+ sharedGlowUniforms.uRimAlpha.value.set(c.rimAlpha[0], c.rimAlpha[1]);
3464
+ sharedGlowUniforms.uRimRadius.value = c.rimRadius / 200;
3465
+ }
3466
+ function mergeOverlapGlow(override) {
259
3467
  return {
260
- widget: {
261
- type: opts.type,
262
- surface: "webgl",
263
- schema: opts.schema,
264
- defaultData: opts.defaultData,
265
- defaultSize,
266
- component: Component
267
- },
268
- archetype: {
269
- id: opts.type,
270
- widget: opts.type,
271
- components: [[Card, { preset: opts.size }]],
272
- interactive: {
273
- selectable: true,
274
- draggable: true,
275
- resizable: false,
276
- selectionFrame: false
277
- },
278
- defaultSize
279
- }
3468
+ ...DEFAULT_OVERLAP_GLOW_CONFIG,
3469
+ ...override
280
3470
  };
281
3471
  }
282
3472
  //#endregion
283
- //#region src/react/WidgetProvider.tsx
3473
+ //#region src/react/widgets/registry.ts
3474
+ /** Narrows to the R3F variant. */
3475
+ function isR3FWidget(widget) {
3476
+ return widget.surface === "webgl";
3477
+ }
3478
+ //#endregion
3479
+ //#region src/react/widgets/WidgetProvider.tsx
284
3480
  /**
285
3481
  * Bridges the engine's widget registry to React context so WidgetSlot /
286
- * WebGLWidgetLayer can resolve components by type.
3482
+ * R3FManager can resolve components by type.
287
3483
  */
288
3484
  function WidgetProvider({ engine, children }) {
289
3485
  return /* @__PURE__ */ jsx(WidgetResolverProvider, {
@@ -304,8 +3500,12 @@ function WidgetProvider({ engine, children }) {
304
3500
  }
305
3501
  //#endregion
306
3502
  //#region src/react/InfiniteCanvas.tsx
307
- const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid, selection, onSelectionChange, onCameraChange, onNavigationChange, className, style, children }, ref) {
3503
+ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid, overlapGlow, selection, snapGuides, onSelectionChange, onCameraChange, onNavigationChange, className, style, children, r3fRoot }, ref) {
308
3504
  const containerRef = useRef(null);
3505
+ const [containerMounted, setContainerMounted] = useState(false);
3506
+ useLayoutEffect(() => {
3507
+ setContainerMounted(true);
3508
+ }, []);
309
3509
  const onSelectionChangeRef = useRef(onSelectionChange);
310
3510
  const onCameraChangeRef = useRef(onCameraChange);
311
3511
  const onNavigationChangeRef = useRef(onNavigationChange);
@@ -318,6 +3518,14 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
318
3518
  useEffect(() => {
319
3519
  onNavigationChangeRef.current = onNavigationChange;
320
3520
  }, [onNavigationChange]);
3521
+ const selectionRef = useRef(selection);
3522
+ const snapGuidesRef = useRef(snapGuides);
3523
+ useEffect(() => {
3524
+ selectionRef.current = selection;
3525
+ }, [selection]);
3526
+ useEffect(() => {
3527
+ snapGuidesRef.current = snapGuides;
3528
+ }, [snapGuides]);
321
3529
  useImperativeHandle(ref, () => ({
322
3530
  panTo: (x, y) => {
323
3531
  engine.panTo(x, y);
@@ -342,9 +3550,10 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
342
3550
  getEngine: () => engine
343
3551
  }), [engine]);
344
3552
  const webglCanvasRef = useRef(null);
345
- const gridRendererRef = useRef(null);
346
- const selectionRendererRef = useRef(null);
347
- const cameraLayerRef = useRef(null);
3553
+ const webglManagerRef = useRef(null);
3554
+ const backgroundLayerRef = useRef(null);
3555
+ const baseLayerRef = useRef(null);
3556
+ const overlayLayerRef = useRef(null);
348
3557
  const slotRefs = useRef(/* @__PURE__ */ new Map());
349
3558
  const [visibleEntities, setVisibleEntities] = useState([]);
350
3559
  const registerSlotRef = useCallback((entityId, el) => {
@@ -355,310 +3564,88 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
355
3564
  const container = containerRef.current;
356
3565
  const canvas = webglCanvasRef.current;
357
3566
  if (!container || !canvas) return;
358
- const gridEnabled = grid !== false;
359
- let gridInst = null;
360
- if (gridEnabled) {
361
- gridInst = new GridRenderer(canvas);
362
- gridRendererRef.current = gridInst;
363
- }
364
- const selInst = new SelectionRenderer();
365
- selectionRendererRef.current = selInst;
3567
+ const manager = new WebGLManager(canvas, {
3568
+ grid,
3569
+ selection: selectionRef.current,
3570
+ snapGuides: snapGuidesRef.current
3571
+ });
3572
+ webglManagerRef.current = manager;
366
3573
  const updateSize = () => {
367
3574
  const rect = container.getBoundingClientRect();
368
3575
  const dpr = window.devicePixelRatio;
369
3576
  engine.setViewport(rect.width, rect.height, dpr);
370
3577
  canvas.style.width = `${rect.width}px`;
371
3578
  canvas.style.height = `${rect.height}px`;
372
- if (gridInst) gridInst.setSize(rect.width, rect.height, dpr);
373
- selInst.setSize(new Vector2(rect.width * dpr, rect.height * dpr), dpr);
3579
+ manager.setSize(rect.width, rect.height, dpr);
374
3580
  };
375
3581
  updateSize();
376
3582
  const observer = new ResizeObserver(updateSize);
377
3583
  observer.observe(container);
378
3584
  return () => {
379
3585
  observer.disconnect();
380
- if (gridInst) {
381
- gridInst.dispose();
382
- gridRendererRef.current = null;
383
- }
384
- selInst.dispose();
385
- selectionRendererRef.current = null;
3586
+ manager.dispose();
3587
+ webglManagerRef.current = null;
386
3588
  };
387
3589
  }, [engine, grid]);
388
3590
  useEffect(() => {
389
- const gridR = gridRendererRef.current;
390
- if (gridR && grid !== false) {
3591
+ const manager = webglManagerRef.current;
3592
+ if (!manager) return;
3593
+ if (grid !== false) {
391
3594
  const isDark = document.documentElement.classList.contains("dark");
392
- gridR.setConfig({
3595
+ manager.setGridConfig({
393
3596
  dotColor: isDark ? [
394
- 1,
395
- 1,
396
- 1
3597
+ .35,
3598
+ .37,
3599
+ .4
397
3600
  ] : [
398
- 0,
399
- 0,
400
- 0
3601
+ .75,
3602
+ .77,
3603
+ .8
401
3604
  ],
402
- dotAlpha: isDark ? .12 : .18,
3605
+ dotAlpha: 1,
403
3606
  ...grid
404
3607
  });
405
3608
  }
406
- const selR = selectionRendererRef.current;
407
- if (selR && selection) selR.setConfig(selection);
3609
+ if (selection) manager.setSelectionConfig(selection);
3610
+ if (snapGuides) manager.setSnapGuideConfig(snapGuides);
408
3611
  engine.markDirty();
409
3612
  }, [
410
3613
  engine,
411
3614
  grid,
412
- selection
3615
+ selection,
3616
+ snapGuides
413
3617
  ]);
414
3618
  useEffect(() => {
3619
+ const merged = mergeOverlapGlow(overlapGlow);
415
3620
  const container = containerRef.current;
416
- if (!container) return;
417
- const onWheel = (e) => {
418
- e.preventDefault();
419
- if (e.ctrlKey || e.metaKey) {
420
- const rect = container.getBoundingClientRect();
421
- engine.zoomAtPoint(e.clientX - rect.left, e.clientY - rect.top, -e.deltaY * .01);
422
- } else engine.panBy(-e.deltaX, -e.deltaY);
423
- };
424
- container.addEventListener("wheel", onWheel, { passive: false });
425
- return () => container.removeEventListener("wheel", onWheel);
426
- }, [engine]);
3621
+ if (container) applyOverlapGlowVars(container, merged);
3622
+ applyOverlapGlowShaderUniforms(merged);
3623
+ engine.markDirty();
3624
+ }, [engine, overlapGlow]);
3625
+ const r3fEventManagerRef = useRef(null);
427
3626
  useEffect(() => {
428
3627
  const container = containerRef.current;
429
3628
  if (!container) return;
430
- let gesture = { type: "idle" };
431
- let lastTapTime = 0;
432
- let lastTapX = 0;
433
- let lastTapY = 0;
434
- const DOUBLE_TAP_MS = 300;
435
- const DOUBLE_TAP_DIST = 30;
436
- function isOnWidget(target) {
437
- let el = target;
438
- while (el && el !== container) {
439
- if (el.hasAttribute("data-widget-slot")) return true;
440
- el = el.parentElement;
441
- }
442
- return false;
443
- }
444
- function isInteractive(target) {
445
- const el = target;
446
- if (!el) return false;
447
- const tag = el.tagName;
448
- return tag === "INPUT" || tag === "TEXTAREA" || tag === "BUTTON" || tag === "SELECT" || el.isContentEditable || el.closest("button") !== null;
449
- }
450
- function getRect() {
451
- return container?.getBoundingClientRect() ?? new DOMRect();
452
- }
453
- function touchDist(t1, t2) {
454
- const dx = t1.clientX - t2.clientX;
455
- const dy = t1.clientY - t2.clientY;
456
- return Math.sqrt(dx * dx + dy * dy);
457
- }
458
- function touchCenter(t1, t2, rect) {
459
- return {
460
- x: (t1.clientX + t2.clientX) / 2 - rect.left,
461
- y: (t1.clientY + t2.clientY) / 2 - rect.top
462
- };
463
- }
464
- function cancelEngineGesture() {
465
- if (gesture.type === "pending-entity" || gesture.type === "entity-dragging") engine.handlePointerUp();
466
- }
467
- const noMods = {
468
- shift: false,
469
- ctrl: false,
470
- alt: false,
471
- meta: false
472
- };
473
- function onTouchStart(e) {
474
- const rect = getRect();
475
- const touches = e.touches;
476
- if (touches.length >= 2) {
477
- e.preventDefault();
478
- cancelEngineGesture();
479
- const dist = touchDist(touches[0], touches[1]);
480
- const center = touchCenter(touches[0], touches[1], rect);
481
- gesture = {
482
- type: "pinching",
483
- lastDist: dist,
484
- lastCx: center.x,
485
- lastCy: center.y
486
- };
487
- return;
488
- }
489
- const touch = touches[0];
490
- const x = touch.clientX - rect.left;
491
- const y = touch.clientY - rect.top;
492
- if (isInteractive(e.target)) return;
493
- e.preventDefault();
494
- const now = Date.now();
495
- if (now - lastTapTime < DOUBLE_TAP_MS && Math.abs(x - lastTapX) < DOUBLE_TAP_DIST && Math.abs(y - lastTapY) < DOUBLE_TAP_DIST) {
496
- lastTapTime = 0;
497
- const directive = engine.handlePointerDown(x, y, 0, noMods);
498
- try {
499
- if (directive.action === "passthrough-track-drag") {
500
- const selected = engine.getSelectedEntities();
501
- if (selected.length === 1) engine.enterContainer(selected[0]);
502
- } else {
503
- const camera = engine.getCamera();
504
- const target = camera.zoom < .9 ? 1 : camera.zoom < 1.8 ? 2 : 1;
505
- engine.zoomAtPoint(x, y, (target - camera.zoom) / camera.zoom);
506
- }
507
- } finally {
508
- engine.handlePointerUp();
509
- engine.markDirty();
510
- }
511
- gesture = { type: "idle" };
512
- return;
513
- }
514
- if (isOnWidget(e.target)) {
515
- engine.handlePointerDown(x, y, 0, noMods);
516
- gesture = {
517
- type: "pending-entity",
518
- x,
519
- y,
520
- time: now
521
- };
522
- } else gesture = {
523
- type: "pending-pan",
524
- x,
525
- y,
526
- time: now
527
- };
528
- }
529
- function onTouchMove(e) {
530
- e.preventDefault();
531
- const rect = getRect();
532
- const touches = e.touches;
533
- if (gesture.type === "pinching" && touches.length >= 2) {
534
- const dist = touchDist(touches[0], touches[1]);
535
- const center = touchCenter(touches[0], touches[1], rect);
536
- const scale = dist / gesture.lastDist;
537
- engine.zoomAtPoint(center.x, center.y, scale - 1);
538
- engine.panBy(center.x - gesture.lastCx, center.y - gesture.lastCy);
539
- gesture.lastDist = dist;
540
- gesture.lastCx = center.x;
541
- gesture.lastCy = center.y;
542
- return;
543
- }
544
- if (touches.length >= 2) {
545
- cancelEngineGesture();
546
- const dist = touchDist(touches[0], touches[1]);
547
- const center = touchCenter(touches[0], touches[1], rect);
548
- gesture = {
549
- type: "pinching",
550
- lastDist: dist,
551
- lastCx: center.x,
552
- lastCy: center.y
553
- };
554
- return;
555
- }
556
- if (touches.length < 1) return;
557
- const touch = touches[0];
558
- const x = touch.clientX - rect.left;
559
- const y = touch.clientY - rect.top;
560
- if (gesture.type === "pending-pan") {
561
- if (Math.abs(x - gesture.x) > 8 || Math.abs(y - gesture.y) > 8) gesture = {
562
- type: "panning",
563
- lastX: x,
564
- lastY: y
565
- };
566
- return;
567
- }
568
- if (gesture.type === "panning") {
569
- engine.panBy(x - gesture.lastX, y - gesture.lastY);
570
- gesture.lastX = x;
571
- gesture.lastY = y;
572
- return;
573
- }
574
- if (gesture.type === "pending-entity" || gesture.type === "entity-dragging") {
575
- engine.handlePointerMove(x, y, noMods);
576
- if (gesture.type === "pending-entity") {
577
- if (Math.abs(x - gesture.x) > 8 || Math.abs(y - gesture.y) > 8) gesture = { type: "entity-dragging" };
578
- }
579
- }
580
- }
581
- function onTouchEnd(e) {
582
- e.preventDefault();
583
- const remaining = e.touches.length;
584
- const rect = getRect();
585
- if (gesture.type === "pinching") {
586
- if (remaining === 1) {
587
- const t = e.touches[0];
588
- gesture = {
589
- type: "panning",
590
- lastX: t.clientX - rect.left,
591
- lastY: t.clientY - rect.top
592
- };
593
- } else if (remaining === 0) gesture = { type: "idle" };
594
- return;
595
- }
596
- if (remaining > 0) return;
597
- if (gesture.type === "pending-pan") {
598
- engine.handlePointerDown(gesture.x, gesture.y, 0, noMods);
599
- engine.handlePointerUp();
600
- engine.markDirty();
601
- lastTapTime = Date.now();
602
- lastTapX = gesture.x;
603
- lastTapY = gesture.y;
604
- }
605
- if (gesture.type === "pending-entity") {
606
- engine.handlePointerUp();
607
- engine.markDirty();
608
- lastTapTime = Date.now();
609
- lastTapX = gesture.x;
610
- lastTapY = gesture.y;
611
- }
612
- if (gesture.type === "entity-dragging") {
613
- engine.handlePointerUp();
614
- engine.markDirty();
615
- }
616
- gesture = { type: "idle" };
617
- }
618
- function onTouchCancel(_e) {
619
- gesture = { type: "idle" };
620
- engine.handlePointerCancel();
621
- }
622
- container.addEventListener("touchstart", onTouchStart, { passive: false });
623
- container.addEventListener("touchmove", onTouchMove, { passive: false });
624
- container.addEventListener("touchend", onTouchEnd, { passive: false });
625
- container.addEventListener("touchcancel", onTouchCancel, { passive: true });
3629
+ const manager = new InputManager(engine, container, [
3630
+ new PointerAdapter(),
3631
+ new WheelAdapter(),
3632
+ new ClickAdapter()
3633
+ ]);
3634
+ manager.addRecognizer(new HoverRecognizer());
3635
+ manager.addRecognizer(new TapRecognizer());
3636
+ manager.addRecognizer(new DoubleTapRecognizer());
3637
+ manager.addRecognizer(new DragRecognizer());
3638
+ manager.addRecognizer(new PinchRecognizer());
3639
+ manager.addRecognizer(new PanRecognizer());
3640
+ manager.setRouter(new R3FRouter(() => r3fEventManagerRef.current));
3641
+ const offHandlers = installEngineHandlers(manager, engine, container);
3642
+ const detach = manager.attach();
626
3643
  return () => {
627
- container.removeEventListener("touchstart", onTouchStart);
628
- container.removeEventListener("touchmove", onTouchMove);
629
- container.removeEventListener("touchend", onTouchEnd);
630
- container.removeEventListener("touchcancel", onTouchCancel);
3644
+ offHandlers();
3645
+ detach();
3646
+ r3fEventManagerRef.current = null;
631
3647
  };
632
3648
  }, [engine]);
633
- const onCanvasPointerDown = useCallback((e) => {
634
- if (e.target?.closest("button, input, textarea, select, [contenteditable]")) return;
635
- const rect = containerRef.current?.getBoundingClientRect();
636
- if (!rect) return;
637
- const directive = engine.handlePointerDown(e.clientX - rect.left, e.clientY - rect.top, e.button, {
638
- shift: e.shiftKey,
639
- ctrl: e.ctrlKey,
640
- alt: e.altKey,
641
- meta: e.metaKey
642
- });
643
- if (directive.action === "capture-resize" || directive.action === "passthrough-track-drag") containerRef.current?.setPointerCapture(e.pointerId);
644
- if (directive.action === "capture-resize") e.preventDefault();
645
- }, [engine]);
646
- const onCanvasPointerMove = useCallback((e) => {
647
- const target = e.target;
648
- if (target.closest?.("[data-widget-slot]") && target !== containerRef.current) return;
649
- const rect = containerRef.current?.getBoundingClientRect();
650
- if (!rect) return;
651
- engine.handlePointerMove(e.clientX - rect.left, e.clientY - rect.top, {
652
- shift: e.shiftKey,
653
- ctrl: e.ctrlKey,
654
- alt: e.altKey,
655
- meta: e.metaKey
656
- });
657
- }, [engine]);
658
- const onCanvasPointerUp = useCallback((e) => {
659
- if (containerRef.current?.hasPointerCapture(e.pointerId)) containerRef.current.releasePointerCapture(e.pointerId);
660
- engine.handlePointerUp();
661
- }, [engine]);
662
3649
  useEffect(() => {
663
3650
  let rafId;
664
3651
  let running = true;
@@ -667,74 +3654,72 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
667
3654
  if (engine.flushIfDirty()) {
668
3655
  const camera = engine.getCamera();
669
3656
  const changes = engine.getFrameChanges();
670
- if (cameraLayerRef.current) cameraLayerRef.current.style.transform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
3657
+ const transform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
3658
+ if (backgroundLayerRef.current) backgroundLayerRef.current.style.transform = transform;
3659
+ if (baseLayerRef.current) baseLayerRef.current.style.transform = transform;
3660
+ if (overlayLayerRef.current) overlayLayerRef.current.style.transform = transform;
671
3661
  const cursor = engine.world.getResource(CursorResource).cursor;
672
3662
  if (containerRef.current && containerRef.current.style.cursor !== cursor) containerRef.current.style.cursor = cursor;
673
- const profiler = engine.profiler;
674
- const profilerOn = profiler.isEnabled();
675
- let selectionFramesDrawn = 0;
676
- let snapGuidesDrawn = 0;
677
- let spacingIndicatorsDrawn = 0;
678
- if (gridRendererRef.current) gridRendererRef.current.getWebGLRenderer().info.reset();
679
- if (gridRendererRef.current) {
680
- profiler.beginWebGL("grid");
681
- gridRendererRef.current.render(camera.x, camera.y, camera.zoom);
682
- profiler.endWebGL("grid");
683
- }
684
- if (selectionRendererRef.current && gridRendererRef.current) {
685
- const selected = engine.getSelectedEntities();
3663
+ const manager = webglManagerRef.current;
3664
+ if (manager) {
3665
+ const selectedIds = engine.getSelectedEntities();
686
3666
  const selBounds = [];
687
- for (const id of selected) {
3667
+ for (const id of selectedIds) {
688
3668
  if (!engine.has(id, SelectionFrame)) continue;
689
- const wb = engine.get(id, WorldBounds);
690
- if (wb) selBounds.push({
691
- x: wb.worldX,
692
- y: wb.worldY,
693
- width: wb.worldWidth,
694
- height: wb.worldHeight
3669
+ const t = engine.get(id, Transform2D);
3670
+ if (t) selBounds.push({
3671
+ x: t.x,
3672
+ y: t.y,
3673
+ width: t.width,
3674
+ height: t.height
695
3675
  });
696
3676
  }
697
3677
  const hovId = engine.getHoveredEntity();
698
3678
  let hovBounds = null;
699
3679
  if (hovId !== null && engine.has(hovId, SelectionFrame)) {
700
- const wb = engine.get(hovId, WorldBounds);
701
- if (wb) hovBounds = {
702
- x: wb.worldX,
703
- y: wb.worldY,
704
- width: wb.worldWidth,
705
- height: wb.worldHeight
3680
+ const t = engine.get(hovId, Transform2D);
3681
+ if (t) hovBounds = {
3682
+ x: t.x,
3683
+ y: t.y,
3684
+ width: t.width,
3685
+ height: t.height
706
3686
  };
707
3687
  }
708
- const snapGuides = engine.getSnapGuides();
709
- const equalSpacing = engine.getEqualSpacing();
710
- selectionFramesDrawn = selBounds.length + (hovBounds ? 1 : 0);
711
- snapGuidesDrawn = snapGuides.length;
712
- spacingIndicatorsDrawn = equalSpacing.length;
713
- profiler.beginWebGL("selection");
714
- selectionRendererRef.current.render(gridRendererRef.current.getWebGLRenderer(), camera.x, camera.y, camera.zoom, selBounds, hovBounds, snapGuides, equalSpacing);
715
- profiler.endWebGL("selection");
716
- }
717
- if (profilerOn && gridRendererRef.current) {
718
- const info = gridRendererRef.current.getWebGLRenderer().info;
719
- profiler.recordWebGLStats({
720
- drawCalls: info.render.calls,
721
- triangles: info.render.triangles,
722
- selectionFrames: selectionFramesDrawn,
723
- snapGuides: snapGuidesDrawn,
724
- spacingIndicators: spacingIndicatorsDrawn,
3688
+ manager.render({
3689
+ camera: {
3690
+ x: camera.x,
3691
+ y: camera.y,
3692
+ zoom: camera.zoom
3693
+ },
3694
+ selection: {
3695
+ bounds: selBounds,
3696
+ hovered: hovBounds
3697
+ },
3698
+ snap: {
3699
+ guides: engine.getSnapGuides(),
3700
+ spacings: engine.getEqualSpacing(),
3701
+ visible: engine.getSnapGuidesVisible()
3702
+ },
3703
+ profiler: engine.profiler,
725
3704
  domPositionsUpdated: changes.positionsChanged.length
726
3705
  });
727
3706
  }
728
3707
  for (const entityId of changes.positionsChanged) {
729
3708
  const el = slotRefs.current.get(entityId);
730
3709
  if (!el) continue;
731
- const wb = engine.get(entityId, WorldBounds);
732
- if (!wb) continue;
733
- el.style.transform = `translate(${wb.worldX}px, ${wb.worldY}px)`;
734
- el.style.width = `${wb.worldWidth}px`;
735
- el.style.height = `${wb.worldHeight}px`;
3710
+ const t = engine.get(entityId, Transform2D);
3711
+ if (!t) continue;
3712
+ el.style.transform = `translate(${t.x}px, ${t.y}px)`;
3713
+ el.style.width = `${t.width}px`;
3714
+ el.style.height = `${t.height}px`;
3715
+ }
3716
+ for (const entityId of changes.zIndicesChanged) {
3717
+ const el = slotRefs.current.get(entityId);
3718
+ if (!el) continue;
3719
+ const z = engine.get(entityId, ZIndex);
3720
+ el.style.zIndex = z ? String(z.value) : "";
736
3721
  }
737
- if (changes.entered.length > 0 || changes.exited.length > 0) setVisibleEntities(engine.getVisibleEntities().map((v) => v.entityId));
3722
+ if (changes.entered.length > 0 || changes.exited.length > 0 || changes.layersChanged) setVisibleEntities(engine.getVisibleEntities().map((v) => v.entityId));
738
3723
  if (changes.selectionChanged && onSelectionChangeRef.current) onSelectionChangeRef.current(engine.getSelectedEntities());
739
3724
  if (changes.cameraChanged && onCameraChangeRef.current) onCameraChangeRef.current({
740
3725
  x: camera.x,
@@ -754,14 +3739,34 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
754
3739
  const visible = engine.getVisibleEntities();
755
3740
  setVisibleEntities(visible.map((v) => v.entityId));
756
3741
  const camera = engine.getCamera();
757
- if (cameraLayerRef.current) cameraLayerRef.current.style.transform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
758
- if (gridRendererRef.current) gridRendererRef.current.render(camera.x, camera.y, camera.zoom);
3742
+ const initTransform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
3743
+ if (backgroundLayerRef.current) backgroundLayerRef.current.style.transform = initTransform;
3744
+ if (baseLayerRef.current) baseLayerRef.current.style.transform = initTransform;
3745
+ if (overlayLayerRef.current) overlayLayerRef.current.style.transform = initTransform;
3746
+ const manager = webglManagerRef.current;
3747
+ if (manager) manager.render({
3748
+ camera: {
3749
+ x: camera.x,
3750
+ y: camera.y,
3751
+ zoom: camera.zoom
3752
+ },
3753
+ selection: {
3754
+ bounds: [],
3755
+ hovered: null
3756
+ },
3757
+ snap: {
3758
+ guides: [],
3759
+ spacings: [],
3760
+ visible: false
3761
+ }
3762
+ });
759
3763
  for (const v of visible) {
760
3764
  const el = slotRefs.current.get(v.entityId);
761
3765
  if (!el) continue;
762
- el.style.transform = `translate(${v.worldX}px, ${v.worldY}px)`;
763
- el.style.width = `${v.worldWidth}px`;
764
- el.style.height = `${v.worldHeight}px`;
3766
+ el.style.transform = `translate(${v.x}px, ${v.y}px)`;
3767
+ el.style.width = `${v.width}px`;
3768
+ el.style.height = `${v.height}px`;
3769
+ el.style.zIndex = String(v.zIndex);
765
3770
  }
766
3771
  rafId = requestAnimationFrame(loop);
767
3772
  return () => {
@@ -773,20 +3778,29 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
773
3778
  for (const entityId of visibleEntities) {
774
3779
  const el = slotRefs.current.get(entityId);
775
3780
  if (!el) continue;
776
- const wb = engine.get(entityId, WorldBounds);
777
- if (!wb) continue;
778
- el.style.transform = `translate(${wb.worldX}px, ${wb.worldY}px)`;
779
- el.style.width = `${wb.worldWidth}px`;
780
- el.style.height = `${wb.worldHeight}px`;
3781
+ const t = engine.get(entityId, Transform2D);
3782
+ if (!t) continue;
3783
+ el.style.transform = `translate(${t.x}px, ${t.y}px)`;
3784
+ el.style.width = `${t.width}px`;
3785
+ el.style.height = `${t.height}px`;
3786
+ const z = engine.get(entityId, ZIndex);
3787
+ if (z) el.style.zIndex = String(z.value);
781
3788
  }
782
3789
  }, [visibleEntities, engine]);
783
- const { domEntities, webglEntities } = useMemo(() => {
784
- const dom = [];
3790
+ const { backgroundDom, baseDom, overlayDom, webglEntities } = useMemo(() => {
3791
+ const background = [];
3792
+ const base = [];
3793
+ const overlay = [];
785
3794
  const webgl = [];
786
- for (const id of visibleEntities) if (engine.get(id, Widget)?.surface === "webgl") webgl.push(id);
787
- else dom.push(id);
3795
+ for (const id of visibleEntities) {
3796
+ if (engine.get(id, Widget)?.surface === "webgl") webgl.push(id);
3797
+ const layerName = engine.get(id, Layer)?.name ?? "base";
3798
+ (layerName === "background" ? background : layerName === "overlay" ? overlay : base).push(id);
3799
+ }
788
3800
  return {
789
- domEntities: dom,
3801
+ backgroundDom: background,
3802
+ baseDom: base,
3803
+ overlayDom: overlay,
790
3804
  webglEntities: webgl
791
3805
  };
792
3806
  }, [visibleEntities, engine]);
@@ -804,29 +3818,30 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
804
3818
  touchAction: "none",
805
3819
  backgroundColor: "var(--canvas-bg, #fafafa)"
806
3820
  },
807
- onPointerDown: onCanvasPointerDown,
808
- onPointerMove: onCanvasPointerMove,
809
- onPointerUp: onCanvasPointerUp,
810
3821
  children: [
811
3822
  /* @__PURE__ */ jsx("canvas", {
812
3823
  ref: webglCanvasRef,
813
3824
  className: "absolute inset-0 pointer-events-none"
814
3825
  }),
815
- webglEntities.length > 0 && /* @__PURE__ */ jsx(WebGLWidgetBridge, {
3826
+ /* @__PURE__ */ jsx("div", { className: "absolute inset-0 pointer-events-none" }),
3827
+ /* @__PURE__ */ jsx(LayerContainer, {
3828
+ layerRef: backgroundLayerRef,
3829
+ children: bucketSlots(backgroundDom, engine, registerSlotRef)
3830
+ }),
3831
+ /* @__PURE__ */ jsx(LayerContainer, {
3832
+ layerRef: baseLayerRef,
3833
+ children: bucketSlots(baseDom, engine, registerSlotRef)
3834
+ }),
3835
+ containerMounted && webglEntities.length > 0 && /* @__PURE__ */ jsx(R3FBridge, {
816
3836
  engine,
817
- entities: webglEntities
3837
+ entities: webglEntities,
3838
+ r3fRoot,
3839
+ eventManagerRef: r3fEventManagerRef
818
3840
  }),
819
- /* @__PURE__ */ jsx("div", { className: "absolute inset-0 pointer-events-none" }),
820
- /* @__PURE__ */ jsxs("div", {
821
- ref: cameraLayerRef,
822
- className: "absolute left-0 top-0 origin-top-left will-change-transform",
823
- children: [domEntities.map((entityId) => /* @__PURE__ */ jsx(WidgetSlot, {
824
- entityId,
825
- slotRef: registerSlotRef
826
- }, entityId)), webglEntities.map((entityId) => /* @__PURE__ */ jsx(SelectionOverlaySlot, {
827
- entityId,
828
- slotRef: registerSlotRef
829
- }, entityId))]
3841
+ /* @__PURE__ */ jsx(LayerContainer, {
3842
+ layerRef: overlayLayerRef,
3843
+ zIndex: 2,
3844
+ children: bucketSlots(overlayDom, engine, registerSlotRef)
830
3845
  }),
831
3846
  children
832
3847
  ]
@@ -835,21 +3850,202 @@ const InfiniteCanvas = React.forwardRef(function InfiniteCanvas({ engine, grid,
835
3850
  })
836
3851
  });
837
3852
  });
838
- /** Bridge component — reads widget resolver from context and passes to WebGLWidgetLayer */
839
- function WebGLWidgetBridge({ engine, entities }) {
3853
+ /** Bridge component — reads widget resolver from context and passes to R3FManager. */
3854
+ function R3FBridge({ engine, entities, r3fRoot, eventManagerRef }) {
840
3855
  const resolver = useWidgetResolver();
841
3856
  const resolve = useCallback((entityId) => {
842
3857
  if (!resolver) return null;
843
3858
  return resolver(entityId, engine.get(entityId, Widget)?.type ?? "");
844
3859
  }, [resolver, engine]);
845
3860
  if (!resolver) return null;
846
- return /* @__PURE__ */ jsx(WebGLWidgetLayer, {
3861
+ return /* @__PURE__ */ jsx(R3FManager, {
847
3862
  engine,
848
3863
  entities,
849
- resolve
3864
+ resolve,
3865
+ r3fRoot,
3866
+ eventManagerRef
3867
+ });
3868
+ }
3869
+ /**
3870
+ * One DOM container for a render layer (RFC-003). Each container holds
3871
+ * the WidgetSlot / SelectionOverlaySlot elements for the entities
3872
+ * bucketed into its layer; the container's CSS transform is driven by
3873
+ * the rAF loop so all layers pan / zoom in lockstep.
3874
+ *
3875
+ * `zIndex` is applied via a one-shot effect (not via React's `style`
3876
+ * prop) so it doesn't fight the rAF loop's direct `style.transform`
3877
+ * writes — and so it doesn't depend on Tailwind's content scanner
3878
+ * picking up the class from inside the library bundle (which it
3879
+ * doesn't, since the library lives in node_modules of the consumer).
3880
+ */
3881
+ function LayerContainer({ layerRef, zIndex, children }) {
3882
+ useEffect(() => {
3883
+ if (zIndex !== void 0 && layerRef.current) layerRef.current.style.zIndex = String(zIndex);
3884
+ }, [layerRef, zIndex]);
3885
+ return /* @__PURE__ */ jsx("div", {
3886
+ ref: layerRef,
3887
+ className: "absolute left-0 top-0 origin-top-left will-change-transform",
3888
+ children
3889
+ });
3890
+ }
3891
+ /**
3892
+ * Renders the right slot per entity in a layer container — DOM widgets
3893
+ * get a `WidgetSlot`, R3F widgets get a `SelectionOverlaySlot` (chrome
3894
+ * + interaction surface; the actual 3D content renders through the R3F
3895
+ * canvas). Pure helper so the JSX in the main component stays compact.
3896
+ */
3897
+ function bucketSlots(entities, engine, registerSlotRef) {
3898
+ return entities.map((entityId) => {
3899
+ return engine.get(entityId, Widget)?.surface === "webgl" ? /* @__PURE__ */ jsx(SelectionOverlaySlot, {
3900
+ entityId,
3901
+ slotRef: registerSlotRef
3902
+ }, entityId) : /* @__PURE__ */ jsx(WidgetSlot, {
3903
+ entityId,
3904
+ slotRef: registerSlotRef
3905
+ }, entityId);
3906
+ });
3907
+ }
3908
+ //#endregion
3909
+ //#region src/react/widgets/card.tsx
3910
+ /**
3911
+ * Visual chrome for an iOS-style card. Reads the entity's `Dragging` tag
3912
+ * and forwards `lifted` to {@link CardChrome}, which owns the actual
3913
+ * appearance (rounded corners, hairline ring, soft drop shadow, lift
3914
+ * transition). Same chrome is used by R3F geometry cards via a DOM slot
3915
+ * beneath the WebGL canvas, so DOM and 3D cards stay visually identical.
3916
+ */
3917
+ function CardFrame({ entityId, children, className, style }) {
3918
+ const dragging = useTag(entityId, Dragging);
3919
+ const overlapCandidate = useTag(entityId, OverlapCandidate);
3920
+ const overlapTarget = useTag(entityId, OverlapTarget);
3921
+ const hot = useComponent(entityId, CardOverlapHotPoint);
3922
+ return /* @__PURE__ */ jsx(CardChrome, {
3923
+ lifted: dragging,
3924
+ className,
3925
+ style,
3926
+ overlapCandidate,
3927
+ overlapTarget,
3928
+ hotX: hot?.x,
3929
+ hotY: hot?.y,
3930
+ hotStrength: hot?.strength,
3931
+ children
850
3932
  });
851
3933
  }
3934
+ /**
3935
+ * Returns a paired widget + archetype for an iOS-style card. Register both
3936
+ * with `createLayoutEngine({ widgets: [card.widget], archetypes: [card.archetype] })`
3937
+ * (or via `engine.registerWidget` / `engine.registerArchetype`) and spawn with
3938
+ * `engine.spawn('your-card-type', { at, data })`.
3939
+ *
3940
+ * The produced widget is non-resizable (Selectable + Draggable only), wrapped
3941
+ * in `<CardFrame>`, and spawns with a `Card` component so `cardSystem` enforces
3942
+ * the preset size each tick.
3943
+ */
3944
+ function createCardWidget(opts) {
3945
+ const defaultSize = DEFAULT_CARD_PRESET_SIZES[opts.size];
3946
+ const Render = opts.render;
3947
+ const Component = ({ entityId }) => {
3948
+ return /* @__PURE__ */ jsx(CardFrame, {
3949
+ entityId,
3950
+ children: /* @__PURE__ */ jsx(Render, {
3951
+ entityId,
3952
+ data: useWidgetData(entityId)
3953
+ })
3954
+ });
3955
+ };
3956
+ return {
3957
+ widget: {
3958
+ type: opts.type,
3959
+ schema: opts.schema,
3960
+ defaultData: opts.defaultData,
3961
+ defaultSize,
3962
+ component: Component,
3963
+ interaction: opts.interaction
3964
+ },
3965
+ archetype: {
3966
+ id: opts.type,
3967
+ widget: opts.type,
3968
+ components: [[Card, {
3969
+ preset: opts.size,
3970
+ accepts: opts.accepts ?? [],
3971
+ provides: opts.provides ?? []
3972
+ }]],
3973
+ interactive: {
3974
+ selectable: true,
3975
+ draggable: true,
3976
+ resizable: false,
3977
+ selectionFrame: false,
3978
+ snapSource: false,
3979
+ snapTarget: true
3980
+ },
3981
+ defaultSize
3982
+ }
3983
+ };
3984
+ }
3985
+ //#endregion
3986
+ //#region src/r3f/widgets/geometry-card.tsx
3987
+ /**
3988
+ * Returns a paired R3F widget + archetype for a card-shaped 3D widget.
3989
+ * Behaves like {@link createCardWidget} — fixed preset size, non-resizable,
3990
+ * no engine-drawn selection frame, and lifts on drag (scale + z) — but
3991
+ * renders a three.js scene instead of DOM content.
3992
+ *
3993
+ * The card body and drop shadow are rendered as DOM `<CardChrome>`
3994
+ * beneath the WebGL canvas, not inside the FBO. The user's `geometry`
3995
+ * component renders ONLY the 3D content; chrome is provided by the
3996
+ * `Card` ECS component (the source of truth for all card-shaped
3997
+ * behavior — chrome, lift, drag-promote, compositor discard).
3998
+ *
3999
+ * Pass `withCard: false` to skip card behavior entirely (bare 3D
4000
+ * widget — no chrome, no lift, no discard).
4001
+ *
4002
+ * Lighting: this helper adds no lights. Declare your own in the `geometry`
4003
+ * component (typically a local `pointLight` scoped with `distance`).
4004
+ */
4005
+ function createGeometryCardWidget(opts) {
4006
+ const defaultSize = DEFAULT_CARD_PRESET_SIZES[opts.size];
4007
+ const Render = opts.geometry;
4008
+ const withCard = opts.withCard ?? true;
4009
+ const Component = ({ entityId, width, height }) => {
4010
+ return /* @__PURE__ */ jsx(Render, {
4011
+ entityId,
4012
+ data: useWidgetData(entityId),
4013
+ width,
4014
+ height
4015
+ });
4016
+ };
4017
+ return {
4018
+ widget: {
4019
+ type: opts.type,
4020
+ surface: "webgl",
4021
+ schema: opts.schema,
4022
+ defaultData: opts.defaultData,
4023
+ defaultSize,
4024
+ component: Component,
4025
+ interaction: opts.interaction
4026
+ },
4027
+ archetype: {
4028
+ id: opts.type,
4029
+ widget: opts.type,
4030
+ components: withCard ? [[Card, {
4031
+ preset: opts.size,
4032
+ background: opts.background ?? "#1C1C1E",
4033
+ accepts: opts.accepts ?? [],
4034
+ provides: opts.provides ?? []
4035
+ }]] : [],
4036
+ interactive: {
4037
+ selectable: true,
4038
+ draggable: true,
4039
+ resizable: false,
4040
+ selectionFrame: false,
4041
+ snapSource: false,
4042
+ snapTarget: true
4043
+ },
4044
+ defaultSize
4045
+ }
4046
+ };
4047
+ }
852
4048
  //#endregion
853
- export { Active, BreakpointConfigResource, CameraResource, Card, CardFrame, CardPresetsResource, Children, CommandBuffer, Container, CursorHint, CursorResource, DEFAULT_GRID_CONFIG, DEFAULT_SELECTION_CONFIG, Draggable, Dragging, HandleSet, Hitbox, InfiniteCanvas, InteractionRole, Locked, MoveCommand, NavigationStackResource, Parent, Resizable, ResizeCommand, Selectable, Selected, SelectionFrame, SetComponentCommand, Transform2D, ViewportResource, Visible, Widget, WidgetBreakpoint, WidgetData, WidgetProvider, WidgetResolverProvider, WorldBounds, ZIndex, ZoomConfigResource, clamp, createArchetypeRegistry, createCardWidget, createGeometryCardWidget, createLayoutEngine, createWidgetRegistry, intersectsAABB, isR3FWidget, pointInAABB, screenToWorld, useAllEntities, useBreakpoint, useCamera, useChildren, useComponent, useContainerRef, useEntityComponents, useEntityTags, useIsSelected, useLayoutEngine, useQuery, useRegisteredComponents, useRegisteredTags, useResource, useTag, useTaggedEntities, useUpdateWidget, useWidgetData, useWidgetResolver, worldBoundsToAABB, worldToScreen };
4049
+ export { Active, BreakpointConfigResource, CameraResource, Card, CardChrome, CardFrame, CardOverlapHotPoint, CardPresetsResource, Children, CommandBuffer, ConsumeCommand, Container, ContainerCamera, ContainerChildren, Culled, CursorHint, CursorResource, DEFAULT_GRID_CONFIG, DEFAULT_OVERLAP_GLOW_CONFIG, DEFAULT_SELECTION_CONFIG, DEFAULT_SNAP_GUIDE_CONFIG, Draggable, Dragging, InfiniteCanvas, InteractionRole, Layer, LayerOrderResource, Locked, MoveCommand, NavigationStackResource, OverlapCandidate, OverlapTarget, ParentFrame, Resizable, ResizeCommand, RootCameraResource, Selectable, Selected, SelectionFrame, SetComponentCommand, SnapSource, SnapTarget, Transform2D, TransformTween, ViewportResource, Visible, Widget, WidgetBreakpoint, WidgetData, WidgetProvider, WidgetResolverProvider, ZIndex, ZOOM_BANDS, ZoomConfigResource, aabbToRect, clamp, createArchetypeRegistry, createCardWidget, createGeometryCardWidget, createLayoutEngine, createWidgetRegistry, intersectsAABB, isFrameAncestorOf, isOutOfBand, isR3FWidget, pointInAABB, rectToAABB, rehydrateEntity, screenToWorld, selectBand, snapshotEntity, useAllEntities, useBreakpoint, useCamera, useChildren, useComponent, useContainerRef, useEntityComponents, useEntityTags, useIsSelected, useLayoutEngine, useQuery, useRegisteredComponents, useRegisteredTags, useResource, useSharedGeometry, useSharedMaterial, useSharedTexture, useTag, useTaggedEntities, useUpdateWidget, useWidgetAnimation, useWidgetData, useWidgetInvalidate, useWidgetPhase, useWidgetResolver, worldToScreen };
854
4050
 
855
4051
  //# sourceMappingURL=index.mjs.map