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