@hyperframes/studio 0.7.52 → 0.7.54

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 (39) hide show
  1. package/dist/assets/{index-DKcziwpY.js → index-CMHYjEZ5.js} +1 -1
  2. package/dist/assets/index-pRhCpGPz.js +423 -0
  3. package/dist/assets/{index-B27HFK8R.js → index-uBY329wb.js} +1 -1
  4. package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
  5. package/dist/chunk-SOTCF4DF.js.map +1 -0
  6. package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +669 -338
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
  12. package/src/components/editor/DomEditCropHandles.tsx +96 -58
  13. package/src/components/editor/DomEditOverlay.test.ts +41 -0
  14. package/src/components/editor/DomEditOverlay.tsx +17 -1
  15. package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
  16. package/src/components/editor/domEditOverlayCrop.ts +103 -7
  17. package/src/components/editor/domEditOverlayGestures.ts +35 -6
  18. package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
  19. package/src/components/editor/domEditingDom.ts +1 -2
  20. package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
  21. package/src/hooks/gsapResizeIntercept.test.ts +129 -0
  22. package/src/hooks/gsapResizeIntercept.ts +385 -0
  23. package/src/hooks/gsapRuntimeBridge.ts +3 -226
  24. package/src/hooks/gsapRuntimePatch.test.ts +63 -0
  25. package/src/hooks/gsapRuntimePatch.ts +35 -1
  26. package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
  27. package/src/hooks/gsapRuntimeReaders.ts +39 -14
  28. package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
  29. package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
  30. package/src/hooks/useGsapAwareEditing.ts +2 -5
  31. package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
  32. package/src/hooks/useGsapScriptCommits.ts +27 -4
  33. package/src/utils/authoredOpacity.ts +35 -0
  34. package/src/utils/elementGsap.ts +31 -0
  35. package/src/utils/gsapSoftReload.test.ts +86 -2
  36. package/src/utils/gsapSoftReload.ts +69 -7
  37. package/dist/assets/index-v3DXednk.js +0 -423
  38. package/dist/chunk-BA66NM4L.js.map +0 -1
  39. /package/dist/{domEditingLayers-H7LDFIJ7.js.map → domEditingLayers-2ECJK24D.js.map} +0 -0
@@ -28,15 +28,21 @@ export function cropRectFromInsets(
28
28
  };
29
29
  }
30
30
 
31
- /** Current inset crop of an element (inline first, computed fallback), or zeros. */
32
- export function readElementCropInsets(element: HTMLElement): ClipPathInsetSides & {
33
- radius: number;
34
- } {
31
+ /**
32
+ * Current inset crop of an element (inline first, computed fallback).
33
+ * Zeros = no clip (croppable, nothing cropped yet). `null` = the element
34
+ * carries a clip-path this tool cannot represent (circle/polygon/non-px
35
+ * inset) — croppers must not lift, edit, or restore it, or the clip gets
36
+ * silently replaced or destroyed on deselect.
37
+ */
38
+ export function readElementCropInsets(
39
+ element: HTMLElement,
40
+ ): (ClipPathInsetSides & { radius: number }) | null {
35
41
  const inline = element.style.getPropertyValue("clip-path").trim();
36
42
  const value =
37
43
  inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
38
- const parsed = parseInsetClipPathSides(value === "none" ? "" : value);
39
- return parsed ?? { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
44
+ if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
45
+ return parseInsetClipPathSides(value);
40
46
  }
41
47
 
42
48
  export interface CropInsetDragInput {
@@ -111,6 +117,96 @@ export function hugRectForElement(
111
117
  element: HTMLElement,
112
118
  ): CropScreenRect {
113
119
  const insets = readElementCropInsets(element);
114
- if (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0) return rect;
120
+ // Uneditable clip (null) can't be hugged show the full element rect.
121
+ if (!insets || (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0))
122
+ return rect;
115
123
  return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
116
124
  }
125
+
126
+ /**
127
+ * The element's own (unrotated) box in overlay space, plus the rotation to
128
+ * apply when drawing crop UI over it. `clip-path` applies in the element's
129
+ * LOCAL frame — before its transform — so the crop dim/outline/handles must be
130
+ * drawn rotated with the element, not on its axis-aligned bounding box: an
131
+ * AABB-drawn dim visually "straightens" a rotated element by masking its
132
+ * corners (the crop window looks axis-aligned while the pixels are not).
133
+ *
134
+ * scaleX/scaleY are overlay px per element CSS px (element's own scale × the
135
+ * editor zoom), so element-space insets map straight onto the frame. Assumes
136
+ * the default 50%/50% transform-origin (the GSAP/studio convention). 3D or
137
+ * unparseable transforms fall back to the axis-aligned frame (angle 0, AABB
138
+ * box) — the pre-existing presentation.
139
+ */
140
+ export interface CropFrame {
141
+ angleDeg: number;
142
+ left: number;
143
+ top: number;
144
+ width: number;
145
+ height: number;
146
+ scaleX: number;
147
+ scaleY: number;
148
+ }
149
+
150
+ export function readElementCropFrame(
151
+ element: HTMLElement,
152
+ overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number },
153
+ ): CropFrame {
154
+ const editX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
155
+ const editY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
156
+ const aabb: CropFrame = {
157
+ angleDeg: 0,
158
+ left: overlayRect.left,
159
+ top: overlayRect.top,
160
+ width: overlayRect.width,
161
+ height: overlayRect.height,
162
+ scaleX: editX,
163
+ scaleY: editY,
164
+ };
165
+ let transform = "";
166
+ try {
167
+ transform = element.ownerDocument.defaultView?.getComputedStyle(element).transform ?? "";
168
+ } catch {
169
+ return aabb;
170
+ }
171
+ if (!transform || transform === "none") return aabb;
172
+ const m = /^matrix\(([^)]+)\)$/.exec(transform);
173
+ if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback
174
+ const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v));
175
+ if (![a, b, c, d].every(Number.isFinite)) return aabb;
176
+ const elScaleX = Math.hypot(a!, b!);
177
+ const det = a! * d! - b! * c!;
178
+ const elScaleY = elScaleX !== 0 ? det / elScaleX : 1;
179
+ if (elScaleX <= 0 || elScaleY <= 0) return aabb;
180
+ const angleDeg = (Math.atan2(b!, a!) * 180) / Math.PI;
181
+ const scaleX = elScaleX * editX;
182
+ const scaleY = elScaleY * editY;
183
+ const width = element.offsetWidth * scaleX;
184
+ const height = element.offsetHeight * scaleY;
185
+ if (!(width > 0) || !(height > 0)) return aabb;
186
+ // Rotation about the default center keeps the center invariant, so the
187
+ // local box is centered on the AABB center.
188
+ const cx = overlayRect.left + overlayRect.width / 2;
189
+ const cy = overlayRect.top + overlayRect.height / 2;
190
+ return {
191
+ angleDeg,
192
+ left: cx - width / 2,
193
+ top: cy - height / 2,
194
+ width,
195
+ height,
196
+ scaleX,
197
+ scaleY,
198
+ };
199
+ }
200
+
201
+ /** Rotate a screen-space pointer delta into the element's local frame. */
202
+ export function rotateDeltaIntoFrame(
203
+ deltaX: number,
204
+ deltaY: number,
205
+ angleDeg: number,
206
+ ): { deltaX: number; deltaY: number } {
207
+ if (angleDeg === 0) return { deltaX, deltaY };
208
+ const rad = (-angleDeg * Math.PI) / 180;
209
+ const cos = Math.cos(rad);
210
+ const sin = Math.sin(rad);
211
+ return { deltaX: deltaX * cos - deltaY * sin, deltaY: deltaX * sin + deltaY * cos };
212
+ }
@@ -39,6 +39,25 @@ export interface GestureState {
39
39
  actualRotation: number;
40
40
  editScaleX: number;
41
41
  editScaleY: number;
42
+ // Rendered-per-CSS-pixel factor of the element itself at gesture start (a GSAP
43
+ // scale() transform makes this > 1) — the resize draft divides by it so the box
44
+ // follows the cursor instead of overshooting by the live scale.
45
+ contentScaleX: number;
46
+ contentScaleY: number;
47
+ // Resize anchor pinning: with a live scale transform, growing the CSS box
48
+ // shifts the rendered box (scaling happens around the element center), so the
49
+ // un-dragged corner creeps during the draft. The move handler measures the
50
+ // gesture-start top-left drift each frame and counters it through the GSAP
51
+ // position channel; the pin accumulates so the correction converges.
52
+ // Present only on resize gestures.
53
+ resizeAnchor?: {
54
+ anchorX: number;
55
+ anchorY: number;
56
+ baseGsapX: number;
57
+ baseGsapY: number;
58
+ pinX: number;
59
+ pinY: number;
60
+ };
42
61
  manualEditDragToken?: string;
43
62
  snapContext?: SnapContext;
44
63
  lastSnappedDx?: number;
@@ -77,21 +96,31 @@ export function resolveDomEditResizeGesture(input: {
77
96
  actualHeight: number;
78
97
  scaleX: number;
79
98
  scaleY: number;
99
+ // Rendered-per-CSS-pixel factor of the element itself (its live GSAP scale).
100
+ // The CSS width/height the draft writes get multiplied by this on screen, so
101
+ // the cursor delta must be divided by it — otherwise the box outruns the
102
+ // pointer on a rescaled element and snaps back on release. Defaults to 1.
103
+ contentScaleX?: number;
104
+ contentScaleY?: number;
80
105
  dx: number;
81
106
  dy: number;
82
107
  uniform: boolean;
83
108
  }): { overlayWidth: number; overlayHeight: number; width: number; height: number } {
84
109
  const scaleX = input.scaleX > 0 ? input.scaleX : 1;
85
110
  const scaleY = input.scaleY > 0 ? input.scaleY : 1;
111
+ const contentScaleX =
112
+ input.contentScaleX !== undefined && input.contentScaleX > 0 ? input.contentScaleX : 1;
113
+ const contentScaleY =
114
+ input.contentScaleY !== undefined && input.contentScaleY > 0 ? input.contentScaleY : 1;
86
115
 
87
116
  if (input.uniform) {
88
- const deltaX = input.dx / scaleX;
89
- const deltaY = input.dy / scaleY;
117
+ const deltaX = input.dx / (scaleX * contentScaleX);
118
+ const deltaY = input.dy / (scaleY * contentScaleY);
90
119
  const delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY;
91
120
  const side = Math.max(1, Math.max(input.actualWidth, input.actualHeight) + delta);
92
121
  return {
93
- overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX),
94
- overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY),
122
+ overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX * contentScaleX),
123
+ overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY * contentScaleY),
95
124
  width: side,
96
125
  height: side,
97
126
  };
@@ -100,8 +129,8 @@ export function resolveDomEditResizeGesture(input: {
100
129
  return {
101
130
  overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, input.originWidth + input.dx),
102
131
  overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, input.originHeight + input.dy),
103
- width: Math.max(1, input.actualWidth + input.dx / scaleX),
104
- height: Math.max(1, input.actualHeight + input.dy / scaleY),
132
+ width: Math.max(1, input.actualWidth + input.dx / (scaleX * contentScaleX)),
133
+ height: Math.max(1, input.actualHeight + input.dy / (scaleY * contentScaleY)),
105
134
  };
106
135
  }
107
136
 
@@ -2,6 +2,7 @@
2
2
  * Gesture-begin functions: startGroupDrag and startGesture.
3
3
  * These are pure "start a new gesture" operations — no draft rect updates.
4
4
  */
5
+ import { readElementGsapNumber } from "../../utils/elementGsap";
5
6
  import { type DomEditSelection } from "./domEditing";
6
7
  import {
7
8
  createManualOffsetDragMember,
@@ -120,8 +121,37 @@ export function startGesture(
120
121
  // `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
121
122
  // element's actual visual angle and commits an absolute angle to the timeline.
122
123
  const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
123
- const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
124
- const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
124
+ // The draft writes CSS width/height, so the resize base must be the CSS
125
+ // layout size. offsetWidth/Height are transform-free; the overlay-rect
126
+ // fallback (rect / editScale) includes the element's own GSAP scale and
127
+ // would make a rescaled element's draft grow from the RENDERED size.
128
+ const layoutWidth = sel.element.offsetWidth;
129
+ const layoutHeight = sel.element.offsetHeight;
130
+ const actualWidth =
131
+ size.width > 0 ? size.width : layoutWidth > 0 ? layoutWidth : rect.width / rect.editScaleX;
132
+ const actualHeight =
133
+ size.height > 0 ? size.height : layoutHeight > 0 ? layoutHeight : rect.height / rect.editScaleY;
134
+ // overlay rect = cssSize x contentScale x editScale, so the element's own
135
+ // render factor (its GSAP scale) falls out of the measured rect. 1 when
136
+ // unscaled or unmeasurable.
137
+ const rawContentScaleX = rect.width / (rect.editScaleX * actualWidth);
138
+ const rawContentScaleY = rect.height / (rect.editScaleY * actualHeight);
139
+ const contentScaleX =
140
+ Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
141
+ const contentScaleY =
142
+ Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
143
+ let resizeAnchor: GestureState["resizeAnchor"];
144
+ if (kind === "resize") {
145
+ const startBcr = sel.element.getBoundingClientRect();
146
+ resizeAnchor = {
147
+ anchorX: startBcr.x,
148
+ anchorY: startBcr.y,
149
+ baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0,
150
+ baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0,
151
+ pinX: 0,
152
+ pinY: 0,
153
+ };
154
+ }
125
155
  let initialPathOffset = captureStudioPathOffset(sel.element);
126
156
  let manualEditDragToken: string | undefined;
127
157
  let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -184,6 +214,9 @@ export function startGesture(
184
214
  actualRotation: rotation.angle,
185
215
  editScaleX: rect.editScaleX,
186
216
  editScaleY: rect.editScaleY,
217
+ contentScaleX,
218
+ contentScaleY,
219
+ resizeAnchor,
187
220
  manualEditDragToken,
188
221
  snapContext,
189
222
  };
@@ -3,6 +3,7 @@
3
3
  * selector utilities, and composition source resolution.
4
4
  * No imports from other domEditing* modules — safe to import from anywhere.
5
5
  */
6
+ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
6
7
  import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes";
7
8
 
8
9
  // ─── Type guard ───────────────────────────────────────────────────────────────
@@ -28,8 +29,6 @@ export function isTextBearingTag(tagName: string): boolean {
28
29
  return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
29
30
  }
30
31
 
31
- const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden";
32
-
33
32
  export function isElementVisibleThroughAncestors(el: HTMLElement): boolean {
34
33
  const win = el.ownerDocument.defaultView;
35
34
  if (!win) return true;
@@ -4,6 +4,7 @@
4
4
  * Owns: onPointerMove, onPointerUp, clearPointerState.
5
5
  * startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
6
6
  */
7
+ import { setElementGsapPosition } from "../../utils/elementGsap";
7
8
  import type { RefObject } from "react";
8
9
  import { type DomEditSelection } from "./domEditing";
9
10
  import {
@@ -53,6 +54,13 @@ import {
53
54
  resolveEquidistanceGuides,
54
55
  SNAP_THRESHOLD_PX,
55
56
  } from "./snapEngine";
57
+ /** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */
58
+ function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void {
59
+ const anchor = g.resizeAnchor;
60
+ if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return;
61
+ setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY);
62
+ }
63
+
56
64
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
57
65
  const setDraftOverlayRect = (next: OverlayRect) => {
58
66
  opts.setOverlayRect(next);
@@ -175,7 +183,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
175
183
  actualAngle: g.actualRotation,
176
184
  snap: e.shiftKey,
177
185
  });
178
- if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
186
+ const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle);
187
+ if (!draftViaGsap) {
179
188
  applyStudioRotationDraft(sel.element, rotated);
180
189
  }
181
190
  return;
@@ -278,12 +287,35 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
278
287
  actualHeight: g.actualHeight,
279
288
  scaleX: g.editScaleX,
280
289
  scaleY: g.editScaleY,
290
+ contentScaleX: g.contentScaleX,
291
+ contentScaleY: g.contentScaleY,
281
292
  dx,
282
293
  dy,
283
294
  uniform: e.shiftKey,
284
295
  });
285
296
  applyStudioBoxSizeDraft(sel.element, nextSize);
286
-
297
+ // Pin the gesture anchor (top-left): with a live scale transform, the CSS
298
+ // size change shifts the rendered box around the element center. Measure
299
+ // the drift of the gesture-start corner and counter it via GSAP x/y —
300
+ // accumulated onto the previous pin so the correction converges instead
301
+ // of oscillating. The release-time position compensation re-measures the
302
+ // drop, so the pin composes with the commit.
303
+ const anchor = g.resizeAnchor;
304
+ if (anchor) {
305
+ const pinned = sel.element.getBoundingClientRect();
306
+ const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x);
307
+ const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y);
308
+ if (
309
+ setElementGsapPosition(
310
+ sel.element,
311
+ anchor.baseGsapX + nextPinX,
312
+ anchor.baseGsapY + nextPinY,
313
+ )
314
+ ) {
315
+ anchor.pinX = nextPinX;
316
+ anchor.pinY = nextPinY;
317
+ }
318
+ }
287
319
  // Re-read BCR after applying dimensions. For elements with a GSAP
288
320
  // scale transform and centered transform-origin the visual top-left
289
321
  // drifts and the visual size diverges from the raw CSS size, so BCR
@@ -382,6 +414,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
382
414
  }
383
415
 
384
416
  if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
417
+ restoreResizeAnchorPin(sel.element, g);
385
418
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
386
419
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
387
420
  if (box) {
@@ -411,7 +444,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
411
444
  restoreStudioRotation(sel.element, g.initialRotation);
412
445
  }
413
446
  };
414
- if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
447
+ const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle);
448
+ if (!rotationChanged) {
415
449
  restoreRotation();
416
450
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
417
451
  return;
@@ -422,14 +456,17 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
422
456
  applyStudioRotation(sel.element, finalRotation);
423
457
  }
424
458
  void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
425
- .catch(() => {
459
+ .catch((error) => {
460
+ console.error("rotate commit failed", error);
426
461
  if (
427
462
  g.manualEditDragToken &&
428
463
  isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
429
464
  )
430
465
  restoreRotation();
431
466
  })
432
- .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
467
+ .finally(() => {
468
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
469
+ });
433
470
  } else if (g.kind === "drag") {
434
471
  const dx = g.lastSnappedDx ?? e.clientX - g.startX;
435
472
  const dy = g.lastSnappedDy ?? e.clientY - g.startY;
@@ -469,12 +506,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
469
506
  const finalSize = readStudioBoxSize(sel.element);
470
507
  applyStudioBoxSize(sel.element, finalSize);
471
508
  void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
472
- .catch(() => {
509
+ .catch((error) => {
510
+ console.error("resize commit failed", error);
473
511
  if (
474
512
  g.manualEditDragToken &&
475
513
  isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
476
- )
514
+ ) {
515
+ restoreResizeAnchorPin(sel.element, g);
477
516
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
517
+ }
478
518
  })
479
519
  .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
480
520
  }
@@ -494,6 +534,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
494
534
  restoreGestureOverlayRect(g);
495
535
  }
496
536
  if (g?.mode === "box-size" && sel) {
537
+ restoreResizeAnchorPin(sel.element, g);
497
538
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
498
539
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
499
540
  restoreGestureOverlayRect(g);
@@ -0,0 +1,129 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
+ import { usePlayerStore } from "../player/store/playerStore";
6
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
7
+
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
11
+ });
12
+
13
+ /**
14
+ * Scale-route resize: an element whose visual size is driven by a scale-group
15
+ * tween. The intercept must (a) route the commit through SCALE, never
16
+ * width/height, and (b) resolve convert-to-keyframes from-values through the
17
+ * group filter — an opacity-touching intro tween on the same element must not
18
+ * ride into the converted keyframes (the disappearance bake class).
19
+ */
20
+ function makeGradedElement(): HTMLElement {
21
+ const el = document.createElement("img");
22
+ el.id = "clip";
23
+ el.setAttribute("data-hf-studio-original-width", "640");
24
+ el.setAttribute("data-hf-studio-original-height", "360");
25
+ // Grading contract: source hidden, canvas carries effective opacity.
26
+ el.setAttribute("data-hf-color-grading-source-hidden", "");
27
+ const canvas = document.createElement("canvas");
28
+ canvas.id = "__hf_color_grading_clip";
29
+ canvas.style.opacity = "0.98";
30
+ document.body.append(el, canvas);
31
+ return el;
32
+ }
33
+
34
+ function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
35
+ // The element's OPACITY intro tween lives on the timeline: unfiltered
36
+ // capture would pick `opacity` up via the other-tween sweep.
37
+ const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } };
38
+ return {
39
+ contentWindow: {
40
+ __timelines: { main: { getChildren: () => [opacityIntro] } },
41
+ gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 },
42
+ },
43
+ contentDocument: document,
44
+ } as unknown as HTMLIFrameElement;
45
+ }
46
+
47
+ function scaleFromTween(): GsapAnimation {
48
+ return {
49
+ id: "#clip-from-200-scale",
50
+ targetSelector: "#clip",
51
+ propertyGroup: "scale",
52
+ method: "from",
53
+ properties: { scale: 0.9 },
54
+ position: 0.2,
55
+ resolvedStart: 0.2,
56
+ duration: 0.8,
57
+ } as unknown as GsapAnimation;
58
+ }
59
+
60
+ function keyframedScaleFixture(): GsapAnimation {
61
+ return {
62
+ ...scaleFromTween(),
63
+ keyframes: {
64
+ keyframes: [
65
+ { percentage: 0, properties: { scale: 0.9 } },
66
+ { percentage: 100, properties: { scale: 1 } },
67
+ ],
68
+ },
69
+ } as unknown as GsapAnimation;
70
+ }
71
+
72
+ /** Drive one resize through the intercept, returning every committed mutation. */
73
+ async function runResize(
74
+ el: HTMLElement,
75
+ iframe: HTMLIFrameElement,
76
+ size: { width: number; height: number },
77
+ ): Promise<Array<Record<string, unknown>>> {
78
+ const selection = { id: "clip", selector: "#clip", element: el } as unknown as DomEditSelection;
79
+ usePlayerStore.setState({ currentTime: 0.5 }); // inside the tween's range
80
+ const committed: Array<Record<string, unknown>> = [];
81
+ const commitMutation = vi.fn(async (_sel: unknown, mutation: Record<string, unknown>) => {
82
+ committed.push(mutation);
83
+ });
84
+ const handled = await tryGsapResizeIntercept(
85
+ selection,
86
+ size,
87
+ [scaleFromTween()],
88
+ iframe,
89
+ commitMutation as never,
90
+ async () => [keyframedScaleFixture()],
91
+ );
92
+ expect(handled).toBe(true);
93
+ return committed;
94
+ }
95
+
96
+ it("scale-route resize converts via the group filter and commits scale, not width/height", async () => {
97
+ const el = makeGradedElement();
98
+ const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
99
+ // uniform: 800/640 === 450/360
100
+ const committed = await runResize(el, iframe, { width: 800, height: 450 });
101
+
102
+ const convert = committed.find((m) => m.type === "convert-to-keyframes");
103
+ expect(convert).toBeDefined();
104
+ const fromValues = convert!.resolvedFromValues as Record<string, number>;
105
+ // Group filter: the opacity intro tween must NOT leak into the conversion.
106
+ expect(fromValues).not.toHaveProperty("opacity");
107
+ expect(fromValues).toHaveProperty("scale");
108
+
109
+ // Every committed property is scale-group — the resize never writes
110
+ // width/height for a scale-driven element (the double-apply bug class).
111
+ const allProps = committed.flatMap((m) => [
112
+ ...Object.keys((m.properties as Record<string, unknown>) ?? {}),
113
+ ...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
114
+ ]);
115
+ expect(allProps).not.toContain("width");
116
+ expect(allProps).not.toContain("height");
117
+ expect(allProps.some((p) => p === "scale" || p === "scaleX")).toBe(true);
118
+ });
119
+
120
+ it("non-uniform drag commits scaleX/scaleY longhands", async () => {
121
+ const el = makeGradedElement();
122
+ const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0 });
123
+ // scaleX 1.25 vs scaleY 1.0 → non-uniform
124
+ const committed = await runResize(el, iframe, { width: 800, height: 360 });
125
+
126
+ const serialized = JSON.stringify(committed);
127
+ expect(serialized).toContain("scaleX");
128
+ expect(serialized).toContain("scaleY");
129
+ });