@hyperframes/studio 0.7.103 → 0.7.105

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-DUB-xtR5.js → hyperframes-player-Jx07OQgw.js} +1 -1
  2. package/dist/assets/{index-DHc-xHLb.js → index-CryZ0PGi.js} +1 -1
  3. package/dist/assets/{index-Cez_yvk5.js → index-Dqi2GmOp.js} +1 -1
  4. package/dist/assets/{index-CxC2vn2-.js → index-DuxKGsHZ.js} +214 -214
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +779 -294
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +6 -2
  10. package/src/captions/hooks/useCaptionSync.ts +2 -1
  11. package/src/components/editor/DomEditOverlay.test.ts +89 -2
  12. package/src/components/editor/DomEditOverlay.tsx +40 -23
  13. package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
  14. package/src/components/editor/domEditOverlayGeometry.ts +59 -2
  15. package/src/components/editor/domEditOverlayGestures.ts +42 -0
  16. package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
  17. package/src/components/editor/groupDragMove.ts +110 -0
  18. package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
  19. package/src/components/editor/manualEditsDom.ts +15 -13
  20. package/src/components/editor/manualOffsetDrag.test.ts +92 -5
  21. package/src/components/editor/manualOffsetDrag.ts +22 -17
  22. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
  23. package/src/components/editor/snapEngageTravel.test.ts +65 -0
  24. package/src/components/editor/snapEngine.ts +21 -1
  25. package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
  26. package/src/components/editor/useDomEditOverlayRects.ts +4 -2
  27. package/src/hooks/domSelectionTimelineMirror.ts +73 -0
  28. package/src/hooks/gestureTransaction.test.ts +7 -12
  29. package/src/hooks/gestureTransaction.ts +7 -20
  30. package/src/hooks/gsapRuntimePatch.test.ts +43 -0
  31. package/src/hooks/gsapRuntimePatch.ts +11 -1
  32. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  33. package/src/hooks/gsapScriptCommitTypes.ts +20 -0
  34. package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
  35. package/src/hooks/keyframeCacheAstLoad.ts +29 -1
  36. package/src/hooks/timelineTimingSync.ts +4 -1
  37. package/src/hooks/useDomEditCommits.ts +2 -1
  38. package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
  39. package/src/hooks/useDomEditPreviewSync.ts +23 -0
  40. package/src/hooks/useDomEditSession.test.tsx +43 -62
  41. package/src/hooks/useDomEditSession.ts +10 -2
  42. package/src/hooks/useDomEditWiring.ts +6 -0
  43. package/src/hooks/useDomSelection.test.ts +133 -3
  44. package/src/hooks/useDomSelection.ts +67 -40
  45. package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
  46. package/src/hooks/useElementLifecycleOps.ts +2 -1
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
  48. package/src/hooks/useFileManager.ts +2 -8
  49. package/src/hooks/useGroupCommits.ts +2 -1
  50. package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
  52. package/src/hooks/useGsapAwareEditing.ts +88 -15
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
  54. package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
  55. package/src/hooks/useGsapScriptCommits.ts +52 -15
  56. package/src/hooks/useStudioUrlState.ts +130 -7
  57. package/src/hooks/useTimelineEditing.ts +2 -1
  58. package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
  59. package/src/player/hooks/useTimelinePlayer.ts +2 -0
  60. package/src/utils/dragDebug.ts +98 -0
  61. package/src/utils/razorSplitTransaction.test.ts +15 -3
  62. package/src/utils/razorSplitTransaction.ts +2 -0
  63. package/src/utils/reloadDebug.ts +10 -0
  64. package/src/utils/resizeDebug.ts +19 -29
  65. package/src/utils/selectDebug.ts +9 -0
  66. package/src/utils/studioDebug.test.ts +32 -0
  67. package/src/utils/studioDebug.ts +30 -0
  68. package/src/utils/studioFileVersion.ts +16 -1
  69. package/src/utils/studioUrlState.test.ts +163 -1
  70. package/src/utils/studioUrlState.ts +60 -3
  71. package/src/utils/timelineCompositionInsert.ts +2 -1
@@ -0,0 +1,110 @@
1
+ import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
2
+ import {
3
+ resolveEquidistanceGuides,
4
+ resolveSnapAdjustment,
5
+ snapEngagedForTravel,
6
+ SNAP_THRESHOLD_PX,
7
+ } from "./snapEngine";
8
+ import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
9
+ import type { GroupGestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
10
+ import type { GroupOverlayItem } from "./domEditOverlayGeometry";
11
+ import {
12
+ findNonRigidMembers,
13
+ logDrag,
14
+ logDragMove,
15
+ readDragPositions,
16
+ } from "../../utils/dragDebug";
17
+
18
+ /**
19
+ * One frame of a group drag, kept out of onPointerMove — which already handles
20
+ * four gesture kinds and reads better without this one's snapping arithmetic.
21
+ * The previous frame's positions live in the closure so the rigidity check below
22
+ * compares against the frame before, not against whatever was last sampled.
23
+ */
24
+ export function createGroupDragMover(
25
+ opts: UseDomEditOverlayGesturesOptions,
26
+ setDraftGroupOverlayItems: (items: GroupOverlayItem[]) => void,
27
+ ) {
28
+ let lastGroupPositions: Record<string, string> = {};
29
+ let lastGesture: GroupGestureState | null = null;
30
+
31
+ /** Snap the group's delta to nearby edges, publishing the guides drawn for it. */
32
+ // fallow-ignore-next-line complexity
33
+ const snapGroupDelta = (
34
+ groupG: GroupGestureState,
35
+ e: React.PointerEvent<HTMLDivElement>,
36
+ proposed: { dx: number; dy: number },
37
+ ) => {
38
+ const sc = groupG.snapContext;
39
+ if (!sc?.snapEnabled || sc.targets.length === 0) return proposed;
40
+ if (!snapEngagedForTravel(proposed.dx, proposed.dy)) {
41
+ opts.snapGuidesRef.current = null;
42
+ return proposed;
43
+ }
44
+ const groupBounds = resolveDomEditGroupOverlayRect(groupG.originItems.map((i) => i.rect));
45
+ if (!groupBounds) return proposed;
46
+ const allTargets = sc.compositionTarget ? [...sc.targets, sc.compositionTarget] : sc.targets;
47
+ const snap = resolveSnapAdjustment({
48
+ movingRect: groupBounds,
49
+ proposedDx: proposed.dx,
50
+ proposedDy: proposed.dy,
51
+ targets: allTargets,
52
+ gridEdges: sc.gridEdges ?? undefined,
53
+ threshold: SNAP_THRESHOLD_PX,
54
+ disabled: e.altKey,
55
+ });
56
+ const movingRect = {
57
+ ...groupBounds,
58
+ left: groupBounds.left + snap.dx,
59
+ top: groupBounds.top + snap.dy,
60
+ };
61
+ const spacingGuides = e.altKey
62
+ ? []
63
+ : resolveEquidistanceGuides({
64
+ movingRect,
65
+ targets: allTargets,
66
+ threshold: SNAP_THRESHOLD_PX,
67
+ });
68
+ opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides };
69
+ return { dx: snap.dx, dy: snap.dy };
70
+ };
71
+
72
+ /** One frame of a group drag: snap the delta, redraw the boxes, move every member. */
73
+ const moveGroupDrag = (groupG: GroupGestureState, e: React.PointerEvent<HTMLDivElement>) => {
74
+ if (groupG !== lastGesture) {
75
+ lastGesture = groupG;
76
+ lastGroupPositions = {};
77
+ }
78
+ const { dx, dy } = snapGroupDelta(groupG, e, {
79
+ dx: e.clientX - groupG.startX,
80
+ dy: e.clientY - groupG.startY,
81
+ });
82
+ groupG.lastSnappedDx = dx;
83
+ groupG.lastSnappedDy = dy;
84
+
85
+ setDraftGroupOverlayItems(
86
+ groupG.originItems.map((i) => ({
87
+ ...i,
88
+ rect: { ...i.rect, left: i.rect.left + dx, top: i.rect.top + dy },
89
+ })),
90
+ );
91
+ const offsets: Record<string, string> = {};
92
+ for (const m of groupG.members) {
93
+ const n = applyManualOffsetDragDraft(m, dx, dy);
94
+ offsets[m.key] = `${Math.round(n.x)},${Math.round(n.y)}`;
95
+ }
96
+ const at = readDragPositions(groupG.members);
97
+ const px = Math.round(e.clientX - groupG.startX);
98
+ const py = Math.round(e.clientY - groupG.startY);
99
+ // A member breaking away IS the fault, so it reports on the frame it happens;
100
+ // the throttled line below would step over it. A gap between pointer and
101
+ // applied there is snapping pulling the group off the cursor.
102
+ const trace = { pointer: `${px},${py}`, applied: `${Math.round(dx)},${Math.round(dy)}`, at };
103
+ const drift = findNonRigidMembers(lastGroupPositions, at);
104
+ if (drift.length > 0) logDrag("drift", { ...trace, drift });
105
+ lastGroupPositions = at;
106
+ logDragMove({ ...trace, offsets });
107
+ };
108
+
109
+ return moveGroupDrag;
110
+ }
@@ -0,0 +1,70 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
4
+ import type { GroupGestureState } from "./domEditOverlayGestures";
5
+
6
+ /**
7
+ * A group drag ended by deselecting the group it had just moved.
8
+ *
9
+ * Every pointerup trails a click. The gesture ref is cleared before the commit,
10
+ * so by the time that click arrives the box no longer looks busy and it reaches
11
+ * the canvas as an ordinary click — landing in the gap between the members,
12
+ * resolving to nothing, and clearing the selection. Captured live as a
13
+ * `[hf-select] clear` with `hadGroup: 3` two milliseconds after the drop.
14
+ *
15
+ * The under-threshold path already ate that click; the committed path has to as
16
+ * well, and the flag is set before the two diverge so neither can forget.
17
+ */
18
+ describe("dropping a dragged group eats the click that follows", () => {
19
+ function harness(travel: { dx: number; dy: number }) {
20
+ const suppressNextBoxClickRef = { current: false };
21
+ const groupGestureRef = {
22
+ current: {
23
+ startX: 0,
24
+ startY: 0,
25
+ originItems: [],
26
+ members: [],
27
+ } as unknown as GroupGestureState,
28
+ };
29
+ const handlers = createDomEditOverlayGestureHandlers({
30
+ overlayRef: { current: null },
31
+ iframeRef: { current: null },
32
+ boxRef: { current: null },
33
+ selectionRef: { current: null },
34
+ hoverSelectionRef: { current: null },
35
+ overlayRectRef: { current: null },
36
+ groupOverlayItemsRef: { current: [] },
37
+ gestureRef: { current: null },
38
+ groupGestureRef,
39
+ blockedMoveRef: { current: null },
40
+ rafPausedRef: { current: false },
41
+ suppressNextBoxClickRef,
42
+ setOverlayRect: vi.fn(),
43
+ setGroupOverlayItems: vi.fn(),
44
+ onBlockedMoveRef: { current: vi.fn() },
45
+ onManualDragStartRef: { current: vi.fn() },
46
+ onPathOffsetCommitRef: { current: vi.fn() },
47
+ onGroupPathOffsetCommitRef: { current: vi.fn() },
48
+ onBoxSizeCommitRef: { current: vi.fn() },
49
+ onRotationCommitRef: { current: vi.fn() },
50
+ onCanvasPointerMoveRef: { current: vi.fn() },
51
+ onCanvasMouseDown: vi.fn(),
52
+ snapGuidesRef: { current: null },
53
+ } as never);
54
+
55
+ handlers.onPointerUp({
56
+ clientX: travel.dx,
57
+ clientY: travel.dy,
58
+ currentTarget: { releasePointerCapture: vi.fn() },
59
+ } as never);
60
+ return suppressNextBoxClickRef;
61
+ }
62
+
63
+ it("eats the click after a drag that moved", () => {
64
+ expect(harness({ dx: 120, dy: 60 }).current).toBe(true);
65
+ });
66
+
67
+ it("still eats it after a press that never travelled", () => {
68
+ expect(harness({ dx: 1, dy: 0 }).current).toBe(true);
69
+ });
70
+ });
@@ -221,6 +221,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
221
221
  return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
222
222
  }
223
223
 
224
+ // fallow-ignore-next-line complexity
224
225
  function stripGsapTranslateFromTransform(element: HTMLElement): void {
225
226
  if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
226
227
  const transform = element.style.getPropertyValue("transform");
@@ -256,6 +257,7 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
256
257
  // and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
257
258
  // still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
258
259
  // reload. Returns true when handled as GSAP (caller must skip the CSS path).
260
+ // fallow-ignore-next-line complexity
259
261
  function applyStudioPathOffsetViaGsap(
260
262
  element: HTMLElement,
261
263
  offset: { x: number; y: number },
@@ -553,26 +555,26 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
553
555
 
554
556
  function reapplyPathOffsets(doc: Document): void {
555
557
  for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
556
- const gsapSkip = gsapAnimatesProperty(el, "x", "y");
558
+ // Unlike size below, the offset channels COMPOSE — applying both doubles the move.
559
+ if (gsapAnimatesProperty(el, "x", "y")) continue;
557
560
  const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
558
561
  const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
559
- if (gsapSkip) continue;
560
- if (x || y) {
561
- applyStudioPathOffset(
562
- el,
563
- {
564
- x: Number.parseFloat(x) || 0,
565
- y: Number.parseFloat(y) || 0,
566
- },
567
- { updateBase: false },
568
- );
569
- }
562
+ if (!x && !y) continue;
563
+ const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
564
+ applyStudioPathOffset(el, offset, { updateBase: false });
570
565
  }
571
566
  }
572
567
 
568
+ /**
569
+ * Put the studio's committed size back after a seek, GSAP-sized elements included.
570
+ * Size does not compose the way the offset above does: both channels write width
571
+ * and height, so the later write wins on the same number. Standing aside meant
572
+ * nothing held the size while a soft reload reverted the old timeline (GSAP hands
573
+ * back each tween's recorded starting width), so the element sat at its stylesheet
574
+ * size until the new one rendered — the jump after a resize.
575
+ */
573
576
  function reapplyBoxSizes(doc: Document): void {
574
577
  for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
575
- if (gsapAnimatesProperty(el, "width", "height")) continue;
576
578
  const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
577
579
  const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
578
580
  if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
@@ -88,6 +88,41 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
88
88
  expect(element.style.getPropertyValue("translate")).toBe("");
89
89
  });
90
90
 
91
+ /**
92
+ * The element that has never been offset is the common case, and it used to skip
93
+ * the measurement and assume the canvas zoom was the whole story. Any transform
94
+ * above the element makes that assumption wrong: the mirrored parent here sends a
95
+ * rightward drag left, so the overlay followed the pointer while the element went
96
+ * the other way, and only on drop did the overlay jump to where the element really
97
+ * was. The fixture mirrors x and scales both axes by 1.2, as a `rotationY: 180`
98
+ * card at `scale: 1.2` does.
99
+ */
100
+ it("measures a mirrored parent even when the element carries no offset yet", () => {
101
+ const window = new Window();
102
+ const element = window.document.createElement("div");
103
+ window.document.body.append(element);
104
+
105
+ element.getBoundingClientRect = () => {
106
+ const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
107
+ const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
108
+ return new window.DOMRect(100 - 1.2 * offsetX, 200 + 1.2 * offsetY, 40, 20);
109
+ };
110
+
111
+ const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
112
+ if (!measured.ok) throw new Error(measured.reason);
113
+
114
+ // Dragging one screen px right must move the element one screen px right, which
115
+ // on a mirrored parent means writing a NEGATIVE offset.
116
+ const offset = resolveManualOffsetForPointerDelta({
117
+ initialOffset: { x: 0, y: 0 },
118
+ screenToOffset: measured.matrix,
119
+ dx: 60,
120
+ dy: 60,
121
+ });
122
+ expect(offset.x).toBeCloseTo(-50, 6);
123
+ expect(offset.y).toBeCloseTo(50, 6);
124
+ });
125
+
91
126
  it("measures movement in parent viewport pixels when the element is inside a scaled iframe", () => {
92
127
  const window = new Window();
93
128
  const iframe = window.document.createElement("iframe");
@@ -133,7 +168,12 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
133
168
  expect(nextOffset).toEqual({ x: 100, y: 50 });
134
169
  });
135
170
 
136
- it("returns identity matrix for non-path-offset elements with zero initial offset", () => {
171
+ // Carrying no path offset used to be taken as permission to assume the response
172
+ // instead of measuring it. It is not a signal about the transforms above the
173
+ // element, so it no longer changes the answer: an element that does not move is
174
+ // unmeasurable either way, and the caller falls back rather than being handed a
175
+ // matrix that was never checked.
176
+ it("does not treat a missing path offset as a measurable response", () => {
137
177
  const window = new Window();
138
178
  const element = window.document.createElement("div");
139
179
  window.document.body.append(element);
@@ -141,10 +181,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
141
181
 
142
182
  const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
143
183
 
144
- expect(measured.ok).toBe(true);
145
- if (measured.ok) {
146
- expectMatrixClose(measured.matrix, { a: 1, b: 0, c: 0, d: 1 });
147
- }
184
+ expect(measured.ok).toBe(false);
148
185
  });
149
186
 
150
187
  it("rejects path-offset elements whose movement response cannot be measured", () => {
@@ -160,6 +197,56 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
160
197
  });
161
198
  });
162
199
 
200
+ /**
201
+ * A group drag is rigid: every member is handed the SAME pointer delta and must
202
+ * travel the same distance on screen, or the group visibly comes apart mid-drag.
203
+ * Members do not share a mapping though — each measures its own, because each can
204
+ * sit under different ancestor transforms. A member whose movement cannot be
205
+ * measured falls back to a guess, and this pins what that guess costs the group.
206
+ */
207
+ describe("group drag stays rigid", () => {
208
+ function member(key: string, response: number, measurable: boolean) {
209
+ const window = new Window();
210
+ const element = window.document.createElement("div");
211
+ window.document.body.append(element);
212
+ element.getBoundingClientRect = () => {
213
+ const ox = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
214
+ const oy = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
215
+ const move = measurable ? response : 0;
216
+ return new window.DOMRect(100 + move * ox, 200 + move * oy, 40, 20);
217
+ };
218
+ const result = createManualOffsetDragMember({
219
+ key,
220
+ selection: { element } as never,
221
+ element,
222
+ rect: { left: 100, top: 200, width: 40, height: 20, editScaleX: 1, editScaleY: 1 },
223
+ });
224
+ if (!result.ok) throw new Error(result.reason);
225
+ return { member: result.member, response };
226
+ }
227
+
228
+ /** Screen distance this member travels for a pointer delta of `d`. */
229
+ function screenTravel(entry: ReturnType<typeof member>, d: number): number {
230
+ const offset = resolveManualOffsetForPointerDelta({
231
+ initialOffset: entry.member.initialOffset,
232
+ screenToOffset: entry.member.screenToOffset,
233
+ dx: d,
234
+ dy: 0,
235
+ });
236
+ return offset.x * entry.response;
237
+ }
238
+
239
+ it("moves every measurable member the same distance for one pointer delta", () => {
240
+ // Two members under different ancestor scales: one 1:1, one inside a half-scale
241
+ // parent. Different offsets, identical screen travel — that is what rigid means.
242
+ const a = member("a", 1, true);
243
+ const b = member("b", 0.5, true);
244
+
245
+ expect(screenTravel(a, 60)).toBeCloseTo(60, 6);
246
+ expect(screenTravel(b, 60)).toBeCloseTo(60, 6);
247
+ });
248
+ });
249
+
163
250
  describe("createManualOffsetDragMember uses raw CSS var offset", () => {
164
251
  it("ignores GSAP transform — initialOffset comes from CSS vars only", () => {
165
252
  const window = new Window();
@@ -213,9 +213,9 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
213
213
  * The perspective w-divisor (matrix3d m44) of the element's current transform.
214
214
  * For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the
215
215
  * element renders 1/m44× larger and a translate of `d` composition px moves
216
- * `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Used
217
- * to keep the drag offset screen-movement mapping correct for depth elements,
218
- * which the flat-scale fast path below would otherwise get wrong by 1/m44.
216
+ * `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Only
217
+ * the unmeasurable-element fallback needs this the measured path reads the
218
+ * foreshortening off the element's real movement along with everything else.
219
219
  */
220
220
  function readTransformWDivisor(element: HTMLElement): number {
221
221
  const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform;
@@ -225,25 +225,25 @@ function readTransformWDivisor(element: HTMLElement): number {
225
225
  return Number.isFinite(w) && w > 0 ? w : 1;
226
226
  }
227
227
 
228
+ /**
229
+ * How far the element actually moves on screen per unit of drag offset, measured
230
+ * rather than assumed.
231
+ *
232
+ * The offset is written on the element, but what reaches the screen is that offset
233
+ * put through every transform above it. A parent carrying a rotation, a mirror, a
234
+ * scale or a perspective changes both the direction and the distance — a card at
235
+ * `rotationY: 180` sends a rightward drag left. Guessing this from the canvas zoom
236
+ * alone was wrong for every such element: the overlay tracked the pointer while the
237
+ * element went somewhere else, and the overlay only jumped to the truth on drop,
238
+ * when it re-measured. Moving the element and watching where it lands costs three
239
+ * layout reads once per gesture and is right for any transform, including ones no
240
+ * closed-form fast path would cover.
241
+ */
228
242
  export function measureManualOffsetDragScreenToOffsetMatrix(
229
243
  element: HTMLElement,
230
244
  initialOffset: { x: number; y: number },
231
245
  options: { probeSize?: number; scaleX?: number; scaleY?: number } = {},
232
246
  ): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
233
- if (
234
- !element.hasAttribute("data-hf-studio-path-offset") &&
235
- initialOffset.x === 0 &&
236
- initialOffset.y === 0
237
- ) {
238
- const sx = options.scaleX || 1;
239
- const sy = options.scaleY || 1;
240
- // Fold in the perspective foreshortening: a depth element (z≠0) moves
241
- // 1/m44× faster on screen than its flat scale implies, so the screen→offset
242
- // matrix must scale by m44 or the element outruns the pointer/overlay.
243
- const w = readTransformWDivisor(element);
244
- return { ok: true, matrix: { a: w / sx, b: 0, c: 0, d: w / sy } };
245
- }
246
-
247
247
  const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
248
248
  if (!Number.isFinite(probeSize) || probeSize <= 0) {
249
249
  return { ok: false, reason: "Invalid movement probe size." };
@@ -325,6 +325,8 @@ export function resolveManualOffsetForPointerDelta(input: {
325
325
  };
326
326
  }
327
327
 
328
+ // Pre-existing complexity — surfaced by this branch touching the file, not by new logic.
329
+ // fallow-ignore-next-line complexity
328
330
  export function createManualOffsetDragMember(input: {
329
331
  key: string;
330
332
  selection: DomEditSelection;
@@ -515,6 +517,7 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
515
517
  endStudioManualEditGesture(member.element, member.gestureToken);
516
518
  }
517
519
 
520
+ /** Roll back a FAILED drag to the exact gesture-start state. */
518
521
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
519
522
  for (const member of members) {
520
523
  restoreManualOffsetDragMember(member);
@@ -522,6 +525,7 @@ export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]
522
525
  }
523
526
  }
524
527
 
528
+ /** Teardown after a COMMITTED drag. */
525
529
  export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
526
530
  for (const member of members) {
527
531
  endStudioManualEditGesture(member.element, member.gestureToken);
@@ -550,6 +554,7 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
550
554
  }
551
555
  }
552
556
 
557
+ /** Shared timeline teardown for either the committed or restored path. */
553
558
  export function resumeGsapTimelines(element: HTMLElement): void {
554
559
  const ids = element.getAttribute("data-hf-drag-paused-timelines");
555
560
  element.removeAttribute("data-hf-drag-paused-timelines");
@@ -0,0 +1,64 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { reapplyPositionEditsAfterSeek } from "./manualEditsDom";
4
+ import { STUDIO_BOX_SIZE_ATTR, STUDIO_HEIGHT_PROP, STUDIO_WIDTH_PROP } from "./manualEditsTypes";
5
+
6
+ /**
7
+ * A resize commit hands the size to a GSAP tween, and a soft reload reverts the
8
+ * old timeline before the new one renders — GSAP restores each tween's recorded
9
+ * starting width on the way out. Nothing else held the size across that window,
10
+ * so the element sat at its stylesheet size for a few hundred milliseconds: the
11
+ * jump after a resize. Worse, the next gesture then started from a box that
12
+ * disagreed with the studio's own vars and snapped on its first move.
13
+ *
14
+ * The seek reapply is what closes the window, and it used to stand aside for
15
+ * exactly the elements that need it — the ones GSAP sizes.
16
+ */
17
+ describe("box size survives a seek while GSAP owns the size", () => {
18
+ afterEach(() => {
19
+ document.body.innerHTML = "";
20
+ Reflect.deleteProperty(window, "__timelines");
21
+ });
22
+
23
+ function cardSizedByGsap(): HTMLElement {
24
+ const el = document.createElement("div");
25
+ el.id = "card";
26
+ el.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
27
+ el.style.setProperty(STUDIO_WIDTH_PROP, "305px");
28
+ el.style.setProperty(STUDIO_HEIGHT_PROP, "202px");
29
+ document.body.append(el);
30
+ // A timeline that animates this element's width/height, as the committed
31
+ // resize leaves behind.
32
+ Object.assign(window, {
33
+ __timelines: {
34
+ main: {
35
+ getChildren: () => [{ targets: () => [el], vars: { width: 305, height: 202 } }],
36
+ },
37
+ },
38
+ });
39
+ return el;
40
+ }
41
+
42
+ it("re-applies the committed size after the timeline gave it back", () => {
43
+ const el = cardSizedByGsap();
44
+ // The revert: GSAP puts the tween's recorded starting size back.
45
+ el.style.width = "395px";
46
+ el.style.height = "261px";
47
+
48
+ reapplyPositionEditsAfterSeek(document);
49
+
50
+ expect(el.style.width).toBe("305px");
51
+ expect(el.style.height).toBe("202px");
52
+ });
53
+
54
+ it("leaves an element alone once its studio size is cleared", () => {
55
+ const el = cardSizedByGsap();
56
+ el.style.removeProperty(STUDIO_WIDTH_PROP);
57
+ el.style.removeProperty(STUDIO_HEIGHT_PROP);
58
+ el.style.width = "395px";
59
+
60
+ reapplyPositionEditsAfterSeek(document);
61
+
62
+ expect(el.style.width).toBe("395px");
63
+ });
64
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveSnapAdjustment,
4
+ snapEngagedForTravel,
5
+ SNAP_THRESHOLD_PX,
6
+ type SnapTarget,
7
+ } from "./snapEngine";
8
+
9
+ /**
10
+ * Picking a selection up used to move it. An element resting within the snap
11
+ * threshold of a guide is already snappable, so the snap computed on the first
12
+ * frame of a drag displaced it by up to the threshold while the pointer had not
13
+ * moved at all — captured live as `pointer "0,0"` against `applied "4,-3"`, with
14
+ * every member of the group jumping 12,-8 composition px before the drag had
15
+ * started. Snapping pulls toward a guide as you drag; it has nothing to say about
16
+ * a gesture that has not moved.
17
+ */
18
+ describe("snapping waits for the drag to travel", () => {
19
+ // Moving box's right edge is at 150; the target's left edge is at 154, so the
20
+ // pair is 4px apart — inside the threshold, and snappable the moment it is asked.
21
+ const movingRect = { left: 100, top: 50, width: 50, height: 40 };
22
+ const target: SnapTarget = {
23
+ left: 154,
24
+ top: 50,
25
+ right: 254,
26
+ bottom: 90,
27
+ centerX: 204,
28
+ centerY: 70,
29
+ id: "neighbour",
30
+ };
31
+
32
+ const snapAt = (dx: number, dy: number) =>
33
+ resolveSnapAdjustment({
34
+ movingRect,
35
+ proposedDx: dx,
36
+ proposedDy: dy,
37
+ targets: [target],
38
+ threshold: SNAP_THRESHOLD_PX,
39
+ disabled: false,
40
+ disabledForTravel: !snapEngagedForTravel(dx, dy),
41
+ });
42
+
43
+ it("does not move a selection that has not been dragged yet", () => {
44
+ expect(snapAt(0, 0)).toMatchObject({ dx: 0, dy: 0 });
45
+ });
46
+
47
+ it("leaves a sub-threshold twitch alone", () => {
48
+ expect(snapAt(1, -1)).toMatchObject({ dx: 1, dy: -1 });
49
+ });
50
+
51
+ it("still snaps once the drag is a real one", () => {
52
+ expect(snapEngagedForTravel(0, 0)).toBe(false);
53
+ expect(snapEngagedForTravel(10, 0)).toBe(true);
54
+ // Without the travel gate the same delta snaps, which is the behaviour to keep.
55
+ const engaged = resolveSnapAdjustment({
56
+ movingRect,
57
+ proposedDx: 0,
58
+ proposedDy: 0,
59
+ targets: [target],
60
+ threshold: SNAP_THRESHOLD_PX,
61
+ disabled: false,
62
+ });
63
+ expect(engaged.dx).toBe(4);
64
+ });
65
+ });
@@ -3,6 +3,24 @@
3
3
  // All position values are in overlay-space (screen) pixels.
4
4
 
5
5
  export const SNAP_THRESHOLD_PX = 6;
6
+ /**
7
+ * Pointer travel a MOVE must reach before snapping is allowed to touch it.
8
+ *
9
+ * An element resting within the threshold of a guide is already "snappable", so
10
+ * a snap computed on the very first frame displaces it by up to the threshold
11
+ * while the pointer has moved nothing — pick a selection up and the whole thing
12
+ * teleports before you have dragged at all. Snapping is meant to pull toward a
13
+ * guide as the user drags, so it does not participate until the drag is real.
14
+ * The value matches the distance a drag must cover to count as a drag rather
15
+ * than a click, so nothing below it moves anything.
16
+ */
17
+ const SNAP_ENGAGE_TRAVEL_PX = 4;
18
+
19
+ /** Whether a move of this size has travelled far enough for snapping to apply. */
20
+ export function snapEngagedForTravel(dx: number, dy: number): boolean {
21
+ return Math.hypot(dx, dy) >= SNAP_ENGAGE_TRAVEL_PX;
22
+ }
23
+
6
24
  const EQUIDISTANCE_TOLERANCE_PX = 1;
7
25
 
8
26
  // ---------------------------------------------------------------------------
@@ -359,8 +377,10 @@ export function resolveSnapAdjustment(input: {
359
377
  gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
360
378
  threshold: number;
361
379
  disabled: boolean;
380
+ /** Set when the gesture has not travelled far enough for snapping yet. */
381
+ disabledForTravel?: boolean;
362
382
  }): SnapResult {
363
- if (input.disabled || input.threshold <= 0) {
383
+ if (input.disabled || input.disabledForTravel || input.threshold <= 0) {
364
384
  return DISABLED_RESULT(input.proposedDx, input.proposedDy);
365
385
  }
366
386