@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.
- package/dist/assets/index-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- 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
|
+
});
|