@hyperframes/studio 0.7.51 → 0.7.53
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-ZwnuKogK.js → index-Bo8sRL2U.js} +1 -1
- package/dist/assets/index-CJpl5RTK.js +423 -0
- package/dist/assets/{index-3b1A9DtP.js → index-DHrXh-VF.js} +1 -1
- package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
- package/dist/chunk-SOTCF4DF.js.map +1 -0
- package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +669 -338
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
- package/src/components/editor/DomEditCropHandles.tsx +96 -58
- package/src/components/editor/DomEditOverlay.test.ts +41 -0
- package/src/components/editor/DomEditOverlay.tsx +17 -1
- package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
- package/src/components/editor/domEditOverlayCrop.ts +103 -7
- package/src/components/editor/domEditOverlayGestures.ts +35 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
- package/src/components/editor/domEditingDom.ts +1 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
- package/src/hooks/gsapResizeIntercept.test.ts +129 -0
- package/src/hooks/gsapResizeIntercept.ts +385 -0
- package/src/hooks/gsapRuntimeBridge.ts +3 -226
- package/src/hooks/gsapRuntimePatch.test.ts +63 -0
- package/src/hooks/gsapRuntimePatch.ts +35 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
- package/src/hooks/gsapRuntimeReaders.ts +39 -14
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
- package/src/hooks/useGsapAwareEditing.ts +2 -5
- package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
- package/src/hooks/useGsapScriptCommits.ts +27 -4
- package/src/utils/authoredOpacity.ts +35 -0
- package/src/utils/elementGsap.ts +31 -0
- package/src/utils/gsapSoftReload.test.ts +86 -2
- package/src/utils/gsapSoftReload.ts +69 -7
- package/dist/assets/index-BiJK2Lfo.js +0 -423
- package/dist/chunk-BA66NM4L.js.map +0 -1
- /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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
+
});
|