@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
@@ -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 { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
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