@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.
- package/README.md +65 -0
- package/dist/advanced.cjs +61 -24
- package/dist/advanced.cjs.map +1 -1
- package/dist/advanced.d.cts +180 -64
- package/dist/advanced.d.cts.map +1 -1
- package/dist/advanced.d.mts +180 -64
- package/dist/advanced.d.mts.map +1 -1
- package/dist/advanced.mjs +29 -12
- package/dist/advanced.mjs.map +1 -1
- package/dist/devtools.cjs +22 -22
- package/dist/devtools.cjs.map +1 -1
- package/dist/devtools.d.cts +2 -2
- package/dist/devtools.d.cts.map +1 -1
- package/dist/devtools.d.mts +2 -2
- package/dist/devtools.d.mts.map +1 -1
- package/dist/devtools.mjs +2 -2
- package/dist/devtools.mjs.map +1 -1
- package/dist/{hooks-BwY7rRHg.mjs → ecs-3kimUV5Z.mjs} +238 -74
- package/dist/ecs-3kimUV5Z.mjs.map +1 -0
- package/dist/{hooks-DHShH86C.cjs → ecs-B4QrqfvQ.cjs} +320 -108
- package/dist/ecs-B4QrqfvQ.cjs.map +1 -0
- package/dist/hooks-CtP02JNt.cjs +3762 -0
- package/dist/hooks-CtP02JNt.cjs.map +1 -0
- package/dist/hooks-gsQDDE56.mjs +3494 -0
- package/dist/hooks-gsQDDE56.mjs.map +1 -0
- package/dist/index-3GY7T8JM.d.mts +480 -0
- package/dist/index-3GY7T8JM.d.mts.map +1 -0
- package/dist/index-B7B1tRPl.d.cts +480 -0
- package/dist/index-B7B1tRPl.d.cts.map +1 -0
- package/dist/index-DSdbSQ_t.d.cts +1451 -0
- package/dist/index-DSdbSQ_t.d.cts.map +1 -0
- package/dist/index-Dj9odADH.d.mts +1451 -0
- package/dist/index-Dj9odADH.d.mts.map +1 -0
- package/dist/index.cjs +3901 -643
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +315 -138
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +315 -138
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3803 -571
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/dist/SelectionRenderer-CR2PBQwx.d.cts +0 -105
- package/dist/SelectionRenderer-CR2PBQwx.d.cts.map +0 -1
- package/dist/SelectionRenderer-DlsBstAq.d.mts +0 -105
- package/dist/SelectionRenderer-DlsBstAq.d.mts.map +0 -1
- package/dist/WebGLWidgetLayer-BBMuwzHq.cjs +0 -3560
- package/dist/WebGLWidgetLayer-BBMuwzHq.cjs.map +0 -1
- package/dist/WebGLWidgetLayer-C3p1tnpm.mjs +0 -3375
- package/dist/WebGLWidgetLayer-C3p1tnpm.mjs.map +0 -1
- package/dist/engine-BfbvWXSk.d.mts +0 -982
- package/dist/engine-BfbvWXSk.d.mts.map +0 -1
- package/dist/engine-CCjuFMC-.d.cts +0 -982
- package/dist/engine-CCjuFMC-.d.cts.map +0 -1
- package/dist/hooks-BwY7rRHg.mjs.map +0 -1
- package/dist/hooks-DHShH86C.cjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,2215 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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/
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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/
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
168
|
-
*
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
215
|
-
*
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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/
|
|
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
|
-
*
|
|
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
|
|
346
|
-
const
|
|
347
|
-
const
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
390
|
-
if (
|
|
3627
|
+
const manager = webglManagerRef.current;
|
|
3628
|
+
if (!manager) return;
|
|
3629
|
+
if (grid !== false) {
|
|
391
3630
|
const isDark = document.documentElement.classList.contains("dark");
|
|
392
|
-
|
|
3631
|
+
manager.setGridConfig({
|
|
393
3632
|
dotColor: isDark ? [
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
3633
|
+
.35,
|
|
3634
|
+
.37,
|
|
3635
|
+
.4
|
|
397
3636
|
] : [
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
3637
|
+
.75,
|
|
3638
|
+
.77,
|
|
3639
|
+
.8
|
|
401
3640
|
],
|
|
402
|
-
dotAlpha:
|
|
3641
|
+
dotAlpha: 1,
|
|
403
3642
|
...grid
|
|
404
3643
|
});
|
|
405
3644
|
}
|
|
406
|
-
|
|
407
|
-
if (
|
|
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 (
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
3703
|
+
for (const id of selectedIds) {
|
|
688
3704
|
if (!engine.has(id, SelectionFrame)) continue;
|
|
689
|
-
const
|
|
690
|
-
if (
|
|
691
|
-
x:
|
|
692
|
-
y:
|
|
693
|
-
width:
|
|
694
|
-
height:
|
|
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
|
|
701
|
-
if (
|
|
702
|
-
x:
|
|
703
|
-
y:
|
|
704
|
-
width:
|
|
705
|
-
height:
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
732
|
-
if (!
|
|
733
|
-
el.style.transform = `translate(${
|
|
734
|
-
el.style.width = `${
|
|
735
|
-
el.style.height = `${
|
|
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
|
-
|
|
758
|
-
if (
|
|
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.
|
|
763
|
-
el.style.width = `${v.
|
|
764
|
-
el.style.height = `${v.
|
|
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
|
|
777
|
-
if (!
|
|
778
|
-
el.style.transform = `translate(${
|
|
779
|
-
el.style.width = `${
|
|
780
|
-
el.style.height = `${
|
|
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 {
|
|
784
|
-
const
|
|
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)
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
|
839
|
-
function
|
|
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(
|
|
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,
|
|
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
|