@hyperframes/studio 0.6.73 → 0.6.74

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 (60) hide show
  1. package/dist/assets/index-BcJO6Ej5.js +140 -0
  2. package/dist/assets/index-C2gBZ2km.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/PropertyPanel.tsx +293 -140
  16. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  17. package/src/components/editor/SnapToolbar.tsx +163 -0
  18. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  19. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  20. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  22. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  23. package/src/components/editor/manualEditingAvailability.ts +6 -0
  24. package/src/components/editor/manualEditsDom.ts +56 -2
  25. package/src/components/editor/manualOffsetDrag.ts +19 -3
  26. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  27. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  28. package/src/components/editor/snapEngine.test.ts +657 -0
  29. package/src/components/editor/snapEngine.ts +575 -0
  30. package/src/components/editor/snapTargetCollection.ts +147 -0
  31. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  32. package/src/components/nle/NLELayout.tsx +18 -0
  33. package/src/contexts/DomEditContext.tsx +24 -0
  34. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  37. package/src/hooks/useAppHotkeys.ts +63 -1
  38. package/src/hooks/useDomEditCommits.ts +39 -4
  39. package/src/hooks/useDomEditSession.ts +177 -63
  40. package/src/hooks/useGsapScriptCommits.ts +144 -7
  41. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  42. package/src/hooks/useGsapTweenCache.ts +174 -3
  43. package/src/hooks/useTimelineEditing.ts +93 -0
  44. package/src/icons/SystemIcons.tsx +2 -0
  45. package/src/player/components/ClipContextMenu.tsx +99 -0
  46. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  47. package/src/player/components/Timeline.test.ts +2 -1
  48. package/src/player/components/Timeline.tsx +108 -68
  49. package/src/player/components/TimelineCanvas.tsx +47 -1
  50. package/src/player/components/TimelineClip.tsx +8 -3
  51. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  52. package/src/player/components/timelineDragDrop.ts +103 -0
  53. package/src/player/components/timelineLayout.ts +1 -1
  54. package/src/player/store/playerStore.ts +42 -0
  55. package/src/utils/editHistory.ts +1 -1
  56. package/src/utils/optimisticUpdate.test.ts +53 -0
  57. package/src/utils/optimisticUpdate.ts +18 -0
  58. package/src/utils/studioUiPreferences.ts +17 -0
  59. package/dist/assets/index-CrxThtSJ.css +0 -1
  60. package/dist/assets/index-Dc2HfqON.js +0 -140
@@ -0,0 +1,585 @@
1
+ /**
2
+ * Bridge between the Studio drag system and GSAP animations running in the
3
+ * preview iframe.
4
+ *
5
+ * The preview iframe exposes `window.gsap` with a `getProperty(element, prop)`
6
+ * method that returns the ACTUAL interpolated value at the current seek time.
7
+ * This module reads those runtime values so that drag commits can write correct
8
+ * absolute positions back into the GSAP script, regardless of tween type,
9
+ * easing, or seek position.
10
+ */
11
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
+ import { clearStudioPathOffset } from "../components/editor/manualEdits";
14
+ import { usePlayerStore } from "../player/store/playerStore";
15
+ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
16
+
17
+ // ── Runtime reads ──────────────────────────────────────────────────────────
18
+
19
+ interface IframeGsap {
20
+ getProperty: (el: Element, prop: string) => number;
21
+ }
22
+
23
+ // fallow-ignore-next-line complexity
24
+ function readGsapPositionFromIframe(
25
+ iframe: HTMLIFrameElement | null,
26
+ elementSelector: string,
27
+ ): { x: number; y: number } | null {
28
+ if (!iframe?.contentWindow) return null;
29
+
30
+ let gsap: IframeGsap | undefined;
31
+ try {
32
+ gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
33
+ } catch {
34
+ return null;
35
+ }
36
+ if (!gsap?.getProperty) return null;
37
+
38
+ let doc: Document | null = null;
39
+ try {
40
+ doc = iframe.contentDocument;
41
+ } catch {
42
+ return null;
43
+ }
44
+ if (!doc) return null;
45
+
46
+ const element = doc.querySelector(elementSelector);
47
+ if (!element) return null;
48
+
49
+ const x = Number(gsap.getProperty(element, "x")) || 0;
50
+ const y = Number(gsap.getProperty(element, "y")) || 0;
51
+ return { x, y };
52
+ }
53
+
54
+ // ── Animation matching ─────────────────────────────────────────────────────
55
+
56
+ // fallow-ignore-next-line complexity
57
+ function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null {
58
+ // Prefer animations that already have x/y
59
+ for (const anim of animations) {
60
+ if (anim.keyframes) {
61
+ const hasPos = anim.keyframes.keyframes.some(
62
+ (kf) => "x" in kf.properties || "y" in kf.properties,
63
+ );
64
+ if (hasPos) return anim;
65
+ }
66
+ const props = anim.properties;
67
+ const fromProps = anim.fromProperties;
68
+ if (anim.method === "fromTo") {
69
+ if ("x" in props || "y" in props || (fromProps && ("x" in fromProps || "y" in fromProps))) {
70
+ return anim;
71
+ }
72
+ } else if ("x" in props || "y" in props) {
73
+ return anim;
74
+ }
75
+ }
76
+ // Fall back to any keyframed animation — drag will add x/y to it
77
+ for (const anim of animations) {
78
+ if (anim.keyframes) return anim;
79
+ }
80
+ // Fall back to any animation — will be converted to keyframes
81
+ return animations[0] ?? null;
82
+ }
83
+
84
+ // ── Selector resolution ────────────────────────────────────────────────────
85
+
86
+ function selectorForSelection(selection: DomEditSelection): string | null {
87
+ if (selection.id) return `#${selection.id}`;
88
+ if (selection.selector) return selection.selector;
89
+ return null;
90
+ }
91
+
92
+ // ── Percentage computation ─────────────────────────────────────────────────
93
+
94
+ function computeCurrentPercentage(selection: DomEditSelection): number {
95
+ const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
96
+ const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
97
+ const currentTime = usePlayerStore.getState().currentTime;
98
+ return elDuration > 0
99
+ ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
100
+ : 0;
101
+ }
102
+
103
+ // ── Dynamic keyframe materialization ──────────────────────────────────────
104
+
105
+ async function materializeIfDynamic(
106
+ anim: GsapAnimation,
107
+ iframe: HTMLIFrameElement | null,
108
+ commitMutation: GsapDragCommitCallbacks["commitMutation"],
109
+ selection: DomEditSelection,
110
+ ): Promise<string | void> {
111
+ if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
112
+
113
+ if (anim.hasUnresolvedSelector) {
114
+ // Unroll: read ALL elements' keyframes from runtime and replace the loop
115
+ const allScanned = scanAllRuntimeKeyframes(iframe);
116
+ if (allScanned.size === 0) return;
117
+ const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
118
+ selector: `#${id}`,
119
+ keyframes: data.keyframes,
120
+ easeEach: data.easeEach,
121
+ }));
122
+ await commitMutation(
123
+ selection,
124
+ {
125
+ type: "materialize-keyframes",
126
+ animationId: anim.id,
127
+ keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
128
+ allElements,
129
+ },
130
+ { label: "Unroll dynamic animations", skipReload: true },
131
+ );
132
+ return `${anim.targetSelector}-to-0`;
133
+ }
134
+
135
+ const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
136
+ if (!runtime || runtime.keyframes.length === 0) return;
137
+ await commitMutation(
138
+ selection,
139
+ {
140
+ type: "materialize-keyframes",
141
+ animationId: anim.id,
142
+ keyframes: runtime.keyframes,
143
+ easeEach: runtime.easeEach,
144
+ },
145
+ { label: "Materialize dynamic keyframes", skipReload: true },
146
+ );
147
+ }
148
+
149
+ // ── High-level intercept ───────────────────────────────────────────────────
150
+
151
+ export interface GsapDragCommitCallbacks {
152
+ commitMutation: (
153
+ selection: DomEditSelection,
154
+ mutation: Record<string, unknown>,
155
+ options: {
156
+ label: string;
157
+ coalesceKey?: string;
158
+ softReload?: boolean;
159
+ skipReload?: boolean;
160
+ beforeReload?: () => void;
161
+ },
162
+ ) => Promise<void>;
163
+ }
164
+
165
+ /**
166
+ * Attempt to handle a drag commit via the GSAP script mutation path.
167
+ *
168
+ * Returns a Promise that resolves to true if the drag was handled via GSAP
169
+ * (caller should skip the CSS path), or false if no GSAP position animation
170
+ * exists. The promise resolves only AFTER the mutation has been persisted and
171
+ * the preview soft-reloaded — the CSS offset stays visible until then so the
172
+ * element doesn't snap back during the async gap.
173
+ */
174
+ // fallow-ignore-next-line complexity
175
+ export async function tryGsapDragIntercept(
176
+ selection: DomEditSelection,
177
+ offset: { x: number; y: number },
178
+ animations: GsapAnimation[],
179
+ iframe: HTMLIFrameElement | null,
180
+ commitMutation: GsapDragCommitCallbacks["commitMutation"],
181
+ fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
182
+ ): Promise<boolean> {
183
+ let posAnim = findGsapPositionAnimation(animations);
184
+ if (!posAnim && fetchFallbackAnimations) {
185
+ const fresh = await fetchFallbackAnimations();
186
+ posAnim = findGsapPositionAnimation(fresh);
187
+ }
188
+ if (!posAnim) return false;
189
+
190
+ const selector = selectorForSelection(selection);
191
+ if (!selector) return false;
192
+
193
+ const gsapPos = readGsapPositionFromIframe(iframe, selector);
194
+ if (!gsapPos) return false;
195
+
196
+ await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
197
+ commitMutation,
198
+ });
199
+ return true;
200
+ }
201
+
202
+ // ── Commit helpers ─────────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Compute the new GSAP position values from runtime-read positions + drag
206
+ * offset, then commit the mutation to the GSAP script.
207
+ *
208
+ * `gsap.getProperty` reads from GSAP's internal cache (element._gsap), not
209
+ * from the DOM transform matrix. The strip in `applyStudioPathOffset` does
210
+ * not affect the cached values, so the formula is simply:
211
+ * newValue = cachedGsapValue + dragOffset
212
+ *
213
+ * For flat tweens (to/set), the mutation would change the tween endpoint,
214
+ * which is invisible at t=0. Instead, we convert to keyframes first so the
215
+ * position is set at the exact seek percentage via a keyframe.
216
+ */
217
+ // fallow-ignore-next-line complexity
218
+ async function commitGsapPositionFromDrag(
219
+ selection: DomEditSelection,
220
+ anim: GsapAnimation,
221
+ studioOffset: { x: number; y: number },
222
+ gsapPos: { x: number; y: number },
223
+ iframe: HTMLIFrameElement | null,
224
+ selector: string,
225
+ callbacks: GsapDragCommitCallbacks,
226
+ ): Promise<void> {
227
+ // CSS composition: translate → rotate → transform. The studioOffset is in
228
+ // pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate
229
+ // space (CSS transform). Counter-rotate the offset to match GSAP's frame.
230
+ const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
231
+ const rotDeg = Number.parseFloat(rotStyle) || 0;
232
+ const rad = (-rotDeg * Math.PI) / 180;
233
+ const cos = Math.cos(rad);
234
+ const sin = Math.sin(rad);
235
+ const adjX = studioOffset.x * cos - studioOffset.y * sin;
236
+ const adjY = studioOffset.x * sin + studioOffset.y * cos;
237
+ const newX = Math.round(gsapPos.x + adjX);
238
+ const newY = Math.round(gsapPos.y + adjY);
239
+ const clearOffset = () => clearStudioPathOffset(selection.element);
240
+
241
+ if (anim.keyframes) {
242
+ const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
243
+ const effectiveAnim = newId ? { ...anim, id: newId } : anim;
244
+ const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
245
+ await commitKeyframedPosition(
246
+ selection,
247
+ effectiveAnim,
248
+ { ...runtimeProps, x: newX, y: newY },
249
+ callbacks,
250
+ clearOffset,
251
+ );
252
+ } else if (anim.method === "from") {
253
+ await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset);
254
+ } else if (anim.method === "fromTo") {
255
+ await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset);
256
+ } else {
257
+ // Flat to()/set() — convert to keyframes first so the drag position
258
+ // is captured at the current seek time, not just the tween endpoint.
259
+ const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
260
+ await commitFlatViaKeyframes(
261
+ selection,
262
+ anim,
263
+ { ...runtimeProps, x: newX, y: newY },
264
+ callbacks,
265
+ clearOffset,
266
+ );
267
+ }
268
+ }
269
+
270
+ // fallow-ignore-next-line complexity
271
+ async function commitKeyframedPosition(
272
+ selection: DomEditSelection,
273
+ anim: GsapAnimation,
274
+ properties: Record<string, number>,
275
+ callbacks: GsapDragCommitCallbacks,
276
+ beforeReload: () => void,
277
+ ): Promise<void> {
278
+ const pct = computeCurrentPercentage(selection);
279
+
280
+ await callbacks.commitMutation(
281
+ selection,
282
+ {
283
+ type: "add-keyframe",
284
+ animationId: anim.id,
285
+ percentage: pct,
286
+ properties,
287
+ },
288
+ { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
289
+ );
290
+ }
291
+
292
+ /**
293
+ * For flat to()/set() tweens, convert to keyframes first so we can place the
294
+ * drag position at the current percentage. Without conversion, the mutation
295
+ * only changes the tween endpoint, which is invisible at t=0.
296
+ */
297
+ // fallow-ignore-next-line complexity
298
+ async function commitFlatViaKeyframes(
299
+ selection: DomEditSelection,
300
+ anim: GsapAnimation,
301
+ properties: Record<string, number>,
302
+ callbacks: GsapDragCommitCallbacks,
303
+ beforeReload: () => void,
304
+ ): Promise<void> {
305
+ await callbacks.commitMutation(
306
+ selection,
307
+ { type: "convert-to-keyframes", animationId: anim.id },
308
+ { label: "Convert to keyframes for drag", skipReload: true },
309
+ );
310
+
311
+ const pct = computeCurrentPercentage(selection);
312
+
313
+ await callbacks.commitMutation(
314
+ selection,
315
+ {
316
+ type: "add-keyframe",
317
+ animationId: anim.id,
318
+ percentage: pct,
319
+ properties,
320
+ },
321
+ { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
322
+ );
323
+ }
324
+
325
+ async function commitFromPosition(
326
+ selection: DomEditSelection,
327
+ anim: GsapAnimation,
328
+ delta: { x: number; y: number },
329
+ callbacks: GsapDragCommitCallbacks,
330
+ beforeReload: () => void,
331
+ ): Promise<void> {
332
+ const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
333
+ const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
334
+
335
+ await callbacks.commitMutation(
336
+ selection,
337
+ { type: "update-property", animationId: anim.id, property: "x", value: fromX },
338
+ { label: "Move layer (GSAP from x)", skipReload: true },
339
+ );
340
+ await callbacks.commitMutation(
341
+ selection,
342
+ { type: "update-property", animationId: anim.id, property: "y", value: fromY },
343
+ { label: "Move layer (GSAP from y)", softReload: true, beforeReload },
344
+ );
345
+ }
346
+
347
+ // fallow-ignore-next-line complexity
348
+ async function commitFromToPosition(
349
+ selection: DomEditSelection,
350
+ anim: GsapAnimation,
351
+ delta: { x: number; y: number },
352
+ callbacks: GsapDragCommitCallbacks,
353
+ beforeReload: () => void,
354
+ ): Promise<void> {
355
+ if (anim.fromProperties) {
356
+ const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x);
357
+ const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y);
358
+ await callbacks.commitMutation(
359
+ selection,
360
+ { type: "update-from-property", animationId: anim.id, property: "x", value: fromX },
361
+ { label: "Move (GSAP from x)", skipReload: true },
362
+ );
363
+ await callbacks.commitMutation(
364
+ selection,
365
+ { type: "update-from-property", animationId: anim.id, property: "y", value: fromY },
366
+ { label: "Move (GSAP from y)", skipReload: true },
367
+ );
368
+ }
369
+
370
+ const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
371
+ const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
372
+ await callbacks.commitMutation(
373
+ selection,
374
+ { type: "update-property", animationId: anim.id, property: "x", value: toX },
375
+ { label: "Move (GSAP to x)", skipReload: true },
376
+ );
377
+ await callbacks.commitMutation(
378
+ selection,
379
+ { type: "update-property", animationId: anim.id, property: "y", value: toY },
380
+ { label: "Move (GSAP to y)", softReload: true, beforeReload },
381
+ );
382
+ }
383
+
384
+ // ── Runtime property reader ───────────────────────────────────────────────
385
+
386
+ export function readGsapProperty(
387
+ iframe: HTMLIFrameElement | null,
388
+ selector: string | null,
389
+ prop: string,
390
+ ): number | null {
391
+ if (!iframe?.contentWindow || !selector) return null;
392
+ try {
393
+ const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
394
+ if (!gsap?.getProperty) return null;
395
+ const el = iframe.contentDocument?.querySelector(selector);
396
+ if (!el) return null;
397
+ const val = Number(gsap.getProperty(el, prop));
398
+ return Number.isFinite(val) ? Math.round(val) : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ export function readAllAnimatedProperties(
405
+ iframe: HTMLIFrameElement | null,
406
+ selector: string,
407
+ anim: GsapAnimation,
408
+ ): Record<string, number> {
409
+ const result: Record<string, number> = {};
410
+ if (!iframe?.contentWindow) return result;
411
+ let gsap: IframeGsap | undefined;
412
+ try {
413
+ gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
414
+ } catch {
415
+ return result;
416
+ }
417
+ if (!gsap?.getProperty) return result;
418
+ let doc: Document | null = null;
419
+ try {
420
+ doc = iframe.contentDocument;
421
+ } catch {
422
+ return result;
423
+ }
424
+ const el = doc?.querySelector(selector);
425
+ if (!el) return result;
426
+
427
+ const propKeys = new Set<string>();
428
+ if (anim.keyframes) {
429
+ for (const kf of anim.keyframes.keyframes) {
430
+ for (const p of Object.keys(kf.properties)) {
431
+ if (typeof kf.properties[p] === "number") propKeys.add(p);
432
+ }
433
+ }
434
+ } else {
435
+ for (const p of Object.keys(anim.properties)) propKeys.add(p);
436
+ }
437
+
438
+ for (const prop of propKeys) {
439
+ const val = Number(gsap.getProperty(el, prop));
440
+ if (Number.isFinite(val)) result[prop] = Math.round(val);
441
+ }
442
+ return result;
443
+ }
444
+
445
+ // ── Resize intercept ──────────────────────────────────────────────────────
446
+
447
+ export async function tryGsapResizeIntercept(
448
+ selection: DomEditSelection,
449
+ size: { width: number; height: number },
450
+ animations: GsapAnimation[],
451
+ iframe: HTMLIFrameElement | null,
452
+ commitMutation: GsapDragCommitCallbacks["commitMutation"],
453
+ fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
454
+ ): Promise<boolean> {
455
+ let anim = animations.find(
456
+ (a) => "width" in a.properties || "height" in a.properties || a.keyframes,
457
+ );
458
+ if (!anim && fetchFallbackAnimations) {
459
+ const fresh = await fetchFallbackAnimations();
460
+ anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes);
461
+ }
462
+ if (!anim) return false;
463
+
464
+ const pct = computeCurrentPercentage(selection);
465
+
466
+ if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
467
+ const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
468
+ if (newId) anim = { ...anim, id: newId };
469
+ } else if (!anim.keyframes) {
470
+ await commitMutation(
471
+ selection,
472
+ { type: "convert-to-keyframes", animationId: anim.id },
473
+ { label: "Convert to keyframes for resize", skipReload: true },
474
+ );
475
+ }
476
+
477
+ const selector = selectorForSelection(selection);
478
+ const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
479
+
480
+ const backfillDefaults: Record<string, number> = { ...runtimeProps };
481
+ if (!("width" in runtimeProps)) {
482
+ const cssW = readGsapProperty(iframe, selector, "width");
483
+ backfillDefaults.width = cssW ?? Math.round(size.width);
484
+ }
485
+ if (!("height" in runtimeProps)) {
486
+ const cssH = readGsapProperty(iframe, selector, "height");
487
+ backfillDefaults.height = cssH ?? Math.round(size.height);
488
+ }
489
+
490
+ const properties = {
491
+ ...runtimeProps,
492
+ width: Math.round(size.width),
493
+ height: Math.round(size.height),
494
+ };
495
+
496
+ await commitMutation(
497
+ selection,
498
+ {
499
+ type: "add-keyframe",
500
+ animationId: anim.id,
501
+ percentage: pct,
502
+ properties,
503
+ backfillDefaults,
504
+ },
505
+ { label: `Resize (keyframe ${pct}%)`, softReload: true },
506
+ );
507
+ return true;
508
+ }
509
+
510
+ // ── Rotation intercept ────────────────────────────────────────────────────
511
+
512
+ export async function tryGsapRotationIntercept(
513
+ selection: DomEditSelection,
514
+ angle: number,
515
+ animations: GsapAnimation[],
516
+ iframe: HTMLIFrameElement | null,
517
+ commitMutation: GsapDragCommitCallbacks["commitMutation"],
518
+ fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
519
+ ): Promise<boolean> {
520
+ let anim = animations.find((a) => "rotation" in a.properties || a.keyframes);
521
+ if (!anim && fetchFallbackAnimations) {
522
+ const fresh = await fetchFallbackAnimations();
523
+ anim = fresh.find((a) => "rotation" in a.properties || a.keyframes);
524
+ }
525
+ if (!anim) return false;
526
+
527
+ const selector = selectorForSelection(selection);
528
+ if (!selector) return false;
529
+
530
+ let gsapRotation = 0;
531
+ if (iframe?.contentWindow) {
532
+ try {
533
+ const gsap = (
534
+ iframe.contentWindow as unknown as {
535
+ gsap?: { getProperty: (el: Element, prop: string) => number };
536
+ }
537
+ ).gsap;
538
+ const doc = iframe.contentDocument;
539
+ const el = doc?.querySelector(selector);
540
+ if (gsap?.getProperty && el) {
541
+ gsapRotation = Number(gsap.getProperty(el, "rotation")) || 0;
542
+ }
543
+ } catch {
544
+ /* cross-origin guard */
545
+ }
546
+ }
547
+
548
+ const pct = computeCurrentPercentage(selection);
549
+ const newRotation = Math.round(gsapRotation + angle);
550
+
551
+ if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
552
+ const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
553
+ if (newId) anim = { ...anim, id: newId };
554
+ } else if (!anim.keyframes) {
555
+ await commitMutation(
556
+ selection,
557
+ { type: "convert-to-keyframes", animationId: anim.id },
558
+ { label: "Convert to keyframes for rotation", skipReload: true },
559
+ );
560
+ }
561
+
562
+ const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
563
+
564
+ const backfillDefaults: Record<string, number> = { ...runtimeProps };
565
+ if (!("rotation" in runtimeProps)) {
566
+ backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
567
+ }
568
+
569
+ const properties = { ...runtimeProps, rotation: newRotation };
570
+
571
+ await commitMutation(
572
+ selection,
573
+ {
574
+ type: "add-keyframe",
575
+ animationId: anim.id,
576
+ percentage: pct,
577
+ properties,
578
+ backfillDefaults,
579
+ },
580
+ { label: `Rotate (keyframe ${pct}%)`, softReload: true },
581
+ );
582
+ return true;
583
+ }
584
+
585
+ export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";