@hyperframes/studio 0.6.73 → 0.6.74

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 (60) hide show
  1. package/dist/assets/index-BcJO6Ej5.js +140 -0
  2. package/dist/assets/index-C2gBZ2km.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/PropertyPanel.tsx +293 -140
  16. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  17. package/src/components/editor/SnapToolbar.tsx +163 -0
  18. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  19. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  20. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  22. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  23. package/src/components/editor/manualEditingAvailability.ts +6 -0
  24. package/src/components/editor/manualEditsDom.ts +56 -2
  25. package/src/components/editor/manualOffsetDrag.ts +19 -3
  26. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  27. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  28. package/src/components/editor/snapEngine.test.ts +657 -0
  29. package/src/components/editor/snapEngine.ts +575 -0
  30. package/src/components/editor/snapTargetCollection.ts +147 -0
  31. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  32. package/src/components/nle/NLELayout.tsx +18 -0
  33. package/src/contexts/DomEditContext.tsx +24 -0
  34. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  37. package/src/hooks/useAppHotkeys.ts +63 -1
  38. package/src/hooks/useDomEditCommits.ts +39 -4
  39. package/src/hooks/useDomEditSession.ts +177 -63
  40. package/src/hooks/useGsapScriptCommits.ts +144 -7
  41. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  42. package/src/hooks/useGsapTweenCache.ts +174 -3
  43. package/src/hooks/useTimelineEditing.ts +93 -0
  44. package/src/icons/SystemIcons.tsx +2 -0
  45. package/src/player/components/ClipContextMenu.tsx +99 -0
  46. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  47. package/src/player/components/Timeline.test.ts +2 -1
  48. package/src/player/components/Timeline.tsx +108 -68
  49. package/src/player/components/TimelineCanvas.tsx +47 -1
  50. package/src/player/components/TimelineClip.tsx +8 -3
  51. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  52. package/src/player/components/timelineDragDrop.ts +103 -0
  53. package/src/player/components/timelineLayout.ts +1 -1
  54. package/src/player/store/playerStore.ts +42 -0
  55. package/src/utils/editHistory.ts +1 -1
  56. package/src/utils/optimisticUpdate.test.ts +53 -0
  57. package/src/utils/optimisticUpdate.ts +18 -0
  58. package/src/utils/studioUiPreferences.ts +17 -0
  59. package/dist/assets/index-CrxThtSJ.css +0 -1
  60. package/dist/assets/index-Dc2HfqON.js +0 -140
@@ -0,0 +1,657 @@
1
+ // fallow-ignore-file code-duplication
2
+ import { describe, test, expect } from "vitest";
3
+ import {
4
+ extractSnapTargets,
5
+ buildCompositionSnapTarget,
6
+ buildGridSnapEdges,
7
+ resolveSnapAdjustment,
8
+ resolveResizeSnapAdjustment,
9
+ resolveEquidistanceGuides,
10
+ SNAP_THRESHOLD_PX,
11
+ type SnapTarget,
12
+ } from "./snapEngine";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function rect(left: number, top: number, width: number, height: number) {
19
+ return { left, top, width, height };
20
+ }
21
+
22
+ function target(id: string, left: number, top: number, width: number, height: number): SnapTarget {
23
+ return {
24
+ left,
25
+ top,
26
+ right: left + width,
27
+ bottom: top + height,
28
+ centerX: left + width / 2,
29
+ centerY: top + height / 2,
30
+ id,
31
+ };
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // extractSnapTargets
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("extractSnapTargets", () => {
39
+ test("computes right, bottom, centerX, centerY", () => {
40
+ const [t] = extractSnapTargets([{ rect: rect(10, 20, 100, 50), id: "a" }]);
41
+ expect(t.left).toBe(10);
42
+ expect(t.top).toBe(20);
43
+ expect(t.right).toBe(110);
44
+ expect(t.bottom).toBe(70);
45
+ expect(t.centerX).toBe(60);
46
+ expect(t.centerY).toBe(45);
47
+ expect(t.id).toBe("a");
48
+ });
49
+
50
+ test("handles multiple rects", () => {
51
+ const targets = extractSnapTargets([
52
+ { rect: rect(0, 0, 10, 10), id: "x" },
53
+ { rect: rect(50, 50, 20, 30), id: "y" },
54
+ ]);
55
+ expect(targets).toHaveLength(2);
56
+ expect(targets[1].right).toBe(70);
57
+ expect(targets[1].bottom).toBe(80);
58
+ });
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // buildCompositionSnapTarget
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe("buildCompositionSnapTarget", () => {
66
+ test("has id 'composition' and correct edges", () => {
67
+ const t = buildCompositionSnapTarget(rect(0, 0, 1920, 1080));
68
+ expect(t.id).toBe("composition");
69
+ expect(t.left).toBe(0);
70
+ expect(t.right).toBe(1920);
71
+ expect(t.centerX).toBe(960);
72
+ expect(t.centerY).toBe(540);
73
+ });
74
+ });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // buildGridSnapEdges
78
+ // ---------------------------------------------------------------------------
79
+
80
+ describe("buildGridSnapEdges", () => {
81
+ test("generates correct grid lines", () => {
82
+ const { x, y } = buildGridSnapEdges(rect(0, 0, 300, 200), 100, 1);
83
+ // At scale=1, step=100: x lines at 100, 200 (not 0 or 300)
84
+ expect(x.map((e) => e.position)).toEqual([100, 200]);
85
+ expect(y.map((e) => e.position)).toEqual([100]);
86
+ expect(x[0].source).toBe("grid");
87
+ });
88
+
89
+ test("applies scale to grid spacing", () => {
90
+ const { x } = buildGridSnapEdges(rect(0, 0, 600, 100), 100, 2);
91
+ // step = 200, lines at 200, 400
92
+ expect(x.map((e) => e.position)).toEqual([200, 400]);
93
+ });
94
+
95
+ test("handles offset composition rect", () => {
96
+ const { x } = buildGridSnapEdges(rect(50, 0, 300, 100), 100, 1);
97
+ // Lines at 150, 250 (offset + step, offset + 2*step)
98
+ expect(x.map((e) => e.position)).toEqual([150, 250]);
99
+ });
100
+
101
+ test("returns empty for zero gridSpacing", () => {
102
+ const { x, y } = buildGridSnapEdges(rect(0, 0, 300, 200), 0, 1);
103
+ expect(x).toHaveLength(0);
104
+ expect(y).toHaveLength(0);
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // resolveSnapAdjustment — edge alignment
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe("resolveSnapAdjustment", () => {
113
+ const compositionTarget = target("composition", 0, 0, 1000, 800);
114
+
115
+ test("left-to-right alignment: moving left snaps to target right", () => {
116
+ // Target at x=200, width=100 => right edge at 300
117
+ // Moving rect at x=0, width=50. Propose dx=297 => proposed left=297
118
+ // Should snap left=300 (delta +3)
119
+ const t = target("a", 200, 100, 100, 100);
120
+ const result = resolveSnapAdjustment({
121
+ movingRect: rect(0, 100, 50, 50),
122
+ proposedDx: 297,
123
+ proposedDy: 0,
124
+ targets: [t],
125
+ threshold: SNAP_THRESHOLD_PX,
126
+ disabled: false,
127
+ });
128
+ expect(result.dx).toBe(300);
129
+ expect(result.guides.length).toBeGreaterThanOrEqual(1);
130
+ expect(result.guides[0].axis).toBe("x");
131
+ expect(result.guides[0].position).toBe(300);
132
+ });
133
+
134
+ test("right-to-left alignment: moving right snaps to target left", () => {
135
+ // Target at x=200. Moving rect width=50, at x=0.
136
+ // Proposed dx=146 => proposed right = 196. Target left=200. diff=4 within threshold.
137
+ const t = target("a", 200, 100, 100, 100);
138
+ const result = resolveSnapAdjustment({
139
+ movingRect: rect(0, 100, 50, 50),
140
+ proposedDx: 146,
141
+ proposedDy: 0,
142
+ targets: [t],
143
+ threshold: SNAP_THRESHOLD_PX,
144
+ disabled: false,
145
+ });
146
+ // Proposed right = 196, target left = 200, adjustment = +4
147
+ expect(result.dx).toBe(150);
148
+ });
149
+
150
+ test("center-to-center alignment on X axis", () => {
151
+ // Target center at x=250. Moving rect width=100 at x=0 => center at 50.
152
+ // Propose dx=198 => proposed center=248, target center=250, diff=2
153
+ const t = target("a", 200, 100, 100, 100);
154
+ const result = resolveSnapAdjustment({
155
+ movingRect: rect(0, 100, 100, 50),
156
+ proposedDx: 198,
157
+ proposedDy: 0,
158
+ targets: [t],
159
+ threshold: SNAP_THRESHOLD_PX,
160
+ disabled: false,
161
+ });
162
+ expect(result.dx).toBe(200);
163
+ });
164
+
165
+ test("top-to-bottom alignment", () => {
166
+ // Target bottom at 200. Moving top proposed at 197. Should snap to 200.
167
+ const t = target("a", 100, 100, 100, 100);
168
+ const result = resolveSnapAdjustment({
169
+ movingRect: rect(100, 0, 50, 50),
170
+ proposedDx: 0,
171
+ proposedDy: 197,
172
+ targets: [t],
173
+ threshold: SNAP_THRESHOLD_PX,
174
+ disabled: false,
175
+ });
176
+ expect(result.dy).toBe(200);
177
+ });
178
+
179
+ test("center-to-center alignment on Y axis", () => {
180
+ // Target centerY = 150. Moving height=100 at y=0 => center=50.
181
+ // Propose dy=98 => proposed center=148, target center=150, diff=2
182
+ const t = target("a", 100, 100, 100, 100);
183
+ const result = resolveSnapAdjustment({
184
+ movingRect: rect(100, 0, 100, 100),
185
+ proposedDx: 0,
186
+ proposedDy: 98,
187
+ targets: [t],
188
+ threshold: SNAP_THRESHOLD_PX,
189
+ disabled: false,
190
+ });
191
+ expect(result.dy).toBe(100);
192
+ });
193
+
194
+ test("composition center snap", () => {
195
+ // Composition center at 500, 400. Moving rect 100x100 at 0,0 => center 50,50.
196
+ // Propose dx=447, dy=347 => proposed center 497,397. Should snap to 500,400.
197
+ const result = resolveSnapAdjustment({
198
+ movingRect: rect(0, 0, 100, 100),
199
+ proposedDx: 447,
200
+ proposedDy: 347,
201
+ targets: [compositionTarget],
202
+ threshold: SNAP_THRESHOLD_PX,
203
+ disabled: false,
204
+ });
205
+ expect(result.dx).toBe(450);
206
+ expect(result.dy).toBe(350);
207
+ });
208
+
209
+ test("no snap when outside threshold", () => {
210
+ const t = target("a", 200, 200, 100, 100);
211
+ const result = resolveSnapAdjustment({
212
+ movingRect: rect(0, 0, 50, 50),
213
+ proposedDx: 10,
214
+ proposedDy: 10,
215
+ targets: [t],
216
+ threshold: SNAP_THRESHOLD_PX,
217
+ disabled: false,
218
+ });
219
+ // Moving rect edges: left=10, center=35, right=60
220
+ // Target edges: left=200, center=250, right=300
221
+ // All distances > 6
222
+ expect(result.dx).toBe(10);
223
+ expect(result.dy).toBe(10);
224
+ expect(result.guides).toHaveLength(0);
225
+ });
226
+
227
+ test("multiple matching guides at same distance", () => {
228
+ // Two targets with left edges at 100 — both should produce guides
229
+ const t1 = target("a", 100, 0, 50, 50);
230
+ const t2 = target("b", 100, 200, 50, 50);
231
+ const result = resolveSnapAdjustment({
232
+ movingRect: rect(0, 100, 50, 50),
233
+ proposedDx: 97,
234
+ proposedDy: 0,
235
+ targets: [t1, t2],
236
+ threshold: SNAP_THRESHOLD_PX,
237
+ disabled: false,
238
+ });
239
+ expect(result.dx).toBe(100);
240
+ // Should have a guide at x=100
241
+ const xGuides = result.guides.filter((g) => g.axis === "x");
242
+ expect(xGuides.length).toBeGreaterThanOrEqual(1);
243
+ expect(xGuides[0].position).toBe(100);
244
+ // The guide extent should cover both targets and the moving rect
245
+ expect(xGuides[0].from).toBe(0); // t1 top
246
+ expect(xGuides[0].to).toBe(250); // t2 bottom
247
+ });
248
+
249
+ test("disabled=true returns passthrough", () => {
250
+ const t = target("a", 100, 100, 50, 50);
251
+ const result = resolveSnapAdjustment({
252
+ movingRect: rect(0, 0, 50, 50),
253
+ proposedDx: 98,
254
+ proposedDy: 98,
255
+ targets: [t],
256
+ threshold: SNAP_THRESHOLD_PX,
257
+ disabled: true,
258
+ });
259
+ expect(result.dx).toBe(98);
260
+ expect(result.dy).toBe(98);
261
+ expect(result.guides).toHaveLength(0);
262
+ });
263
+
264
+ test("threshold=0 means no snap", () => {
265
+ const t = target("a", 100, 100, 50, 50);
266
+ const result = resolveSnapAdjustment({
267
+ movingRect: rect(0, 0, 50, 50),
268
+ proposedDx: 99,
269
+ proposedDy: 99,
270
+ targets: [t],
271
+ threshold: 0,
272
+ disabled: false,
273
+ });
274
+ expect(result.dx).toBe(99);
275
+ expect(result.dy).toBe(99);
276
+ expect(result.guides).toHaveLength(0);
277
+ });
278
+
279
+ test("element snap takes priority over grid snap", () => {
280
+ // Element left edge at 100. Grid line at 97.
281
+ // Moving rect proposed left at 98 => dist to element=2, dist to grid=1.
282
+ // Grid is closer but element should win (priority).
283
+ // Actually the spec says element takes priority when both match within threshold.
284
+ // Let's set up: element at 103, grid at 97. Moving proposed left=100.
285
+ // Dist to element=3, dist to grid=3. Element should win.
286
+ const t = target("a", 103, 100, 50, 50);
287
+ const gridEdges = {
288
+ x: [{ position: 97, source: "grid" as const, id: "grid-x-0" }],
289
+ y: [],
290
+ };
291
+ const result = resolveSnapAdjustment({
292
+ movingRect: rect(0, 100, 50, 50),
293
+ proposedDx: 100,
294
+ proposedDy: 0,
295
+ targets: [t],
296
+ gridEdges,
297
+ threshold: SNAP_THRESHOLD_PX,
298
+ disabled: false,
299
+ });
300
+ // Element at 103 wins over grid at 97 (both within threshold, same distance)
301
+ expect(result.dx).toBe(103);
302
+ });
303
+
304
+ test("grid snap used when no element matches", () => {
305
+ const gridEdges = {
306
+ x: [{ position: 100, source: "grid" as const, id: "grid-x-0" }],
307
+ y: [],
308
+ };
309
+ const result = resolveSnapAdjustment({
310
+ movingRect: rect(0, 0, 50, 50),
311
+ proposedDx: 97,
312
+ proposedDy: 10,
313
+ targets: [],
314
+ gridEdges,
315
+ threshold: SNAP_THRESHOLD_PX,
316
+ disabled: false,
317
+ });
318
+ expect(result.dx).toBe(100);
319
+ });
320
+
321
+ test("snaps X and Y independently", () => {
322
+ const t = target("a", 200, 300, 100, 100);
323
+ const result = resolveSnapAdjustment({
324
+ movingRect: rect(0, 0, 100, 100),
325
+ proposedDx: 198,
326
+ proposedDy: 500,
327
+ targets: [t],
328
+ threshold: SNAP_THRESHOLD_PX,
329
+ disabled: false,
330
+ });
331
+ // X should snap (left-to-left, diff=2), Y should not snap (too far)
332
+ expect(result.dx).toBe(200);
333
+ expect(result.dy).toBe(500);
334
+ });
335
+
336
+ test("works correctly with many targets (80)", () => {
337
+ const targets: SnapTarget[] = [];
338
+ for (let i = 0; i < 80; i++) {
339
+ targets.push(target(`el-${i}`, i * 50, i * 30, 40, 20));
340
+ }
341
+ // Moving rect near target el-40: left=2000, top=1200
342
+ const result = resolveSnapAdjustment({
343
+ movingRect: rect(0, 0, 40, 20),
344
+ proposedDx: 1998,
345
+ proposedDy: 1198,
346
+ targets,
347
+ threshold: SNAP_THRESHOLD_PX,
348
+ disabled: false,
349
+ });
350
+ expect(result.dx).toBe(2000);
351
+ expect(result.dy).toBe(1200);
352
+ expect(result.guides.length).toBeGreaterThanOrEqual(1);
353
+ });
354
+
355
+ test("opposite-direction tie produces no snap (ambiguous midpoint)", () => {
356
+ const tA = target("a", 100, 100, 10, 10);
357
+ const tB = target("b", 120, 100, 10, 10);
358
+ // Moving rect at x=110, width=10 → left=110, right=120
359
+ // tA.right=110, distance=0; tB.left=120, distance=0 — both exact, opposite pull
360
+ const result = resolveSnapAdjustment({
361
+ movingRect: rect(110, 100, 10, 10),
362
+ proposedDx: 0,
363
+ proposedDy: 0,
364
+ targets: [tA, tB],
365
+ threshold: SNAP_THRESHOLD_PX,
366
+ disabled: false,
367
+ });
368
+ expect(result.dx).toBe(0);
369
+ expect(result.dy).toBe(0);
370
+ });
371
+
372
+ test("handles subpixel positions from non-100% zoom", () => {
373
+ const t = target("a", 200.5, 100.3, 100, 100);
374
+ const result = resolveSnapAdjustment({
375
+ movingRect: rect(0, 0, 50, 50),
376
+ proposedDx: 197.8,
377
+ proposedDy: 0,
378
+ targets: [t],
379
+ threshold: SNAP_THRESHOLD_PX,
380
+ disabled: false,
381
+ });
382
+ // left edge at 197.8, target left at 200.5, diff=2.7 within threshold
383
+ expect(result.dx).toBe(200.5);
384
+ });
385
+ });
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // resolveResizeSnapAdjustment
389
+ // ---------------------------------------------------------------------------
390
+
391
+ describe("resolveResizeSnapAdjustment", () => {
392
+ test("only right edge snaps on X", () => {
393
+ // Moving rect at (100, 100) size 50x50, right=150.
394
+ // Target left at 200. Propose dx=47 => proposed right=197. Dist to 200=3.
395
+ const t = target("a", 200, 100, 100, 100);
396
+ const result = resolveResizeSnapAdjustment({
397
+ movingRect: rect(100, 100, 50, 50),
398
+ proposedDx: 47,
399
+ proposedDy: 0,
400
+ targets: [t],
401
+ threshold: SNAP_THRESHOLD_PX,
402
+ disabled: false,
403
+ });
404
+ expect(result.dx).toBe(50); // right edge snaps to 200
405
+ });
406
+
407
+ test("only bottom edge snaps on Y", () => {
408
+ // Moving rect at (100, 100) size 50x50, bottom=150.
409
+ // Target top at 200. Propose dy=47 => proposed bottom=197. Dist to 200=3.
410
+ const t = target("a", 100, 200, 100, 100);
411
+ const result = resolveResizeSnapAdjustment({
412
+ movingRect: rect(100, 100, 50, 50),
413
+ proposedDx: 0,
414
+ proposedDy: 47,
415
+ targets: [t],
416
+ threshold: SNAP_THRESHOLD_PX,
417
+ disabled: false,
418
+ });
419
+ expect(result.dy).toBe(50); // bottom edge snaps to 200
420
+ });
421
+
422
+ test("left edge does NOT snap during resize", () => {
423
+ // Target right at 150. Moving rect left=100. If drag were active,
424
+ // left would snap. But during resize, only right edge snaps.
425
+ // Moving rect at (100, 100) size 200x200, right=300.
426
+ // Target right=150. Proposed dx=-153 => proposed right=147. Dist to 150=3.
427
+ // This SHOULD snap right to 150 (dx = -150). But left stays at 100.
428
+ const t = target("a", 50, 100, 100, 100); // right=150
429
+ const result = resolveResizeSnapAdjustment({
430
+ movingRect: rect(100, 100, 200, 200),
431
+ proposedDx: -153,
432
+ proposedDy: 0,
433
+ targets: [t],
434
+ threshold: SNAP_THRESHOLD_PX,
435
+ disabled: false,
436
+ });
437
+ // Right edge: 300 + (-153) = 147 => snaps to 150, adjustment = +3, dx = -150
438
+ expect(result.dx).toBe(-150);
439
+ });
440
+
441
+ test("disabled=true returns passthrough for resize", () => {
442
+ const t = target("a", 200, 200, 100, 100);
443
+ const result = resolveResizeSnapAdjustment({
444
+ movingRect: rect(100, 100, 50, 50),
445
+ proposedDx: 47,
446
+ proposedDy: 47,
447
+ targets: [t],
448
+ threshold: SNAP_THRESHOLD_PX,
449
+ disabled: true,
450
+ });
451
+ expect(result.dx).toBe(47);
452
+ expect(result.dy).toBe(47);
453
+ expect(result.guides).toHaveLength(0);
454
+ });
455
+
456
+ test("resize produces guide lines", () => {
457
+ const t = target("a", 200, 100, 100, 100);
458
+ const result = resolveResizeSnapAdjustment({
459
+ movingRect: rect(100, 100, 50, 50),
460
+ proposedDx: 47,
461
+ proposedDy: 0,
462
+ targets: [t],
463
+ threshold: SNAP_THRESHOLD_PX,
464
+ disabled: false,
465
+ });
466
+ expect(result.guides.length).toBeGreaterThanOrEqual(1);
467
+ const xGuide = result.guides.find((g) => g.axis === "x");
468
+ expect(xGuide).toBeDefined();
469
+ expect(xGuide!.position).toBe(200);
470
+ });
471
+ });
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // resolveEquidistanceGuides
475
+ // ---------------------------------------------------------------------------
476
+
477
+ describe("resolveEquidistanceGuides", () => {
478
+ test("detects equal horizontal spacing", () => {
479
+ // Three elements in a row: A(0..40), moving(70..110), B(140..180)
480
+ // Gap A-moving = 70 - 40 = 30, gap moving-B = 140 - 110 = 30 => equal
481
+ const targets = [target("a", 0, 0, 40, 40), target("b", 140, 0, 40, 40)];
482
+ const guides = resolveEquidistanceGuides({
483
+ movingRect: rect(70, 0, 40, 40),
484
+ targets,
485
+ threshold: SNAP_THRESHOLD_PX,
486
+ });
487
+ const xGuides = guides.filter((g) => g.axis === "x");
488
+ expect(xGuides.length).toBe(2);
489
+ expect(xGuides[0].size).toBe(30);
490
+ expect(xGuides[1].size).toBe(30);
491
+ });
492
+
493
+ test("detects equal vertical spacing", () => {
494
+ // A(y=0..40), moving(y=60..100), B(y=120..160)
495
+ // Gap = 20 each
496
+ const targets = [target("a", 0, 0, 40, 40), target("b", 0, 120, 40, 40)];
497
+ const guides = resolveEquidistanceGuides({
498
+ movingRect: rect(0, 60, 40, 40),
499
+ targets,
500
+ threshold: SNAP_THRESHOLD_PX,
501
+ });
502
+ const yGuides = guides.filter((g) => g.axis === "y");
503
+ expect(yGuides.length).toBe(2);
504
+ expect(yGuides[0].size).toBe(20);
505
+ });
506
+
507
+ test("no equidistance when gaps differ", () => {
508
+ // A(0..40), moving(80..120), B(200..240)
509
+ // Gap A-moving = 40, gap moving-B = 80 => not equal
510
+ const targets = [target("a", 0, 0, 40, 40), target("b", 200, 0, 40, 40)];
511
+ const guides = resolveEquidistanceGuides({
512
+ movingRect: rect(80, 0, 40, 40),
513
+ targets,
514
+ threshold: SNAP_THRESHOLD_PX,
515
+ });
516
+ const xGuides = guides.filter((g) => g.axis === "x");
517
+ expect(xGuides.length).toBe(0);
518
+ });
519
+
520
+ test("handles tolerance of 1px", () => {
521
+ // A(0..40), moving(70..110), B(139..179)
522
+ // Gap A-moving = 30, gap moving-B = 29 => difference = 1 => within tolerance
523
+ const targets = [target("a", 0, 0, 40, 40), target("b", 139, 0, 40, 40)];
524
+ const guides = resolveEquidistanceGuides({
525
+ movingRect: rect(70, 0, 40, 40),
526
+ targets,
527
+ threshold: SNAP_THRESHOLD_PX,
528
+ });
529
+ const xGuides = guides.filter((g) => g.axis === "x");
530
+ expect(xGuides.length).toBe(2);
531
+ });
532
+
533
+ test("ignores overlapping elements", () => {
534
+ // A(0..100), moving(50..150), B(200..300) — A and moving overlap
535
+ const targets = [target("a", 0, 0, 100, 40), target("b", 200, 0, 100, 40)];
536
+ const guides = resolveEquidistanceGuides({
537
+ movingRect: rect(50, 0, 100, 40),
538
+ targets,
539
+ threshold: SNAP_THRESHOLD_PX,
540
+ });
541
+ const xGuides = guides.filter((g) => g.axis === "x");
542
+ // Gap A-moving = 50 - 100 = -50 (overlap), should be skipped
543
+ expect(xGuides.length).toBe(0);
544
+ });
545
+
546
+ test("only reports triplets involving the moving rect", () => {
547
+ // A(0..40), B(60..100), C(120..160) — all gaps = 20 but none involves moving
548
+ // Moving rect is far away at (500..540)
549
+ const targets = [
550
+ target("a", 0, 0, 40, 40),
551
+ target("b", 60, 0, 40, 40),
552
+ target("c", 120, 0, 40, 40),
553
+ ];
554
+ const guides = resolveEquidistanceGuides({
555
+ movingRect: rect(500, 0, 40, 40),
556
+ targets,
557
+ threshold: SNAP_THRESHOLD_PX,
558
+ });
559
+ // The A-B-C triplet doesn't involve moving, so no guides from it
560
+ // Any triplet involving moving would have huge gaps that don't match
561
+ const xGuides = guides.filter((g) => g.axis === "x");
562
+ expect(xGuides.length).toBe(0);
563
+ });
564
+ });
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // Edge cases
568
+ // ---------------------------------------------------------------------------
569
+
570
+ describe("edge cases", () => {
571
+ test("empty targets returns passthrough", () => {
572
+ const result = resolveSnapAdjustment({
573
+ movingRect: rect(0, 0, 50, 50),
574
+ proposedDx: 10,
575
+ proposedDy: 20,
576
+ targets: [],
577
+ threshold: SNAP_THRESHOLD_PX,
578
+ disabled: false,
579
+ });
580
+ expect(result.dx).toBe(10);
581
+ expect(result.dy).toBe(20);
582
+ expect(result.guides).toHaveLength(0);
583
+ });
584
+
585
+ test("exact match (zero distance) produces snap", () => {
586
+ const t = target("a", 100, 100, 50, 50);
587
+ const result = resolveSnapAdjustment({
588
+ movingRect: rect(0, 0, 50, 50),
589
+ proposedDx: 100,
590
+ proposedDy: 100,
591
+ targets: [t],
592
+ threshold: SNAP_THRESHOLD_PX,
593
+ disabled: false,
594
+ });
595
+ expect(result.dx).toBe(100);
596
+ expect(result.dy).toBe(100);
597
+ expect(result.guides.length).toBeGreaterThanOrEqual(1);
598
+ });
599
+
600
+ test("negative proposed delta works", () => {
601
+ const t = target("a", 50, 50, 100, 100);
602
+ // Moving rect at (200, 200), propose dx=-148 => proposed left=52, target left=50, diff=2
603
+ const result = resolveSnapAdjustment({
604
+ movingRect: rect(200, 200, 50, 50),
605
+ proposedDx: -148,
606
+ proposedDy: -148,
607
+ targets: [t],
608
+ threshold: SNAP_THRESHOLD_PX,
609
+ disabled: false,
610
+ });
611
+ expect(result.dx).toBe(-150);
612
+ expect(result.dy).toBe(-150);
613
+ });
614
+
615
+ test("left-to-left alignment", () => {
616
+ const t = target("a", 100, 0, 200, 200);
617
+ const result = resolveSnapAdjustment({
618
+ movingRect: rect(0, 300, 80, 80),
619
+ proposedDx: 97,
620
+ proposedDy: 0,
621
+ targets: [t],
622
+ threshold: SNAP_THRESHOLD_PX,
623
+ disabled: false,
624
+ });
625
+ expect(result.dx).toBe(100);
626
+ });
627
+
628
+ test("right-to-right alignment", () => {
629
+ // Target right = 300. Moving rect width=80 at x=0, right=80.
630
+ // Propose dx=217 => proposed right=297, target right=300, diff=3.
631
+ const t = target("a", 100, 0, 200, 200);
632
+ const result = resolveSnapAdjustment({
633
+ movingRect: rect(0, 300, 80, 80),
634
+ proposedDx: 217,
635
+ proposedDy: 0,
636
+ targets: [t],
637
+ threshold: SNAP_THRESHOLD_PX,
638
+ disabled: false,
639
+ });
640
+ expect(result.dx).toBe(220); // proposed left=220, proposed right=300
641
+ });
642
+
643
+ test("bottom-to-bottom alignment", () => {
644
+ // Target bottom = 200. Moving rect height=50 at y=0, bottom=50.
645
+ // Propose dy=147 => proposed bottom=197, target bottom=200, diff=3.
646
+ const t = target("a", 0, 0, 200, 200);
647
+ const result = resolveSnapAdjustment({
648
+ movingRect: rect(0, 0, 50, 50),
649
+ proposedDx: 0,
650
+ proposedDy: 147,
651
+ targets: [t],
652
+ threshold: SNAP_THRESHOLD_PX,
653
+ disabled: false,
654
+ });
655
+ expect(result.dy).toBe(150);
656
+ });
657
+ });