@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
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resize-gesture GSAP intercept: routes a manual resize on a scale-driven
|
|
3
|
+
* element into scale commits (per-axis longhands for non-uniform drags, with
|
|
4
|
+
* keyframe normalization), then settles position synchronously so the drop
|
|
5
|
+
* frame can't jump. Split from gsapRuntimeBridge, which owns the shared
|
|
6
|
+
* group-tween resolution used by the drag/resize/rotate intercepts.
|
|
7
|
+
*/
|
|
8
|
+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
9
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
|
+
import { clearStudioBoxSize } from "../components/editor/manualEdits";
|
|
11
|
+
import { setElementGsapPosition } from "../utils/elementGsap";
|
|
12
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
|
+
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
|
14
|
+
import {
|
|
15
|
+
commitStaticGsapPosition,
|
|
16
|
+
commitStaticGsapSize,
|
|
17
|
+
commitKeyframedSizeFromResize,
|
|
18
|
+
computeCurrentPercentage,
|
|
19
|
+
findExistingPositionWrite,
|
|
20
|
+
findSizeSetAnimation,
|
|
21
|
+
materializeIfDynamic,
|
|
22
|
+
} from "./gsapDragCommit";
|
|
23
|
+
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
24
|
+
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
25
|
+
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
26
|
+
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
27
|
+
import { selectorFromSelection } from "./gsapShared";
|
|
28
|
+
import { roundTo3 } from "../utils/rounding";
|
|
29
|
+
import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
|
|
30
|
+
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
31
|
+
|
|
32
|
+
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
33
|
+
|
|
34
|
+
/** Build identity (zero / one) values for each property in `source`. */
|
|
35
|
+
function synthesizeIdentityProps(
|
|
36
|
+
source: Record<string, number | string>,
|
|
37
|
+
): Record<string, number | string> {
|
|
38
|
+
const id: Record<string, number | string> = {};
|
|
39
|
+
for (const [k, v] of Object.entries(source)) {
|
|
40
|
+
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
41
|
+
else id[k] = v;
|
|
42
|
+
}
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Resize intercept ──────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
// fallow-ignore-next-line complexity
|
|
49
|
+
export async function tryGsapResizeIntercept(
|
|
50
|
+
selection: DomEditSelection,
|
|
51
|
+
size: { width: number; height: number },
|
|
52
|
+
animations: GsapAnimation[],
|
|
53
|
+
iframe: HTMLIFrameElement | null,
|
|
54
|
+
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
55
|
+
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
// If the element already has a scale-group tween, resize should modify scale
|
|
58
|
+
// (the user is resizing something whose visual size is driven by scale).
|
|
59
|
+
// Otherwise, use the size group (width/height).
|
|
60
|
+
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
|
|
61
|
+
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
|
62
|
+
const resolved = await resolveGroupTween(
|
|
63
|
+
resizeGroup,
|
|
64
|
+
animations,
|
|
65
|
+
selection,
|
|
66
|
+
commitMutation,
|
|
67
|
+
fetchFallbackAnimations,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
let anim = resolved?.anim ?? null;
|
|
71
|
+
if (!anim || anim.method === "set") {
|
|
72
|
+
const sel = selectorFromSelection(selection);
|
|
73
|
+
if (!sel) return false;
|
|
74
|
+
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
|
|
75
|
+
|
|
76
|
+
// If the element is animated (has a real tween, not just a static size
|
|
77
|
+
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
78
|
+
// instead of a global set that resizes every frame.
|
|
79
|
+
if (resizeGroup === "size") {
|
|
80
|
+
const animatedTween = pickClosestToPlayhead(
|
|
81
|
+
animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
|
|
82
|
+
);
|
|
83
|
+
if (animatedTween) {
|
|
84
|
+
const handled = await commitKeyframedSizeFromResize(
|
|
85
|
+
selection,
|
|
86
|
+
size,
|
|
87
|
+
sel,
|
|
88
|
+
sizeSet,
|
|
89
|
+
animatedTween,
|
|
90
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
91
|
+
);
|
|
92
|
+
if (handled) return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
|
97
|
+
commitMutation,
|
|
98
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
99
|
+
});
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
104
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
105
|
+
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
106
|
+
const coalesceKey = `gsap:resize:${anim.id}`;
|
|
107
|
+
|
|
108
|
+
const selector = selectorFromSelection(selection);
|
|
109
|
+
// Scope every capture to the resize group — same contract as the rotation
|
|
110
|
+
// intercept. Unfiltered, an opacity-touching intro tween on the element
|
|
111
|
+
// would ride into resize conversions/backfills (the Fix-2 bake class).
|
|
112
|
+
const runtimeProps = selector
|
|
113
|
+
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
|
|
114
|
+
: {};
|
|
115
|
+
|
|
116
|
+
let resizeProps: Record<string, number>;
|
|
117
|
+
let scaleDraftEl: HTMLElement | null = null;
|
|
118
|
+
let scaleDraftDropPoint: { x: number; y: number } | null = null;
|
|
119
|
+
let nonUniformScale = false;
|
|
120
|
+
if (resizeGroup === "scale") {
|
|
121
|
+
// Iframe-realm element — instanceof HTMLElement fails across realms; the
|
|
122
|
+
// selector targets composition elements, and every use below is duck-typed.
|
|
123
|
+
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
|
|
124
|
+
// The resize draft modifies el.style.width/height, so read the ORIGINAL
|
|
125
|
+
// dimensions saved by the draft system before it ran.
|
|
126
|
+
const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
|
|
127
|
+
const origH = Number.parseFloat(el?.getAttribute("data-hf-studio-original-height") ?? "");
|
|
128
|
+
const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
|
|
129
|
+
const cssH = Number.isFinite(origH) && origH > 0 ? origH : cssW;
|
|
130
|
+
// `size` is the draft's CSS box; on screen it is multiplied by the element's
|
|
131
|
+
// LIVE scale (the draft divides the cursor delta by it — see
|
|
132
|
+
// resolveDomEditResizeGesture). The committed keyframe REPLACES that live
|
|
133
|
+
// scale, so it must reproduce the rendered intent: css × live / original.
|
|
134
|
+
// Live scale is 1 on a fresh element (first resize), so this is a no-op there.
|
|
135
|
+
const rawLiveScaleX = readGsapProperty(iframe, selector ?? null, "scaleX") ?? 1;
|
|
136
|
+
const rawLiveScaleY = readGsapProperty(iframe, selector ?? null, "scaleY") ?? 1;
|
|
137
|
+
const liveScaleX = rawLiveScaleX > 0 ? rawLiveScaleX : 1;
|
|
138
|
+
const liveScaleY = rawLiveScaleY > 0 ? rawLiveScaleY : 1;
|
|
139
|
+
const newScaleX = roundTo3((size.width * liveScaleX) / cssW);
|
|
140
|
+
const newScaleY = roundTo3((size.height * liveScaleY) / cssH);
|
|
141
|
+
// A free-form corner drag is usually NON-uniform. A single `scale` value
|
|
142
|
+
// can't represent it — committing width-derived scale used to snap the
|
|
143
|
+
// height at drop. Commit scaleX/scaleY longhands instead; keep the uniform
|
|
144
|
+
// shorthand when the two agree (aspect-true drags, shift-drags).
|
|
145
|
+
nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
|
|
146
|
+
resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX };
|
|
147
|
+
scaleDraftEl = el;
|
|
148
|
+
// Where the user DROPPED the box: the draft (anchor-pinned to the
|
|
149
|
+
// gesture-start top-left) is still applied here, so this rect is exactly
|
|
150
|
+
// what the preview showed at release. The committed scale renders around
|
|
151
|
+
// the element CENTER instead — the finalize step below measures that
|
|
152
|
+
// difference and compensates, so release matches the drop pixel-for-pixel
|
|
153
|
+
// regardless of live scale or repeat resizes.
|
|
154
|
+
if (el) {
|
|
155
|
+
const dropRect = el.getBoundingClientRect();
|
|
156
|
+
scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
resizeProps = {
|
|
160
|
+
width: Math.round(size.width),
|
|
161
|
+
height: Math.round(size.height),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Finalize a scale-route commit: tear down the gesture's inline width/height
|
|
165
|
+
// draft (leaving it applied compounds with the committed scale — the element
|
|
166
|
+
// jumps past the dragged size), then MEASURE where the committed scale
|
|
167
|
+
// actually rendered the box and shift the position hold by the residual so
|
|
168
|
+
// it lands back on the drop point. The compensation only applies to a STATIC
|
|
169
|
+
// position (a `tl.set` hold or none) — a keyframed position path has no
|
|
170
|
+
// single anchor to preserve, so it keeps the plain center-scale behavior.
|
|
171
|
+
// The size route commits the same width/height channels the draft wrote, so
|
|
172
|
+
// it needs none of this.
|
|
173
|
+
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
|
|
174
|
+
// approximate rather than corner-exact.
|
|
175
|
+
// fallow-ignore-next-line complexity
|
|
176
|
+
const finalizeScaleResizeCommit = async () => {
|
|
177
|
+
if (!scaleDraftEl) return;
|
|
178
|
+
clearStudioBoxSize(scaleDraftEl);
|
|
179
|
+
if (!scaleDraftDropPoint || !selector) return;
|
|
180
|
+
const hasLivePositionTween = hasNonHoldTweenForElement(
|
|
181
|
+
iframe,
|
|
182
|
+
selector,
|
|
183
|
+
undefined,
|
|
184
|
+
POSITION_CHANNELS,
|
|
185
|
+
);
|
|
186
|
+
if (hasLivePositionTween) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// The scale commit has rendered (instant patch or soft-reload seek) and the
|
|
190
|
+
// draft is cleared — this rect is where the element ACTUALLY sits now.
|
|
191
|
+
const post = scaleDraftEl.getBoundingClientRect();
|
|
192
|
+
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
|
|
193
|
+
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
|
|
194
|
+
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) return;
|
|
195
|
+
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
196
|
+
// The ONE corrected position — rounded once so the live runtime and the
|
|
197
|
+
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
|
198
|
+
// rounded value from this delta).
|
|
199
|
+
const corrected = {
|
|
200
|
+
x: Math.round(gsapPos.x + residual.x),
|
|
201
|
+
y: Math.round(gsapPos.y + residual.y),
|
|
202
|
+
};
|
|
203
|
+
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
|
|
204
|
+
// rendered the committed scale around the element center — NOT at the drop
|
|
205
|
+
// point — and everything up to here runs in the same microtask chain as
|
|
206
|
+
// that reload, so no frame has painted the uncorrected position yet. The
|
|
207
|
+
// server persist below costs network round-trips; without this set, the
|
|
208
|
+
// element visibly sits at the wrong spot for those frames (the drop
|
|
209
|
+
// "jump"). The persisted commit re-applies the same values (idempotent).
|
|
210
|
+
setElementGsapPosition(scaleDraftEl, corrected.x, corrected.y);
|
|
211
|
+
// Re-fetch: the scale commit above just rewrote the script, so the caller's
|
|
212
|
+
// animation list (and its ids) may be stale for the position lookup.
|
|
213
|
+
const currentAnimations = fetchFallbackAnimations
|
|
214
|
+
? await fetchFallbackAnimations()
|
|
215
|
+
: (resolved?.animations ?? animations);
|
|
216
|
+
const existingSet = findExistingPositionWrite(currentAnimations, selector);
|
|
217
|
+
// Delta chosen so the drag-path math composes back to exactly `corrected`
|
|
218
|
+
// (no drag scratch attrs exist during a resize, so base = gsapPos).
|
|
219
|
+
await commitStaticGsapPosition(
|
|
220
|
+
selection,
|
|
221
|
+
{ x: corrected.x - gsapPos.x, y: corrected.y - gsapPos.y },
|
|
222
|
+
gsapPos,
|
|
223
|
+
selector,
|
|
224
|
+
existingSet,
|
|
225
|
+
{
|
|
226
|
+
commitMutation,
|
|
227
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
|
233
|
+
// tween for this resize group, so nudge it as a whole rather than adding a
|
|
234
|
+
// keyframe at the playhead.
|
|
235
|
+
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
|
236
|
+
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
237
|
+
await commitWholePropertyOffset(
|
|
238
|
+
selection,
|
|
239
|
+
anim,
|
|
240
|
+
resizeProps,
|
|
241
|
+
pct,
|
|
242
|
+
iframe,
|
|
243
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
244
|
+
"Resize animation",
|
|
245
|
+
);
|
|
246
|
+
await finalizeScaleResizeCommit();
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
251
|
+
const ts = resolveTweenStart(anim);
|
|
252
|
+
const td = resolveTweenDuration(anim);
|
|
253
|
+
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
|
254
|
+
// Outside-range uses the extend path which handles everything atomically.
|
|
255
|
+
if (!outsideRange) {
|
|
256
|
+
// fallow-ignore-next-line code-duplication
|
|
257
|
+
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
258
|
+
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
259
|
+
if (newId) anim = { ...anim, id: newId };
|
|
260
|
+
} else if (!anim.keyframes) {
|
|
261
|
+
const resolvedFromValues = selector
|
|
262
|
+
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
|
|
263
|
+
: undefined;
|
|
264
|
+
await commitMutation(
|
|
265
|
+
selection,
|
|
266
|
+
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
267
|
+
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
|
|
268
|
+
);
|
|
269
|
+
if (fetchFallbackAnimations) {
|
|
270
|
+
const fresh = await fetchFallbackAnimations();
|
|
271
|
+
const refreshed = fresh.find(
|
|
272
|
+
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
|
|
273
|
+
);
|
|
274
|
+
if (refreshed) anim = refreshed;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A NON-uniform scale must also take the full-rewrite path: it mixes
|
|
280
|
+
// scaleX/scaleY into a tween whose existing keyframes may carry the uniform
|
|
281
|
+
// `scale` shorthand, and GSAP's percentage keyframes animate each property
|
|
282
|
+
// name independently — a shorthand/longhand mix would leave the old `scale`
|
|
283
|
+
// sub-tween running against the new scaleX/scaleY. The rewrite below
|
|
284
|
+
// normalizes every keyframe to the longhands. For an in-range resize the
|
|
285
|
+
// min/max window math below degenerates to the tween's own start/duration,
|
|
286
|
+
// so timing is unchanged.
|
|
287
|
+
if ((outsideRange || nonUniformScale) && ts !== null) {
|
|
288
|
+
// For flat tweens, synthesize the keyframes from the tween's properties
|
|
289
|
+
const kfs =
|
|
290
|
+
anim.keyframes?.keyframes ??
|
|
291
|
+
(() => {
|
|
292
|
+
const fromProps =
|
|
293
|
+
anim.method === "from" || anim.method === "fromTo"
|
|
294
|
+
? { ...anim.properties }
|
|
295
|
+
: synthesizeIdentityProps(anim.properties);
|
|
296
|
+
const toProps =
|
|
297
|
+
anim.method === "from"
|
|
298
|
+
? synthesizeIdentityProps(anim.properties)
|
|
299
|
+
: { ...anim.properties };
|
|
300
|
+
return [
|
|
301
|
+
{ percentage: 0, properties: fromProps },
|
|
302
|
+
{ percentage: 100, properties: toProps },
|
|
303
|
+
];
|
|
304
|
+
})();
|
|
305
|
+
const newStart = Math.min(ct, ts);
|
|
306
|
+
const newEnd = Math.max(ct, ts + td);
|
|
307
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
308
|
+
const existingKfs = kfs;
|
|
309
|
+
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
|
|
310
|
+
for (const kf of existingKfs) {
|
|
311
|
+
const absTime = ts + (kf.percentage / 100) * td;
|
|
312
|
+
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
313
|
+
const props = { ...kf.properties };
|
|
314
|
+
// Normalize the uniform `scale` shorthand to longhands when this commit
|
|
315
|
+
// writes scaleX/scaleY, so the tween never mixes the two forms.
|
|
316
|
+
if (nonUniformScale && "scale" in props) {
|
|
317
|
+
const uniform = props.scale;
|
|
318
|
+
if (typeof uniform === "number") {
|
|
319
|
+
props.scaleX = uniform;
|
|
320
|
+
props.scaleY = uniform;
|
|
321
|
+
}
|
|
322
|
+
delete props.scale;
|
|
323
|
+
}
|
|
324
|
+
// Only backfill properties that the animation already had (x, y, scale).
|
|
325
|
+
// Don't backfill width/height — they should only appear on the resize keyframe.
|
|
326
|
+
for (const k of Object.keys(resizeProps)) {
|
|
327
|
+
if (k in props) continue;
|
|
328
|
+
if (k === "width" || k === "height") continue;
|
|
329
|
+
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
330
|
+
}
|
|
331
|
+
remapped.push({ percentage: newPct, properties: props });
|
|
332
|
+
}
|
|
333
|
+
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
|
|
334
|
+
// An in-range rewrite can land on an existing keyframe's percentage —
|
|
335
|
+
// merge into it instead of emitting a duplicate step.
|
|
336
|
+
const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05);
|
|
337
|
+
if (collidingKf) Object.assign(collidingKf.properties, resizeProps);
|
|
338
|
+
else remapped.push({ percentage: targetPct, properties: resizeProps });
|
|
339
|
+
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
340
|
+
|
|
341
|
+
await commitMutation(
|
|
342
|
+
selection,
|
|
343
|
+
{
|
|
344
|
+
type: "replace-with-keyframes",
|
|
345
|
+
animationId: anim.id,
|
|
346
|
+
targetSelector: anim.targetSelector,
|
|
347
|
+
position: roundTo3(newStart),
|
|
348
|
+
duration: roundTo3(newDuration),
|
|
349
|
+
keyframes: remapped,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
label: outsideRange
|
|
353
|
+
? `Resize (extended to ${ct.toFixed(2)}s)`
|
|
354
|
+
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
|
|
355
|
+
softReload: true,
|
|
356
|
+
coalesceKey,
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
await finalizeScaleResizeCommit();
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const SIZE_PROPS = new Set(["width", "height"]);
|
|
364
|
+
const backfillDefaults: Record<string, number> = {};
|
|
365
|
+
for (const k of Object.keys(runtimeProps)) {
|
|
366
|
+
if (SIZE_PROPS.has(k)) continue;
|
|
367
|
+
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await commitMutation(
|
|
371
|
+
selection,
|
|
372
|
+
{
|
|
373
|
+
type: "add-keyframe",
|
|
374
|
+
animationId: anim.id,
|
|
375
|
+
percentage: pct,
|
|
376
|
+
properties: resizeProps,
|
|
377
|
+
backfillDefaults,
|
|
378
|
+
},
|
|
379
|
+
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
|
|
380
|
+
);
|
|
381
|
+
await finalizeScaleResizeCommit();
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Rotation intercept ────────────────────────────────────────────────────
|
|
@@ -17,17 +17,14 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
|
17
17
|
import {
|
|
18
18
|
commitStaticGsapPosition,
|
|
19
19
|
commitStaticGsapRotation,
|
|
20
|
-
commitStaticGsapSize,
|
|
21
|
-
commitKeyframedSizeFromResize,
|
|
22
20
|
commitWholePathOffset,
|
|
23
21
|
computeCurrentPercentage,
|
|
24
22
|
findExistingPositionWrite,
|
|
25
23
|
findRotationSetAnimation,
|
|
26
|
-
findSizeSetAnimation,
|
|
27
24
|
materializeIfDynamic,
|
|
28
25
|
} from "./gsapDragCommit";
|
|
29
26
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
30
|
-
import {
|
|
27
|
+
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
31
28
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
32
29
|
import { selectorFromSelection } from "./gsapShared";
|
|
33
30
|
import {
|
|
@@ -36,12 +33,11 @@ import {
|
|
|
36
33
|
readGsapPositionFromIframe,
|
|
37
34
|
} from "./gsapPositionDetection";
|
|
38
35
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
39
|
-
import { roundTo3 } from "../utils/rounding";
|
|
40
36
|
|
|
41
37
|
// Position channels — used to scope the "has a live position tween?" check so a
|
|
42
38
|
// sibling rotation/scale animation never forces a static position hold into the
|
|
43
39
|
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
|
|
44
|
-
const POSITION_CHANNELS = [
|
|
40
|
+
export const POSITION_CHANNELS = [
|
|
45
41
|
"x",
|
|
46
42
|
"y",
|
|
47
43
|
"xPercent",
|
|
@@ -67,7 +63,7 @@ const POSITION_CHANNELS = [
|
|
|
67
63
|
* re-fetch, then return the group tween
|
|
68
64
|
* 3. null — caller must handle the missing-tween case
|
|
69
65
|
*/
|
|
70
|
-
async function resolveGroupTween(
|
|
66
|
+
export async function resolveGroupTween(
|
|
71
67
|
group: PropertyGroupName,
|
|
72
68
|
animations: GsapAnimation[],
|
|
73
69
|
selection: DomEditSelection,
|
|
@@ -264,224 +260,6 @@ export { readGsapProperty, readAllAnimatedProperties };
|
|
|
264
260
|
|
|
265
261
|
// ── Identity-prop synthesis ───────────────────────────────────────────────
|
|
266
262
|
|
|
267
|
-
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
268
|
-
|
|
269
|
-
/** Build identity (zero / one) values for each property in `source`. */
|
|
270
|
-
function synthesizeIdentityProps(
|
|
271
|
-
source: Record<string, number | string>,
|
|
272
|
-
): Record<string, number | string> {
|
|
273
|
-
const id: Record<string, number | string> = {};
|
|
274
|
-
for (const [k, v] of Object.entries(source)) {
|
|
275
|
-
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
276
|
-
else id[k] = v;
|
|
277
|
-
}
|
|
278
|
-
return id;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ── Resize intercept ──────────────────────────────────────────────────────
|
|
282
|
-
|
|
283
|
-
export async function tryGsapResizeIntercept(
|
|
284
|
-
selection: DomEditSelection,
|
|
285
|
-
size: { width: number; height: number },
|
|
286
|
-
animations: GsapAnimation[],
|
|
287
|
-
iframe: HTMLIFrameElement | null,
|
|
288
|
-
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
289
|
-
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
290
|
-
): Promise<boolean> {
|
|
291
|
-
// If the element already has a scale-group tween, resize should modify scale
|
|
292
|
-
// (the user is resizing something whose visual size is driven by scale).
|
|
293
|
-
// Otherwise, use the size group (width/height).
|
|
294
|
-
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
|
|
295
|
-
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
|
296
|
-
const resolved = await resolveGroupTween(
|
|
297
|
-
resizeGroup,
|
|
298
|
-
animations,
|
|
299
|
-
selection,
|
|
300
|
-
commitMutation,
|
|
301
|
-
fetchFallbackAnimations,
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
let anim = resolved?.anim ?? null;
|
|
305
|
-
if (!anim || anim.method === "set") {
|
|
306
|
-
const sel = selectorFromSelection(selection);
|
|
307
|
-
if (!sel) return false;
|
|
308
|
-
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
|
|
309
|
-
|
|
310
|
-
// If the element is animated (has a real tween, not just a static size
|
|
311
|
-
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
312
|
-
// instead of a global set that resizes every frame.
|
|
313
|
-
if (resizeGroup === "size") {
|
|
314
|
-
const animatedTween = pickClosestToPlayhead(
|
|
315
|
-
animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
|
|
316
|
-
);
|
|
317
|
-
if (animatedTween) {
|
|
318
|
-
const handled = await commitKeyframedSizeFromResize(
|
|
319
|
-
selection,
|
|
320
|
-
size,
|
|
321
|
-
sel,
|
|
322
|
-
sizeSet,
|
|
323
|
-
animatedTween,
|
|
324
|
-
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
325
|
-
);
|
|
326
|
-
if (handled) return true;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
|
331
|
-
commitMutation,
|
|
332
|
-
fetchAnimations: fetchFallbackAnimations,
|
|
333
|
-
});
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
338
|
-
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
339
|
-
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
340
|
-
const coalesceKey = `gsap:resize:${anim.id}`;
|
|
341
|
-
|
|
342
|
-
const selector = selectorFromSelection(selection);
|
|
343
|
-
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
344
|
-
|
|
345
|
-
let resizeProps: Record<string, number>;
|
|
346
|
-
if (resizeGroup === "scale") {
|
|
347
|
-
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
|
|
348
|
-
// The resize draft modifies el.style.width, so read the ORIGINAL width
|
|
349
|
-
// saved by the draft system before it ran.
|
|
350
|
-
const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
|
|
351
|
-
const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
|
|
352
|
-
const newScale = roundTo3(size.width / cssW);
|
|
353
|
-
resizeProps = { scale: newScale };
|
|
354
|
-
} else {
|
|
355
|
-
resizeProps = {
|
|
356
|
-
width: Math.round(size.width),
|
|
357
|
-
height: Math.round(size.height),
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
|
362
|
-
// tween for this resize group, so nudge it as a whole rather than adding a
|
|
363
|
-
// keyframe at the playhead.
|
|
364
|
-
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
|
365
|
-
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
366
|
-
await commitWholePropertyOffset(
|
|
367
|
-
selection,
|
|
368
|
-
anim,
|
|
369
|
-
resizeProps,
|
|
370
|
-
pct,
|
|
371
|
-
iframe,
|
|
372
|
-
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
373
|
-
"Resize animation",
|
|
374
|
-
);
|
|
375
|
-
return true;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const ct = usePlayerStore.getState().currentTime;
|
|
379
|
-
const ts = resolveTweenStart(anim);
|
|
380
|
-
const td = resolveTweenDuration(anim);
|
|
381
|
-
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
|
382
|
-
// Outside-range uses the extend path which handles everything atomically.
|
|
383
|
-
if (!outsideRange) {
|
|
384
|
-
// fallow-ignore-next-line code-duplication
|
|
385
|
-
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
386
|
-
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
387
|
-
if (newId) anim = { ...anim, id: newId };
|
|
388
|
-
} else if (!anim.keyframes) {
|
|
389
|
-
const resolvedFromValues = selector
|
|
390
|
-
? readAllAnimatedProperties(iframe, selector, anim)
|
|
391
|
-
: undefined;
|
|
392
|
-
await commitMutation(
|
|
393
|
-
selection,
|
|
394
|
-
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
395
|
-
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
|
|
396
|
-
);
|
|
397
|
-
if (fetchFallbackAnimations) {
|
|
398
|
-
const fresh = await fetchFallbackAnimations();
|
|
399
|
-
const refreshed = fresh.find(
|
|
400
|
-
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
|
|
401
|
-
);
|
|
402
|
-
if (refreshed) anim = refreshed;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (outsideRange && ts !== null) {
|
|
408
|
-
// For flat tweens, synthesize the keyframes from the tween's properties
|
|
409
|
-
const kfs =
|
|
410
|
-
anim.keyframes?.keyframes ??
|
|
411
|
-
(() => {
|
|
412
|
-
const fromProps =
|
|
413
|
-
anim.method === "from" || anim.method === "fromTo"
|
|
414
|
-
? { ...anim.properties }
|
|
415
|
-
: synthesizeIdentityProps(anim.properties);
|
|
416
|
-
const toProps =
|
|
417
|
-
anim.method === "from"
|
|
418
|
-
? synthesizeIdentityProps(anim.properties)
|
|
419
|
-
: { ...anim.properties };
|
|
420
|
-
return [
|
|
421
|
-
{ percentage: 0, properties: fromProps },
|
|
422
|
-
{ percentage: 100, properties: toProps },
|
|
423
|
-
];
|
|
424
|
-
})();
|
|
425
|
-
const newStart = Math.min(ct, ts);
|
|
426
|
-
const newEnd = Math.max(ct, ts + td);
|
|
427
|
-
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
428
|
-
const existingKfs = kfs;
|
|
429
|
-
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
|
|
430
|
-
for (const kf of existingKfs) {
|
|
431
|
-
const absTime = ts + (kf.percentage / 100) * td;
|
|
432
|
-
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
433
|
-
const props = { ...kf.properties };
|
|
434
|
-
// Only backfill properties that the animation already had (x, y, scale).
|
|
435
|
-
// Don't backfill width/height — they should only appear on the resize keyframe.
|
|
436
|
-
for (const k of Object.keys(resizeProps)) {
|
|
437
|
-
if (k in props) continue;
|
|
438
|
-
if (k === "width" || k === "height") continue;
|
|
439
|
-
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
440
|
-
}
|
|
441
|
-
remapped.push({ percentage: newPct, properties: props });
|
|
442
|
-
}
|
|
443
|
-
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
|
|
444
|
-
remapped.push({ percentage: targetPct, properties: resizeProps });
|
|
445
|
-
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
446
|
-
|
|
447
|
-
await commitMutation(
|
|
448
|
-
selection,
|
|
449
|
-
{
|
|
450
|
-
type: "replace-with-keyframes",
|
|
451
|
-
animationId: anim.id,
|
|
452
|
-
targetSelector: anim.targetSelector,
|
|
453
|
-
position: roundTo3(newStart),
|
|
454
|
-
duration: roundTo3(newDuration),
|
|
455
|
-
keyframes: remapped,
|
|
456
|
-
},
|
|
457
|
-
{ label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey },
|
|
458
|
-
);
|
|
459
|
-
return true;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const SIZE_PROPS = new Set(["width", "height"]);
|
|
463
|
-
const backfillDefaults: Record<string, number> = {};
|
|
464
|
-
for (const k of Object.keys(runtimeProps)) {
|
|
465
|
-
if (SIZE_PROPS.has(k)) continue;
|
|
466
|
-
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
await commitMutation(
|
|
470
|
-
selection,
|
|
471
|
-
{
|
|
472
|
-
type: "add-keyframe",
|
|
473
|
-
animationId: anim.id,
|
|
474
|
-
percentage: pct,
|
|
475
|
-
properties: resizeProps,
|
|
476
|
-
backfillDefaults,
|
|
477
|
-
},
|
|
478
|
-
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
|
|
479
|
-
);
|
|
480
|
-
return true;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// ── Rotation intercept ────────────────────────────────────────────────────
|
|
484
|
-
|
|
485
263
|
export async function tryGsapRotationIntercept(
|
|
486
264
|
selection: DomEditSelection,
|
|
487
265
|
angle: number,
|
|
@@ -517,7 +295,6 @@ export async function tryGsapRotationIntercept(
|
|
|
517
295
|
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
|
518
296
|
// gesture's live preview already gsap.set this value (single source of truth).
|
|
519
297
|
const newRotation = Math.round(angle);
|
|
520
|
-
|
|
521
298
|
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
|
|
522
299
|
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
|
|
523
300
|
// mirroring the static position set. Idempotent: re-rotate updates an existing
|