@flighthq/interaction 0.2.0 → 0.2.1-next.410.a23f189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/cursorBackend.d.ts +12 -0
  2. package/dist/cursorBackend.d.ts.map +1 -0
  3. package/dist/cursorBackend.js +17 -0
  4. package/dist/cursorBackend.js.map +1 -0
  5. package/dist/displayHitTests.d.ts +10 -11
  6. package/dist/displayHitTests.d.ts.map +1 -1
  7. package/dist/displayHitTests.js +12 -14
  8. package/dist/displayHitTests.js.map +1 -1
  9. package/dist/enableInteractionGuards.d.ts +23 -0
  10. package/dist/enableInteractionGuards.d.ts.map +1 -0
  11. package/dist/enableInteractionGuards.js +75 -0
  12. package/dist/enableInteractionGuards.js.map +1 -0
  13. package/dist/focusManager.d.ts +46 -0
  14. package/dist/focusManager.d.ts.map +1 -0
  15. package/dist/focusManager.js +243 -0
  16. package/dist/focusManager.js.map +1 -0
  17. package/dist/hitTests.d.ts +45 -24
  18. package/dist/hitTests.d.ts.map +1 -1
  19. package/dist/hitTests.js +164 -47
  20. package/dist/hitTests.js.map +1 -1
  21. package/dist/index.d.ts +9 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +9 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/interactionManager.d.ts +10 -2
  26. package/dist/interactionManager.d.ts.map +1 -1
  27. package/dist/interactionManager.js +58 -6
  28. package/dist/interactionManager.js.map +1 -1
  29. package/dist/interactionSpatialIndex.d.ts +17 -0
  30. package/dist/interactionSpatialIndex.d.ts.map +1 -0
  31. package/dist/interactionSpatialIndex.js +77 -0
  32. package/dist/interactionSpatialIndex.js.map +1 -0
  33. package/dist/nodeInteractionState.d.ts +39 -0
  34. package/dist/nodeInteractionState.d.ts.map +1 -0
  35. package/dist/nodeInteractionState.js +72 -0
  36. package/dist/nodeInteractionState.js.map +1 -0
  37. package/dist/registerBitmapHitTest.d.ts +11 -0
  38. package/dist/registerBitmapHitTest.d.ts.map +1 -0
  39. package/dist/registerBitmapHitTest.js +55 -0
  40. package/dist/registerBitmapHitTest.js.map +1 -0
  41. package/dist/registerDefaultHitTests.d.ts +2 -0
  42. package/dist/registerDefaultHitTests.d.ts.map +1 -0
  43. package/dist/registerDefaultHitTests.js +20 -0
  44. package/dist/registerDefaultHitTests.js.map +1 -0
  45. package/dist/registerShapeHitTest.d.ts +10 -0
  46. package/dist/registerShapeHitTest.d.ts.map +1 -0
  47. package/dist/registerShapeHitTest.js +32 -0
  48. package/dist/registerShapeHitTest.js.map +1 -0
  49. package/dist/registerTextHitTest.d.ts +12 -0
  50. package/dist/registerTextHitTest.d.ts.map +1 -0
  51. package/dist/registerTextHitTest.js +32 -0
  52. package/dist/registerTextHitTest.js.map +1 -0
  53. package/dist/spriteHitTests.d.ts +3 -3
  54. package/dist/spriteHitTests.d.ts.map +1 -1
  55. package/dist/spriteHitTests.js +5 -5
  56. package/dist/spriteHitTests.js.map +1 -1
  57. package/package.json +13 -7
  58. package/src/cursorBackend.test.ts +24 -0
  59. package/src/displayHitTests.test.ts +39 -47
  60. package/src/enableInteractionGuards.test.ts +102 -0
  61. package/src/focusManager.test.ts +307 -0
  62. package/src/hitTests.test.ts +235 -98
  63. package/src/interactionManager.test.ts +59 -8
  64. package/src/interactionSpatialIndex.test.ts +103 -0
  65. package/src/nodeInteractionState.test.ts +141 -0
  66. package/src/registerBitmapHitTest.test.ts +22 -0
  67. package/src/{registerDefaultHitTestPoints.test.ts → registerDefaultHitTests.test.ts} +10 -7
  68. package/src/registerShapeHitTest.test.ts +27 -0
  69. package/src/registerTextHitTest.test.ts +54 -0
  70. package/src/spriteHitTests.test.ts +15 -15
  71. package/dist/registerDefaultHitTestPoints.d.ts +0 -2
  72. package/dist/registerDefaultHitTestPoints.d.ts.map +0 -1
  73. package/dist/registerDefaultHitTestPoints.js +0 -21
  74. package/dist/registerDefaultHitTestPoints.js.map +0 -1
@@ -1,32 +1,186 @@
1
1
  import { createDisplayObject, createDisplayObjectGeneric, getDisplayObjectRuntime } from '@flighthq/displayobject';
2
- import { setRectangle } from '@flighthq/geometry';
2
+ import { createRectangle, setRectangle } from '@flighthq/geometry';
3
3
  import { addNodeChild, getNodeLocalBoundsRectangle, invalidateNodeLocalTransform } from '@flighthq/node';
4
- import type { DisplayObject, DisplayObjectRuntime } from '@flighthq/types';
4
+ import { appendPathRectangle, createPath } from '@flighthq/path';
5
+ import type { DisplayObject, DisplayObjectRuntime, HitTestResult, NodeAny } from '@flighthq/types';
5
6
  import { DisplayObjectKind } from '@flighthq/types';
6
7
 
7
8
  import {
9
+ describeGraphHit,
8
10
  findGraphHitTarget,
11
+ findGraphHitTargetPrecise,
12
+ findGraphHitTargets,
13
+ findGraphHitTargetsPrecise,
9
14
  hitTestDisplayObjects,
10
15
  hitTestGraphLocalBounds,
11
16
  hitTestGraphPoint,
12
- registerHitTestPoint,
17
+ hitTestGraphPointPrecise,
18
+ hitTestNodeRegion,
19
+ registerHitTest,
20
+ registerHitTestPrecise,
13
21
  } from './hitTests';
22
+ import { setNodeHitArea, setNodeHitTestEnabled } from './nodeInteractionState';
23
+
24
+ // A precise provider over local bounds: 0 (hit) inside, -1 outside — the boolean-precise shape.
25
+ function boundsPrecise(source: NodeAny, x: number, y: number): number {
26
+ return hitTestGraphLocalBounds(source, x, y) ? 0 : -1;
27
+ }
28
+
29
+ function boundsObject(w: number, h: number): DisplayObject {
30
+ const obj = createDisplayObject();
31
+ setRectangle(getNodeLocalBoundsRectangle(obj), 0, 0, w, h);
32
+ setNodeHitTestEnabled(obj, true);
33
+ return obj;
34
+ }
35
+
36
+ function emptyResult(node: DisplayObject): HitTestResult {
37
+ return { localX: 0, localY: 0, node, subIndex: -2 };
38
+ }
39
+
40
+ beforeAll(() => {
41
+ registerHitTest(DisplayObjectKind, hitTestGraphLocalBounds);
42
+ });
43
+
44
+ describe('describeGraphHit', () => {
45
+ it('fills node, local coordinates, and the sub-index from the kind exact provider', () => {
46
+ const kind = 'DescribeKind';
47
+ const node = createDisplayObjectGeneric(kind);
48
+ node.x = 40;
49
+ node.y = 40;
50
+ invalidateNodeLocalTransform(node);
51
+ setRectangle(getNodeLocalBoundsRectangle(node), 0, 0, 100, 100);
52
+ registerHitTestPrecise(kind, () => 7);
53
+ const out = emptyResult(node);
54
+ describeGraphHit(node, 50, 50, out);
55
+ expect(out.node).toBe(node);
56
+ expect(out.localX).toBe(10);
57
+ expect(out.localY).toBe(10);
58
+ expect(out.subIndex).toBe(7);
59
+ });
60
+
61
+ it('reports subIndex -1 when the kind has no exact provider', () => {
62
+ const obj = boundsObject(100, 100);
63
+ const out = emptyResult(obj);
64
+ describeGraphHit(obj, 50, 50, out);
65
+ expect(out.subIndex).toBe(-1);
66
+ });
67
+ });
14
68
 
15
69
  describe('findGraphHitTarget', () => {
16
70
  it('returns null when node is disabled', () => {
17
- const obj = createDisplayObject();
71
+ const obj = boundsObject(100, 100);
18
72
  obj.enabled = false;
19
73
  expect(findGraphHitTarget(obj, 50, 50)).toBeNull();
20
74
  });
21
75
 
22
- it('returns a child node registered with a hit handler', () => {
76
+ it('is not a candidate until it opts in, then is', () => {
77
+ const obj = createDisplayObject();
78
+ setRectangle(getNodeLocalBoundsRectangle(obj), 0, 0, 100, 100);
79
+ expect(findGraphHitTarget(obj, 50, 50)).toBeNull();
80
+ setNodeHitTestEnabled(obj, true);
81
+ expect(findGraphHitTarget(obj, 50, 50)).toBe(obj);
82
+ setNodeHitTestEnabled(obj, false);
83
+ expect(findGraphHitTarget(obj, 50, 50)).toBeNull();
84
+ });
85
+
86
+ it('descends into an un-opted-in parent to reach an opted-in child', () => {
23
87
  const parent = createDisplayObject();
24
- const child = createDisplayObjectGeneric(DisplayObjectKind);
25
- setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
88
+ const child = boundsObject(100, 100);
89
+ addNodeChild(parent, child);
90
+ expect(findGraphHitTarget(parent, 50, 50)).toBe(child);
91
+ });
92
+
93
+ it('treats a hitArea node as an atomic unit that consumes and hides its children', () => {
94
+ const parent = boundsObject(200, 200);
95
+ const child = boundsObject(50, 50);
26
96
  addNodeChild(parent, child);
27
- registerHitTestPoint(DisplayObjectKind, hitTestGraphLocalBounds);
28
- const hit = findGraphHitTarget(parent, 50, 50);
29
- expect(hit).toBe(child);
97
+ setNodeHitArea(parent, 'bounds');
98
+ expect(findGraphHitTarget(parent, 25, 25)).toBe(parent);
99
+ });
100
+
101
+ it('resolves a rectangle hitArea in the node local space, through the world transform', () => {
102
+ const obj = createDisplayObject();
103
+ obj.x = 40;
104
+ obj.y = 40;
105
+ invalidateNodeLocalTransform(obj);
106
+ setNodeHitTestEnabled(obj, true);
107
+ setNodeHitArea(obj, createRectangle(0, 0, 100, 100));
108
+ expect(findGraphHitTarget(obj, 50, 50)).toBe(obj);
109
+ expect(findGraphHitTarget(obj, 20, 20)).toBeNull();
110
+ });
111
+
112
+ it('resolves a path hitArea by winding, and a node proxy in the proxy world space', () => {
113
+ const pathObj = createDisplayObject();
114
+ setNodeHitTestEnabled(pathObj, true);
115
+ const path = createPath();
116
+ appendPathRectangle(path, 0, 0, 30, 30);
117
+ setNodeHitArea(pathObj, path);
118
+ expect(findGraphHitTarget(pathObj, 10, 10)).toBe(pathObj);
119
+ expect(findGraphHitTarget(pathObj, 40, 40)).toBeNull();
120
+
121
+ const proxyOwner = createDisplayObject();
122
+ const proxy = createDisplayObjectGeneric(DisplayObjectKind);
123
+ setRectangle(getNodeLocalBoundsRectangle(proxy), 0, 0, 20, 20);
124
+ setNodeHitTestEnabled(proxyOwner, true);
125
+ setNodeHitArea(proxyOwner, proxy);
126
+ expect(findGraphHitTarget(proxyOwner, 5, 5)).toBe(proxyOwner);
127
+ expect(findGraphHitTarget(proxyOwner, 50, 50)).toBeNull();
128
+ });
129
+ });
130
+
131
+ describe('findGraphHitTargetPrecise', () => {
132
+ it('uses the kind exact provider, so a bbox hit off the real geometry misses', () => {
133
+ const kind = 'PreciseFirstKind';
134
+ const node = createDisplayObjectGeneric(kind);
135
+ setRectangle(getNodeLocalBoundsRectangle(node), 0, 0, 100, 100);
136
+ setNodeHitTestEnabled(node, true);
137
+ // Exact provider only accepts the left half.
138
+ registerHitTestPrecise(kind, (s, x, y) => (hitTestGraphLocalBounds(s, x, y) && x < 50 ? 0 : -1));
139
+ registerHitTest(kind, hitTestGraphLocalBounds);
140
+ expect(findGraphHitTargetPrecise(node, 25, 50)).toBe(node);
141
+ expect(findGraphHitTargetPrecise(node, 75, 50)).toBeNull();
142
+ // Coarse still hits the whole box.
143
+ expect(findGraphHitTarget(node, 75, 50)).toBe(node);
144
+ });
145
+
146
+ it('falls back to the coarse bounds handler when no exact provider is registered', () => {
147
+ const obj = boundsObject(100, 100);
148
+ expect(findGraphHitTargetPrecise(obj, 50, 50)).toBe(obj);
149
+ });
150
+ });
151
+
152
+ describe('findGraphHitTargets', () => {
153
+ it('collects every hit under the point, front-to-back', () => {
154
+ const root = createDisplayObject();
155
+ const under = boundsObject(100, 100);
156
+ const over = boundsObject(100, 100);
157
+ addNodeChild(root, under);
158
+ addNodeChild(root, over);
159
+ const stack = findGraphHitTargets(root, 50, 50);
160
+ expect(stack).toEqual([over, under]);
161
+ });
162
+
163
+ it('clears the out array and returns it', () => {
164
+ const obj = boundsObject(100, 100);
165
+ const out: DisplayObject[] = [createDisplayObject()];
166
+ const result = findGraphHitTargets(obj, 50, 50, out);
167
+ expect(result).toBe(out);
168
+ expect(out).toEqual([obj]);
169
+ });
170
+ });
171
+
172
+ describe('findGraphHitTargetsPrecise', () => {
173
+ it('collects hits using exact geometry per kind', () => {
174
+ const kind = 'PreciseStackKind';
175
+ registerHitTest(kind, hitTestGraphLocalBounds);
176
+ registerHitTestPrecise(kind, boundsPrecise);
177
+ const root = createDisplayObject();
178
+ const a = createDisplayObjectGeneric(kind);
179
+ setRectangle(getNodeLocalBoundsRectangle(a), 0, 0, 100, 100);
180
+ setNodeHitTestEnabled(a, true);
181
+ addNodeChild(root, a);
182
+ expect(findGraphHitTargetsPrecise(root, 50, 50)).toEqual([a]);
183
+ expect(findGraphHitTargetsPrecise(root, 500, 500)).toEqual([]);
30
184
  });
31
185
  });
32
186
 
@@ -37,53 +191,32 @@ describe('hitTestDisplayObjects', () => {
37
191
  beforeEach(() => {
38
192
  a = createDisplayObject();
39
193
  b = createDisplayObject();
40
-
41
194
  addNodeChild(createDisplayObject(), a);
42
195
  addNodeChild(createDisplayObject(), b);
43
-
44
196
  setRectangle(getNodeLocalBoundsRectangle(a), 0, 0, 10, 10);
45
197
  setRectangle(getNodeLocalBoundsRectangle(b), 0, 0, 10, 10);
46
-
47
198
  a.x = 0;
48
199
  a.y = 0;
49
200
  b.x = 5;
50
201
  b.y = 5;
51
-
52
202
  a.scaleX = a.scaleY = 1;
53
203
  b.scaleX = b.scaleY = 1;
54
204
  });
55
205
 
56
206
  it('returns true when bounds intersect', () => {
57
- const result = hitTestDisplayObjects(a, b);
58
- expect(result).toBe(true);
207
+ expect(hitTestDisplayObjects(a, b)).toBe(true);
59
208
  });
60
209
 
61
210
  it('returns false when bounds do not intersect', () => {
62
211
  b.x = 20;
63
212
  b.y = 20;
64
213
  invalidateNodeLocalTransform(b);
65
-
66
- const result = hitTestDisplayObjects(a, b);
67
- expect(result).toBe(false);
214
+ expect(hitTestDisplayObjects(a, b)).toBe(false);
68
215
  });
69
216
 
70
217
  it('returns false if either object has no parent', () => {
71
218
  (getDisplayObjectRuntime(b) as DisplayObjectRuntime).parent = null;
72
-
73
- const result = hitTestDisplayObjects(a, b);
74
- expect(result).toBe(false);
75
- });
76
-
77
- it('compares bounds in world space', () => {
78
- a.scaleX = 1;
79
- a.scaleY = 1;
80
-
81
- b.x = 5;
82
- b.y = 5;
83
- invalidateNodeLocalTransform(b);
84
-
85
- const result = hitTestDisplayObjects(a, b);
86
- expect(result).toBe(true);
219
+ expect(hitTestDisplayObjects(a, b)).toBe(false);
87
220
  });
88
221
 
89
222
  it('includes child bounds when a child extends beyond the object local bounds', () => {
@@ -93,25 +226,18 @@ describe('hitTestDisplayObjects', () => {
93
226
  invalidateNodeLocalTransform(child);
94
227
  setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 20, 20);
95
228
  addNodeChild(a, child);
96
-
97
229
  b.x = 100;
98
230
  b.y = 100;
99
231
  invalidateNodeLocalTransform(b);
100
-
101
232
  expect(hitTestDisplayObjects(a, b)).toBe(true);
102
233
  });
103
234
  });
104
235
 
105
236
  describe('hitTestGraphLocalBounds', () => {
106
- it('returns true when world-space point is inside local bounds', () => {
237
+ it('returns whether a world-space point is inside local bounds', () => {
107
238
  const obj = createDisplayObject();
108
239
  setRectangle(getNodeLocalBoundsRectangle(obj), 0, 0, 100, 100);
109
240
  expect(hitTestGraphLocalBounds(obj, 50, 50)).toBe(true);
110
- });
111
-
112
- it('returns false when world-space point is outside local bounds', () => {
113
- const obj = createDisplayObject();
114
- setRectangle(getNodeLocalBoundsRectangle(obj), 0, 0, 100, 100);
115
241
  expect(hitTestGraphLocalBounds(obj, 200, 200)).toBe(false);
116
242
  });
117
243
  });
@@ -119,88 +245,99 @@ describe('hitTestGraphLocalBounds', () => {
119
245
  describe('hitTestGraphPoint', () => {
120
246
  let obj: DisplayObject;
121
247
 
122
- beforeAll(() => {
123
- registerHitTestPoint(DisplayObjectKind, hitTestGraphLocalBounds);
124
- });
125
-
126
248
  beforeEach(() => {
127
- obj = createDisplayObject();
128
- setRectangle(getNodeLocalBoundsRectangle(obj), 0, 0, 100, 100);
249
+ obj = boundsObject(100, 100);
129
250
  });
130
251
 
131
- it('returns true for point inside bounds', () => {
132
- const result = hitTestGraphPoint(obj, 50, 50);
133
- expect(result).toBe(true);
252
+ it('returns true for point inside bounds, false outside', () => {
253
+ expect(hitTestGraphPoint(obj, 50, 50)).toBe(true);
254
+ expect(hitTestGraphPoint(obj, 200, 200)).toBe(false);
134
255
  });
135
256
 
136
- it('returns false for point outside bounds', () => {
137
- const result = hitTestGraphPoint(obj, 200, 200);
138
- expect(result).toBe(false);
257
+ it('returns false for a node that has not opted in', () => {
258
+ const other = createDisplayObject();
259
+ setRectangle(getNodeLocalBoundsRectangle(other), 0, 0, 100, 100);
260
+ expect(hitTestGraphPoint(other, 50, 50)).toBe(false);
139
261
  });
140
262
 
141
263
  it('returns false if object is not enabled', () => {
142
264
  obj.enabled = false;
143
- const result = hitTestGraphPoint(obj, 50, 50);
144
- expect(result).toBe(false);
265
+ expect(hitTestGraphPoint(obj, 50, 50)).toBe(false);
145
266
  });
146
267
 
147
- it('returns false when point is outside bounds', () => {
148
- const result = hitTestGraphPoint(obj, 200, 200);
149
- expect(result).toBe(false);
268
+ it('returns true when an opted-in child is hit even if the parent is inert', () => {
269
+ const parent = createDisplayObject();
270
+ const child = boundsObject(100, 100);
271
+ addNodeChild(parent, child);
272
+ expect(hitTestGraphPoint(parent, 50, 50)).toBe(true);
150
273
  });
151
274
 
152
- it('respects world transform', () => {
153
- obj.x = 100;
154
- obj.y = 100;
155
- invalidateNodeLocalTransform(obj);
156
- const inside = hitTestGraphPoint(obj, 150, 150);
157
- const outside = hitTestGraphPoint(obj, 50, 50);
158
-
159
- expect(inside).toBe(true);
160
- expect(outside).toBe(false);
275
+ it('does not test children of a disabled parent', () => {
276
+ const parent = createDisplayObject();
277
+ parent.enabled = false;
278
+ const child = boundsObject(100, 100);
279
+ addNodeChild(parent, child);
280
+ expect(hitTestGraphPoint(parent, 50, 50)).toBe(false);
161
281
  });
282
+ });
162
283
 
163
- it('works with the default shapeFlag param', () => {
164
- const result = hitTestGraphPoint(obj, 50, 50);
165
- expect(result).toBe(true);
166
-
167
- const resultExplicit = hitTestGraphPoint(obj, 50, 50, true);
168
- expect(resultExplicit).toBe(true);
284
+ describe('hitTestGraphPointPrecise', () => {
285
+ it('uses exact geometry, so a bbox hit off the real geometry misses', () => {
286
+ const kind = 'PreciseAnyKind';
287
+ const node = createDisplayObjectGeneric(kind);
288
+ setRectangle(getNodeLocalBoundsRectangle(node), 0, 0, 100, 100);
289
+ setNodeHitTestEnabled(node, true);
290
+ registerHitTestPrecise(kind, (s, x, y) => (hitTestGraphLocalBounds(s, x, y) && x < 50 ? 0 : -1));
291
+ expect(hitTestGraphPointPrecise(node, 25, 50)).toBe(true);
292
+ expect(hitTestGraphPointPrecise(node, 75, 50)).toBe(false);
169
293
  });
294
+ });
170
295
 
171
- it('returns true when a child is hit even if the parent has no local bounds', () => {
172
- const child = createDisplayObject();
173
- setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
174
- addNodeChild(obj, child);
175
-
176
- setRectangle(getNodeLocalBoundsRectangle(obj), 0, 0, 0, 0);
177
- expect(hitTestGraphPoint(obj, 50, 50)).toBe(true);
296
+ describe('hitTestNodeRegion', () => {
297
+ it('tests a node kind geometry, ignoring eligibility and children', () => {
298
+ const obj = createDisplayObject();
299
+ setRectangle(getNodeLocalBoundsRectangle(obj), 0, 0, 100, 100);
300
+ expect(hitTestNodeRegion(obj, 50, 50)).toBe(true);
301
+ expect(hitTestNodeRegion(obj, 200, 200)).toBe(false);
178
302
  });
179
303
 
180
- it('does not test children of a disabled parent', () => {
181
- obj.enabled = false;
182
-
183
- const child = createDisplayObject();
184
- setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
185
- addNodeChild(obj, child);
186
-
187
- expect(hitTestGraphPoint(obj, 50, 50)).toBe(false);
304
+ it('uses the hitArea when one is set', () => {
305
+ const obj = createDisplayObject();
306
+ setNodeHitArea(obj, createRectangle(0, 0, 30, 30));
307
+ expect(hitTestNodeRegion(obj, 10, 10)).toBe(true);
308
+ expect(hitTestNodeRegion(obj, 40, 40)).toBe(false);
188
309
  });
189
310
 
190
- it('uses a registered handler for a custom kind', () => {
191
- const CustomKind = 'CustomKind';
192
- registerHitTestPoint(CustomKind, () => true);
193
- const custom = createDisplayObjectGeneric(CustomKind);
194
-
195
- expect(hitTestGraphPoint(custom, 50, 50)).toBe(true);
311
+ it('uses the exact provider when precise', () => {
312
+ const kind = 'RegionPreciseKind';
313
+ const node = createDisplayObjectGeneric(kind);
314
+ setRectangle(getNodeLocalBoundsRectangle(node), 0, 0, 100, 100);
315
+ registerHitTestPrecise(kind, (s, x, y) => (hitTestGraphLocalBounds(s, x, y) && x < 50 ? 0 : -1));
316
+ expect(hitTestNodeRegion(node, 25, 50, true)).toBe(true);
317
+ expect(hitTestNodeRegion(node, 75, 50, true)).toBe(false);
196
318
  });
197
319
  });
198
320
 
199
- describe('registerHitTestPoint', () => {
200
- it('registers a handler that hitTestGraphPoint will use', () => {
321
+ describe('registerHitTest', () => {
322
+ it('registers a coarse handler that hitTestGraphPoint will use', () => {
201
323
  const kind = 'RegisterTest';
202
- registerHitTestPoint(kind, () => true);
324
+ registerHitTest(kind, () => true);
203
325
  const node = createDisplayObjectGeneric(kind);
326
+ setNodeHitTestEnabled(node, true);
204
327
  expect(hitTestGraphPoint(node, 0, 0)).toBe(true);
205
328
  });
206
329
  });
330
+
331
+ describe('registerHitTestPrecise', () => {
332
+ it('registers an exact provider used by the precise queries and describeGraphHit', () => {
333
+ const kind = 'RegisterPreciseTest';
334
+ registerHitTest(kind, () => true);
335
+ registerHitTestPrecise(kind, () => 3);
336
+ const node = createDisplayObjectGeneric(kind);
337
+ setNodeHitTestEnabled(node, true);
338
+ expect(findGraphHitTargetPrecise(node, 0, 0)).toBe(node);
339
+ const out = emptyResult(node);
340
+ describeGraphHit(node, 0, 0, out);
341
+ expect(out.subIndex).toBe(3);
342
+ });
343
+ });
@@ -2,11 +2,11 @@ import { createDisplayObject } from '@flighthq/displayobject';
2
2
  import { setRectangle } from '@flighthq/geometry';
3
3
  import { addNodeChild, createNode, getNodeLocalBoundsRectangle, invalidateNodeLocalTransform } from '@flighthq/node';
4
4
  import { connectSignal, createSignal, emitSignal } from '@flighthq/signals';
5
- import type { InputKeyboardData, InputPointerData, InputSignals } from '@flighthq/types';
5
+ import type { Cursor, InputKeyboardData, InputPointerData, InputSignals } from '@flighthq/types';
6
6
  import { DisplayObjectKind } from '@flighthq/types';
7
7
 
8
8
  import { hitTestGraphLocalBounds } from './hitTests';
9
- import { registerHitTestPoint } from './hitTests';
9
+ import { registerHitTest } from './hitTests';
10
10
  import {
11
11
  captureInteractionPointer,
12
12
  connectInputToInteraction,
@@ -25,10 +25,12 @@ import {
25
25
  enableInteractionSignals,
26
26
  getInteractionSignals,
27
27
  releaseInteractionPointer,
28
+ setInteractionConnectGuard,
28
29
  } from './interactionManager';
30
+ import { setNodeCursor, setNodeHitTestEnabled } from './nodeInteractionState';
29
31
 
30
32
  beforeAll(() => {
31
- registerHitTestPoint(DisplayObjectKind, hitTestGraphLocalBounds);
33
+ registerHitTest(DisplayObjectKind, hitTestGraphLocalBounds);
32
34
  });
33
35
 
34
36
  describe('captureInteractionPointer', () => {
@@ -76,10 +78,11 @@ describe('connectInteractionSignal', () => {
76
78
  it('can use tracked subscribers without scanning for direct signal connections', () => {
77
79
  const kind = 'TrackedSubscriberHitTest';
78
80
  const root = createNode(kind);
81
+ setNodeHitTestEnabled(root, true);
79
82
  const manager = createInteractionManager(root, { trackedSubscribersOnly: true });
80
83
  let fired = 0;
81
84
  let hitTests = 0;
82
- registerHitTestPoint(kind, () => {
85
+ registerHitTest(kind, () => {
83
86
  hitTests++;
84
87
  return true;
85
88
  });
@@ -98,10 +101,11 @@ describe('connectInteractionSignal', () => {
98
101
  it('clears tracked once subscribers after dispatch', () => {
99
102
  const kind = 'TrackedOnceHitTest';
100
103
  const root = createNode(kind);
104
+ setNodeHitTestEnabled(root, true);
101
105
  const manager = createInteractionManager(root, { trackedSubscribersOnly: true });
102
106
  let fired = 0;
103
107
  let hitTests = 0;
104
- registerHitTestPoint(kind, () => {
108
+ registerHitTest(kind, () => {
105
109
  hitTests++;
106
110
  return true;
107
111
  });
@@ -137,6 +141,13 @@ describe('createInteractionManager', () => {
137
141
  expect(manager.enabled).toBe(true);
138
142
  expect(manager.trackedSubscribersOnly).toBe(true);
139
143
  });
144
+
145
+ it('defaults cursorBackend to null and accepts one via options', () => {
146
+ const root = createDisplayObject();
147
+ expect(createInteractionManager(root).cursorBackend).toBeNull();
148
+ const backend = { setCursor: () => {} };
149
+ expect(createInteractionManager(root, { cursorBackend: backend }).cursorBackend).toBe(backend);
150
+ });
140
151
  });
141
152
 
142
153
  describe('createInteractionSignals', () => {
@@ -168,11 +179,12 @@ describe('disconnectInteractionSignal', () => {
168
179
  it('disconnects a tracked subscriber and removes the dispatch cost', () => {
169
180
  const kind = 'DisconnectTrackedHitTest';
170
181
  const root = createNode(kind);
182
+ setNodeHitTestEnabled(root, true);
171
183
  const manager = createInteractionManager(root, { trackedSubscribersOnly: true });
172
184
  let fired = 0;
173
185
  let hitTests = 0;
174
186
  const slot = () => fired++;
175
- registerHitTestPoint(kind, () => {
187
+ registerHitTest(kind, () => {
176
188
  hitTests++;
177
189
  return true;
178
190
  });
@@ -254,7 +266,7 @@ describe('dispatchInteractionPointerDown', () => {
254
266
  const root = createNode(kind);
255
267
  const manager = createInteractionManager(root);
256
268
  let hitTests = 0;
257
- registerHitTestPoint(kind, () => {
269
+ registerHitTest(kind, () => {
258
270
  hitTests++;
259
271
  return true;
260
272
  });
@@ -268,7 +280,7 @@ describe('dispatchInteractionPointerDown', () => {
268
280
  const root = createNode(kind);
269
281
  const manager = createInteractionManager(root);
270
282
  let hitTests = 0;
271
- registerHitTestPoint(kind, () => {
283
+ registerHitTest(kind, () => {
272
284
  hitTests++;
273
285
  return true;
274
286
  });
@@ -288,6 +300,7 @@ describe('dispatchInteractionPointerDown', () => {
288
300
  const root = createDisplayObject();
289
301
  const child = createDisplayObject();
290
302
  setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
303
+ setNodeHitTestEnabled(child, true);
291
304
  addNodeChild(root, child);
292
305
 
293
306
  const signals = enableInteractionSignals(child);
@@ -303,6 +316,7 @@ describe('dispatchInteractionPointerDown', () => {
303
316
  const root = createDisplayObject();
304
317
  const child = createDisplayObject();
305
318
  setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
319
+ setNodeHitTestEnabled(child, true);
306
320
  addNodeChild(root, child);
307
321
 
308
322
  const signals = enableInteractionSignals(child);
@@ -318,6 +332,7 @@ describe('dispatchInteractionPointerDown', () => {
318
332
  const root = createDisplayObject();
319
333
  const child = createDisplayObject();
320
334
  setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
335
+ setNodeHitTestEnabled(child, true);
321
336
  addNodeChild(root, child);
322
337
 
323
338
  const signals = enableInteractionSignals(child);
@@ -341,6 +356,7 @@ describe('dispatchInteractionPointerDown', () => {
341
356
  child.y = 20;
342
357
  invalidateNodeLocalTransform(child);
343
358
  setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
359
+ setNodeHitTestEnabled(child, true);
344
360
  addNodeChild(root, child);
345
361
 
346
362
  const manager = createInteractionManager(root);
@@ -379,6 +395,7 @@ describe('dispatchInteractionPointerDown', () => {
379
395
  child.y = 7;
380
396
  invalidateNodeLocalTransform(child);
381
397
  setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
398
+ setNodeHitTestEnabled(child, true);
382
399
  addNodeChild(root, parent);
383
400
  addNodeChild(parent, child);
384
401
 
@@ -422,6 +439,23 @@ describe('dispatchInteractionPointerMove', () => {
422
439
  expect(fired).toBe(1);
423
440
  });
424
441
 
442
+ it('applies the rollover target cursor and clears it on exit — even with no move subscribers', () => {
443
+ const root = createDisplayObject();
444
+ const child = createDisplayObject();
445
+ setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
446
+ setNodeHitTestEnabled(child, true);
447
+ addNodeChild(root, child);
448
+ const applied: (Cursor | null)[] = [];
449
+ const manager = createInteractionManager(root, { cursorBackend: { setCursor: (c) => applied.push(c) } });
450
+ setNodeCursor(child, 'pointer');
451
+
452
+ dispatchInteractionPointerMove(manager, 50, 50);
453
+ expect(applied.at(-1)).toBe('pointer');
454
+
455
+ dispatchInteractionPointerMove(manager, 500, 500);
456
+ expect(applied.at(-1)).toBeNull();
457
+ });
458
+
425
459
  it('fires over and roll over when entering a target', () => {
426
460
  const { child, manager } = createHitScene();
427
461
  const order: string[] = [];
@@ -562,10 +596,27 @@ describe('releaseInteractionPointer', () => {
562
596
  });
563
597
  });
564
598
 
599
+ describe('setInteractionConnectGuard', () => {
600
+ it('invokes the installed guard when a signal is connected, and stops after null', () => {
601
+ const root = createDisplayObject();
602
+ const manager = createInteractionManager(root);
603
+ const seen: string[] = [];
604
+ setInteractionConnectGuard((target, name) => {
605
+ void target;
606
+ seen.push(name);
607
+ });
608
+ connectInteractionSignal(manager, root, 'onPointerDown', () => {});
609
+ setInteractionConnectGuard(null);
610
+ connectInteractionSignal(manager, root, 'onPointerUp', () => {});
611
+ expect(seen).toEqual(['onPointerDown']);
612
+ });
613
+ });
614
+
565
615
  function createHitScene() {
566
616
  const root = createDisplayObject();
567
617
  const child = createDisplayObject();
568
618
  setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 100, 100);
619
+ setNodeHitTestEnabled(child, true);
569
620
  addNodeChild(root, child);
570
621
  return { child, manager: createInteractionManager(root), root };
571
622
  }