@hyperframes/studio 0.6.72 → 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-CveQve6o.js +0 -140
@@ -0,0 +1,575 @@
1
+ // fallow-ignore-file code-duplication
2
+ // Snap computation engine — pure functions, zero React/DOM dependencies.
3
+ // All position values are in overlay-space (screen) pixels.
4
+
5
+ export const SNAP_THRESHOLD_PX = 6;
6
+ const EQUIDISTANCE_TOLERANCE_PX = 1;
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface SnapEdge {
13
+ position: number;
14
+ source: "grid";
15
+ id: string;
16
+ }
17
+
18
+ export interface SnapTarget {
19
+ left: number;
20
+ top: number;
21
+ right: number;
22
+ bottom: number;
23
+ centerX: number;
24
+ centerY: number;
25
+ id: string;
26
+ }
27
+
28
+ export interface SnapGuide {
29
+ axis: "x" | "y";
30
+ position: number;
31
+ /** Extent of the guide line (min of involved elements). */
32
+ from: number;
33
+ /** Extent of the guide line (max of involved elements). */
34
+ to: number;
35
+ }
36
+
37
+ export interface SpacingGuide {
38
+ axis: "x" | "y";
39
+ /** Position of the gap (start of gap). */
40
+ position: number;
41
+ /** Size of the gap in pixels. */
42
+ size: number;
43
+ /** Extent for rendering the indicator. */
44
+ from: number;
45
+ /** Extent for rendering the indicator. */
46
+ to: number;
47
+ }
48
+
49
+ export interface SnapResult {
50
+ dx: number;
51
+ dy: number;
52
+ guides: SnapGuide[];
53
+ spacingGuides: SpacingGuide[];
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Rect shorthand used across the public API
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface Rect {
61
+ left: number;
62
+ top: number;
63
+ width: number;
64
+ height: number;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function rectRight(r: Rect): number {
72
+ return r.left + r.width;
73
+ }
74
+
75
+ function rectBottom(r: Rect): number {
76
+ return r.top + r.height;
77
+ }
78
+
79
+ function rectCenterX(r: Rect): number {
80
+ return r.left + r.width / 2;
81
+ }
82
+
83
+ function rectCenterY(r: Rect): number {
84
+ return r.top + r.height / 2;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Public API
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Convert overlay rects to snap targets with precomputed edges & centers.
93
+ */
94
+ export function extractSnapTargets(entries: Array<{ rect: Rect; id: string }>): SnapTarget[] {
95
+ return entries.map(({ rect: r, id }) => ({
96
+ left: r.left,
97
+ top: r.top,
98
+ right: rectRight(r),
99
+ bottom: rectBottom(r),
100
+ centerX: rectCenterX(r),
101
+ centerY: rectCenterY(r),
102
+ id,
103
+ }));
104
+ }
105
+
106
+ /**
107
+ * Create a snap target from the composition/overlay boundary.
108
+ */
109
+ export function buildCompositionSnapTarget(rect: Rect): SnapTarget {
110
+ return {
111
+ left: rect.left,
112
+ top: rect.top,
113
+ right: rectRight(rect),
114
+ bottom: rectBottom(rect),
115
+ centerX: rectCenterX(rect),
116
+ centerY: rectCenterY(rect),
117
+ id: "composition",
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Generate grid-line snap edges.
123
+ * `gridSpacing` is in composition pixels; `scale` converts to overlay pixels.
124
+ * X edges are vertical grid lines; Y edges are horizontal grid lines.
125
+ */
126
+ export function buildGridSnapEdges(
127
+ compositionRect: Rect,
128
+ gridSpacing: number,
129
+ scale: number,
130
+ ): { x: SnapEdge[]; y: SnapEdge[] } {
131
+ const xEdges: SnapEdge[] = [];
132
+ const yEdges: SnapEdge[] = [];
133
+
134
+ if (gridSpacing <= 0 || scale <= 0) return { x: xEdges, y: yEdges };
135
+
136
+ const step = gridSpacing * scale;
137
+
138
+ // Vertical grid lines (x-axis edges)
139
+ let x = compositionRect.left + step;
140
+ const xMax = compositionRect.left + compositionRect.width;
141
+ let idx = 0;
142
+ while (x < xMax) {
143
+ xEdges.push({ position: x, source: "grid", id: `grid-x-${idx}` });
144
+ x += step;
145
+ idx++;
146
+ }
147
+
148
+ // Horizontal grid lines (y-axis edges)
149
+ let y = compositionRect.top + step;
150
+ const yMax = compositionRect.top + compositionRect.height;
151
+ idx = 0;
152
+ while (y < yMax) {
153
+ yEdges.push({ position: y, source: "grid", id: `grid-y-${idx}` });
154
+ y += step;
155
+ idx++;
156
+ }
157
+
158
+ return { x: xEdges, y: yEdges };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Internal snap resolution helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ interface EdgeCandidate {
166
+ /** Distance the moving rect must adjust to align with this edge. */
167
+ adjustment: number;
168
+ /** Absolute distance (for comparison). */
169
+ distance: number;
170
+ /** Position of the guide line. */
171
+ guidePosition: number;
172
+ /** Source of the match. */
173
+ source: "element" | "composition" | "grid";
174
+ /** Id of the target or grid line. */
175
+ targetId: string;
176
+ }
177
+
178
+ /**
179
+ * Collect edge candidates on a single axis for a moving rect.
180
+ * `movingEdges` are the edges of the moving rect (e.g. left, centerX, right).
181
+ * `targetEdges` are the corresponding edges on each target.
182
+ */
183
+ // fallow-ignore-next-line complexity
184
+ function collectCandidates(
185
+ movingEdges: number[],
186
+ targets: SnapTarget[],
187
+ targetEdgeExtractor: (t: SnapTarget) => number[],
188
+ gridEdges: SnapEdge[] | undefined,
189
+ threshold: number,
190
+ ): EdgeCandidate[] {
191
+ const candidates: EdgeCandidate[] = [];
192
+
193
+ for (const target of targets) {
194
+ const tEdges = targetEdgeExtractor(target);
195
+ for (const mEdge of movingEdges) {
196
+ for (const tEdge of tEdges) {
197
+ const adjustment = tEdge - mEdge;
198
+ const distance = Math.abs(adjustment);
199
+ if (distance <= threshold) {
200
+ candidates.push({
201
+ adjustment,
202
+ distance,
203
+ guidePosition: tEdge,
204
+ source: target.id === "composition" ? "composition" : "element",
205
+ targetId: target.id,
206
+ });
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ if (gridEdges) {
213
+ for (const edge of gridEdges) {
214
+ for (const mEdge of movingEdges) {
215
+ const adjustment = edge.position - mEdge;
216
+ const distance = Math.abs(adjustment);
217
+ if (distance <= threshold) {
218
+ candidates.push({
219
+ adjustment,
220
+ distance,
221
+ guidePosition: edge.position,
222
+ source: "grid",
223
+ targetId: edge.id,
224
+ });
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ return candidates;
231
+ }
232
+
233
+ /**
234
+ * From a list of candidates, pick the best adjustment:
235
+ * - Element/composition matches take priority over grid matches.
236
+ * - Among equal-priority matches, pick the smallest distance.
237
+ * - Return all guides that share the winning adjustment.
238
+ */
239
+ function pickBest(candidates: EdgeCandidate[]): {
240
+ adjustment: number;
241
+ matches: EdgeCandidate[];
242
+ } | null {
243
+ if (candidates.length === 0) return null;
244
+
245
+ // Partition into element/composition vs grid
246
+ const elementCandidates = candidates.filter(
247
+ (c) => c.source === "element" || c.source === "composition",
248
+ );
249
+ const gridCandidates = candidates.filter((c) => c.source === "grid");
250
+
251
+ // Pick the pool with the best (smallest distance) match, preferring element
252
+ let pool: EdgeCandidate[];
253
+ const bestElem = elementCandidates.length
254
+ ? Math.min(...elementCandidates.map((c) => c.distance))
255
+ : Infinity;
256
+ const bestGrid = gridCandidates.length
257
+ ? Math.min(...gridCandidates.map((c) => c.distance))
258
+ : Infinity;
259
+
260
+ if (bestElem <= bestGrid) {
261
+ pool = elementCandidates;
262
+ } else {
263
+ pool = gridCandidates;
264
+ }
265
+
266
+ const minDist = Math.min(...pool.map((c) => c.distance));
267
+ const winners = pool.filter((c) => c.distance === minDist);
268
+
269
+ // When candidates at the same distance pull in opposite directions (e.g.
270
+ // element centered between two equidistant targets), suppress the snap
271
+ // entirely — the element holds where the user dragged it.
272
+ const hasPositive = winners.some((c) => c.adjustment > 0);
273
+ const hasNegative = winners.some((c) => c.adjustment < 0);
274
+ if (hasPositive && hasNegative) return null;
275
+
276
+ const adjustment = winners[0].adjustment;
277
+ const matches = pool.filter((c) => c.adjustment === adjustment);
278
+ return { adjustment, matches };
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Guide extent computation
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function computeGuideExtent(
286
+ axis: "x" | "y",
287
+ movingRect: Rect,
288
+ matchedTargetIds: string[],
289
+ targetMap: Map<string, SnapTarget>,
290
+ ): { from: number; to: number } {
291
+ const extents: number[] = [];
292
+
293
+ if (axis === "x") {
294
+ extents.push(movingRect.top, rectBottom(movingRect));
295
+ for (const tid of matchedTargetIds) {
296
+ const t = targetMap.get(tid);
297
+ if (t) extents.push(t.top, t.bottom);
298
+ }
299
+ } else {
300
+ extents.push(movingRect.left, rectRight(movingRect));
301
+ for (const tid of matchedTargetIds) {
302
+ const t = targetMap.get(tid);
303
+ if (t) extents.push(t.left, t.right);
304
+ }
305
+ }
306
+
307
+ return { from: Math.min(...extents), to: Math.max(...extents) };
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Shared guide-building logic
312
+ // ---------------------------------------------------------------------------
313
+
314
+ function buildGuidesFromMatches(
315
+ bestX: { adjustment: number; matches: EdgeCandidate[] } | null,
316
+ bestY: { adjustment: number; matches: EdgeCandidate[] } | null,
317
+ adjustedRect: Rect,
318
+ targetMap: Map<string, SnapTarget>,
319
+ ): SnapGuide[] {
320
+ const guides: SnapGuide[] = [];
321
+
322
+ for (const [axis, best] of [
323
+ ["x", bestX],
324
+ ["y", bestY],
325
+ ] as const) {
326
+ if (!best) continue;
327
+ const seenPositions = new Set<number>();
328
+ for (const m of best.matches) {
329
+ if (seenPositions.has(m.guidePosition)) continue;
330
+ seenPositions.add(m.guidePosition);
331
+ const targetIds = best.matches
332
+ .filter((mm) => mm.guidePosition === m.guidePosition)
333
+ .map((mm) => mm.targetId);
334
+ const extent = computeGuideExtent(axis, adjustedRect, targetIds, targetMap);
335
+ guides.push({ axis, position: m.guidePosition, from: extent.from, to: extent.to });
336
+ }
337
+ }
338
+
339
+ return guides;
340
+ }
341
+
342
+ const DISABLED_RESULT = (dx: number, dy: number): SnapResult => ({
343
+ dx,
344
+ dy,
345
+ guides: [],
346
+ spacingGuides: [],
347
+ });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // resolveSnapAdjustment — main drag snap entry point
351
+ // ---------------------------------------------------------------------------
352
+
353
+ // fallow-ignore-next-line complexity
354
+ export function resolveSnapAdjustment(input: {
355
+ movingRect: Rect;
356
+ proposedDx: number;
357
+ proposedDy: number;
358
+ targets: SnapTarget[];
359
+ gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
360
+ threshold: number;
361
+ disabled: boolean;
362
+ }): SnapResult {
363
+ if (input.disabled || input.threshold <= 0) {
364
+ return DISABLED_RESULT(input.proposedDx, input.proposedDy);
365
+ }
366
+
367
+ const mr = input.movingRect;
368
+ const proposed: Rect = {
369
+ left: mr.left + input.proposedDx,
370
+ top: mr.top + input.proposedDy,
371
+ width: mr.width,
372
+ height: mr.height,
373
+ };
374
+
375
+ const xCandidates = collectCandidates(
376
+ [proposed.left, rectCenterX(proposed), rectRight(proposed)],
377
+ input.targets,
378
+ (t) => [t.left, t.centerX, t.right],
379
+ input.gridEdges?.x,
380
+ input.threshold,
381
+ );
382
+ const yCandidates = collectCandidates(
383
+ [proposed.top, rectCenterY(proposed), rectBottom(proposed)],
384
+ input.targets,
385
+ (t) => [t.top, t.centerY, t.bottom],
386
+ input.gridEdges?.y,
387
+ input.threshold,
388
+ );
389
+
390
+ const bestX = pickBest(xCandidates);
391
+ const bestY = pickBest(yCandidates);
392
+ const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
393
+ const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
394
+
395
+ const adjustedRect: Rect = {
396
+ left: mr.left + adjustedDx,
397
+ top: mr.top + adjustedDy,
398
+ width: mr.width,
399
+ height: mr.height,
400
+ };
401
+
402
+ const targetMap = new Map(input.targets.map((t) => [t.id, t]));
403
+
404
+ return {
405
+ dx: adjustedDx,
406
+ dy: adjustedDy,
407
+ guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
408
+ spacingGuides: [], // computed separately via resolveEquidistanceGuides
409
+ };
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // resolveResizeSnapAdjustment — resize variant (only right/bottom snap)
414
+ // ---------------------------------------------------------------------------
415
+
416
+ // fallow-ignore-next-line complexity
417
+ export function resolveResizeSnapAdjustment(input: {
418
+ movingRect: Rect;
419
+ proposedDx: number;
420
+ proposedDy: number;
421
+ targets: SnapTarget[];
422
+ gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
423
+ threshold: number;
424
+ disabled: boolean;
425
+ }): SnapResult {
426
+ if (input.disabled || input.threshold <= 0) {
427
+ return DISABLED_RESULT(input.proposedDx, input.proposedDy);
428
+ }
429
+
430
+ const mr = input.movingRect;
431
+ const proposedRight = rectRight(mr) + input.proposedDx;
432
+ const proposedBottom = rectBottom(mr) + input.proposedDy;
433
+
434
+ const xCandidates = collectCandidates(
435
+ [proposedRight],
436
+ input.targets,
437
+ (t) => [t.left, t.centerX, t.right],
438
+ input.gridEdges?.x,
439
+ input.threshold,
440
+ );
441
+ const yCandidates = collectCandidates(
442
+ [proposedBottom],
443
+ input.targets,
444
+ (t) => [t.top, t.centerY, t.bottom],
445
+ input.gridEdges?.y,
446
+ input.threshold,
447
+ );
448
+
449
+ const bestX = pickBest(xCandidates);
450
+ const bestY = pickBest(yCandidates);
451
+ const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
452
+ const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
453
+
454
+ const adjustedRect: Rect = {
455
+ left: mr.left,
456
+ top: mr.top,
457
+ width: mr.width + adjustedDx,
458
+ height: mr.height + adjustedDy,
459
+ };
460
+
461
+ const targetMap = new Map(input.targets.map((t) => [t.id, t]));
462
+
463
+ return {
464
+ dx: adjustedDx,
465
+ dy: adjustedDy,
466
+ guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
467
+ spacingGuides: [], // computed separately via resolveEquidistanceGuides
468
+ };
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // resolveEquidistanceGuides
473
+ // ---------------------------------------------------------------------------
474
+
475
+ // fallow-ignore-next-line complexity
476
+ export function resolveEquidistanceGuides(input: {
477
+ movingRect: Rect;
478
+ targets: SnapTarget[];
479
+ threshold: number;
480
+ }): SpacingGuide[] {
481
+ const guides: SpacingGuide[] = [];
482
+ const mr = input.movingRect;
483
+
484
+ const movingTarget: SnapTarget = {
485
+ left: mr.left,
486
+ top: mr.top,
487
+ right: rectRight(mr),
488
+ bottom: rectBottom(mr),
489
+ centerX: rectCenterX(mr),
490
+ centerY: rectCenterY(mr),
491
+ id: "\0__snap_moving__",
492
+ };
493
+
494
+ const allTargets = [...input.targets, movingTarget];
495
+
496
+ // X axis: sort by centerX, scan for equal gaps between adjacent triplets
497
+ const sortedX = [...allTargets].sort((a, b) => a.centerX - b.centerX);
498
+ for (let i = 0; i < sortedX.length - 2; i++) {
499
+ const a = sortedX[i];
500
+ const b = sortedX[i + 1];
501
+ const c = sortedX[i + 2];
502
+
503
+ // Gap between A and B = B.left - A.right
504
+ const gapAB = b.left - a.right;
505
+ const gapBC = c.left - b.right;
506
+
507
+ if (gapAB < 0 || gapBC < 0) continue; // overlapping elements
508
+
509
+ // Check if the moving rect is one of A, B, or C
510
+ const involvesMoving =
511
+ a.id === "\0__snap_moving__" || b.id === "\0__snap_moving__" || c.id === "\0__snap_moving__";
512
+ if (!involvesMoving) continue;
513
+
514
+ if (Math.abs(gapAB - gapBC) <= EQUIDISTANCE_TOLERANCE_PX) {
515
+ const crossMin = Math.min(a.top, b.top, c.top);
516
+ const crossMax = Math.max(a.bottom, b.bottom, c.bottom);
517
+
518
+ // Gap A-B
519
+ guides.push({
520
+ axis: "x",
521
+ position: a.right,
522
+ size: gapAB,
523
+ from: crossMin,
524
+ to: crossMax,
525
+ });
526
+ // Gap B-C
527
+ guides.push({
528
+ axis: "x",
529
+ position: b.right,
530
+ size: gapBC,
531
+ from: crossMin,
532
+ to: crossMax,
533
+ });
534
+ }
535
+ }
536
+
537
+ // Y axis: sort by centerY, scan for equal gaps between adjacent triplets
538
+ const sortedY = [...allTargets].sort((a, b) => a.centerY - b.centerY);
539
+ for (let i = 0; i < sortedY.length - 2; i++) {
540
+ const a = sortedY[i];
541
+ const b = sortedY[i + 1];
542
+ const c = sortedY[i + 2];
543
+
544
+ const gapAB = b.top - a.bottom;
545
+ const gapBC = c.top - b.bottom;
546
+
547
+ if (gapAB < 0 || gapBC < 0) continue;
548
+
549
+ const involvesMoving =
550
+ a.id === "\0__snap_moving__" || b.id === "\0__snap_moving__" || c.id === "\0__snap_moving__";
551
+ if (!involvesMoving) continue;
552
+
553
+ if (Math.abs(gapAB - gapBC) <= EQUIDISTANCE_TOLERANCE_PX) {
554
+ const crossMin = Math.min(a.left, b.left, c.left);
555
+ const crossMax = Math.max(a.right, b.right, c.right);
556
+
557
+ guides.push({
558
+ axis: "y",
559
+ position: a.bottom,
560
+ size: gapAB,
561
+ from: crossMin,
562
+ to: crossMax,
563
+ });
564
+ guides.push({
565
+ axis: "y",
566
+ position: b.bottom,
567
+ size: gapBC,
568
+ from: crossMin,
569
+ to: crossMax,
570
+ });
571
+ }
572
+ }
573
+
574
+ return guides;
575
+ }
@@ -0,0 +1,147 @@
1
+ // fallow-ignore-file unused-file
2
+ // fallow-ignore-file code-duplication
3
+ import type { DomEditSelection } from "./domEditing";
4
+ import {
5
+ isElementVisibleForOverlay,
6
+ toOverlayRect,
7
+ type OverlayRect,
8
+ } from "./domEditOverlayGeometry";
9
+ import {
10
+ extractSnapTargets,
11
+ buildCompositionSnapTarget,
12
+ buildGridSnapEdges,
13
+ type SnapTarget,
14
+ type SnapEdge,
15
+ } from "./snapEngine";
16
+ import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
17
+
18
+ export interface SnapContext {
19
+ targets: SnapTarget[];
20
+ compositionTarget: SnapTarget | null;
21
+ gridEdges: { x: SnapEdge[]; y: SnapEdge[] } | null;
22
+ snapEnabled: boolean;
23
+ }
24
+
25
+ function readPositiveDimension(value: string | null): number | null {
26
+ if (!value) return null;
27
+ const parsed = Number.parseFloat(value);
28
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
29
+ }
30
+
31
+ const IGNORED_TAGS = new Set(["script", "style", "link", "meta", "base", "template", "br", "wbr"]);
32
+
33
+ function isHtmlElement(node: Node): node is HTMLElement {
34
+ return node.nodeType === 1;
35
+ }
36
+
37
+ function collectVisibleElements(
38
+ root: HTMLElement,
39
+ excludeElements: Set<HTMLElement>,
40
+ maxItems: number,
41
+ ): HTMLElement[] {
42
+ const result: HTMLElement[] = [];
43
+ // fallow-ignore-next-line complexity
44
+ const visit = (el: HTMLElement) => {
45
+ if (result.length >= maxItems) return;
46
+ for (const child of Array.from(el.children)) {
47
+ if (!isHtmlElement(child)) continue;
48
+ if (IGNORED_TAGS.has(child.tagName.toLowerCase())) continue;
49
+ if (child.hasAttribute("data-composition-id")) continue;
50
+ if (excludeElements.has(child)) continue;
51
+ if (!isElementVisibleForOverlay(child)) continue;
52
+ result.push(child);
53
+ visit(child);
54
+ }
55
+ };
56
+ visit(root);
57
+ return result;
58
+ }
59
+
60
+ // fallow-ignore-next-line complexity
61
+ export function collectSnapContext(input: {
62
+ overlayEl: HTMLDivElement;
63
+ iframe: HTMLIFrameElement;
64
+ excludeElements: Set<HTMLElement>;
65
+ }): SnapContext {
66
+ const prefs = readStudioUiPreferences();
67
+ const snapEnabled = prefs.snapEnabled ?? true;
68
+
69
+ const doc = input.iframe.contentDocument;
70
+ if (!doc) {
71
+ return { targets: [], compositionTarget: null, gridEdges: null, snapEnabled };
72
+ }
73
+
74
+ const root =
75
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? (doc.documentElement as HTMLElement);
76
+ const rootRect = root?.getBoundingClientRect();
77
+ const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
78
+ const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
79
+ const rootWidth = declaredWidth ?? rootRect?.width;
80
+ const rootHeight = declaredHeight ?? rootRect?.height;
81
+
82
+ if (!rootWidth || !rootHeight || !rootRect) {
83
+ return { targets: [], compositionTarget: null, gridEdges: null, snapEnabled };
84
+ }
85
+
86
+ const iframeRect = input.iframe.getBoundingClientRect();
87
+ const overlayRect = input.overlayEl.getBoundingClientRect();
88
+ const rootScaleX = iframeRect.width / rootWidth;
89
+ const rootScaleY = iframeRect.height / rootHeight;
90
+
91
+ const compositionOverlayRect: OverlayRect = {
92
+ left: iframeRect.left - overlayRect.left,
93
+ top: iframeRect.top - overlayRect.top,
94
+ width: iframeRect.width,
95
+ height: iframeRect.height,
96
+ editScaleX: rootScaleX,
97
+ editScaleY: rootScaleY,
98
+ };
99
+ const compositionTarget = buildCompositionSnapTarget(compositionOverlayRect);
100
+
101
+ const MAX_SNAP_TARGETS = 80;
102
+ const elements = collectVisibleElements(root, input.excludeElements, MAX_SNAP_TARGETS);
103
+ if (elements.length >= MAX_SNAP_TARGETS) {
104
+ console.warn(
105
+ `[snap] Target cap reached (${MAX_SNAP_TARGETS}). Elements beyond this limit are excluded from snap alignment.`,
106
+ );
107
+ }
108
+
109
+ const entries: Array<{
110
+ rect: { left: number; top: number; width: number; height: number };
111
+ id: string;
112
+ }> = [];
113
+ for (let i = 0; i < elements.length; i++) {
114
+ const rect = toOverlayRect(input.overlayEl, input.iframe, elements[i]);
115
+ if (rect) entries.push({ rect, id: `snap-target-${i}` });
116
+ }
117
+
118
+ const targets = extractSnapTargets(entries);
119
+
120
+ let gridEdges: { x: SnapEdge[]; y: SnapEdge[] } | null = null;
121
+ const gridSpacing = prefs.gridSpacing ?? 50;
122
+ const snapToGrid = prefs.snapToGrid ?? false;
123
+ if (snapToGrid && gridSpacing > 0) {
124
+ gridEdges = buildGridSnapEdges(compositionOverlayRect, gridSpacing, rootScaleX);
125
+ }
126
+
127
+ return { targets, compositionTarget, gridEdges, snapEnabled };
128
+ }
129
+
130
+ // fallow-ignore-next-line complexity
131
+ export function buildExcludeElements(input: {
132
+ iframe: HTMLIFrameElement;
133
+ selection?: DomEditSelection | null;
134
+ groupSelections?: DomEditSelection[];
135
+ }): Set<HTMLElement> {
136
+ const elements = new Set<HTMLElement>();
137
+ const sel = input.selection;
138
+ if (sel?.element) {
139
+ elements.add(sel.element);
140
+ }
141
+ if (input.groupSelections) {
142
+ for (const gs of input.groupSelections) {
143
+ if (gs.element) elements.add(gs.element);
144
+ }
145
+ }
146
+ return elements;
147
+ }