@hyperframes/studio 0.6.88 → 0.6.89

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 (49) hide show
  1. package/dist/assets/index-2SbRRd33.js +146 -0
  2. package/dist/assets/index-D2NkPomd.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +33 -193
  6. package/src/components/StudioLeftSidebar.tsx +6 -0
  7. package/src/components/StudioRightPanel.tsx +8 -0
  8. package/src/components/TimelineToolbar.tsx +54 -31
  9. package/src/components/editor/AnimationCard.tsx +15 -3
  10. package/src/components/editor/DomEditOverlay.test.ts +34 -1
  11. package/src/components/editor/FileTree.tsx +5 -1
  12. package/src/components/editor/FileTreeNodes.tsx +17 -3
  13. package/src/components/editor/LayersPanel.tsx +19 -4
  14. package/src/components/editor/PropertyPanel.tsx +82 -170
  15. package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
  16. package/src/components/editor/gsapAnimatesProperty.ts +52 -0
  17. package/src/components/editor/manualEditsDom.ts +11 -57
  18. package/src/components/editor/manualOffsetDrag.test.ts +18 -1
  19. package/src/components/editor/manualOffsetDrag.ts +16 -10
  20. package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
  21. package/src/components/editor/propertyPanelHelpers.ts +76 -0
  22. package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
  23. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  24. package/src/components/editor/useLayerDrag.ts +6 -3
  25. package/src/components/renders/RenderQueueItem.tsx +47 -46
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -2
  27. package/src/components/sidebar/LeftSidebar.tsx +11 -0
  28. package/src/hooks/gsapDragCommit.ts +294 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +49 -402
  31. package/src/hooks/gsapRuntimeReaders.ts +201 -0
  32. package/src/hooks/timelineEditingHelpers.ts +148 -0
  33. package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
  34. package/src/hooks/useBlockHandlers.ts +150 -0
  35. package/src/hooks/useClipboard.ts +1 -10
  36. package/src/hooks/useDomEditPreviewSync.ts +126 -0
  37. package/src/hooks/useDomEditSession.ts +11 -79
  38. package/src/hooks/useGestureCommit.ts +166 -0
  39. package/src/hooks/useGestureRecording.ts +271 -169
  40. package/src/hooks/useGsapScriptCommits.ts +7 -80
  41. package/src/hooks/useLintModal.ts +97 -25
  42. package/src/hooks/useTimelineEditing.ts +10 -132
  43. package/src/player/components/TimelineCanvas.tsx +24 -7
  44. package/src/player/components/useTimelinePlayhead.ts +2 -1
  45. package/src/player/store/playerStore.ts +12 -0
  46. package/src/utils/gsapSoftReload.ts +18 -1
  47. package/src/utils/studioUrlState.test.ts +9 -0
  48. package/dist/assets/index-B9_ctmee.js +0 -143
  49. package/dist/assets/index-CGlIm_-E.css +0 -1
@@ -10,14 +10,15 @@
10
10
  */
11
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
-
14
13
  import { usePlayerStore } from "../player/store/playerStore";
15
- import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
14
+
15
+ import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
16
16
  import {
17
- absoluteToPercentage,
18
- resolveTweenStart,
19
- resolveTweenDuration,
20
- } from "../utils/globalTimeCompiler";
17
+ commitGsapPositionFromDrag,
18
+ computeCurrentPercentage,
19
+ materializeIfDynamic,
20
+ } from "./gsapDragCommit";
21
+ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
21
22
 
22
23
  // ── Runtime reads ──────────────────────────────────────────────────────────
23
24
 
@@ -59,31 +60,40 @@ function readGsapPositionFromIframe(
59
60
  // ── Animation matching ─────────────────────────────────────────────────────
60
61
 
61
62
  // fallow-ignore-next-line complexity
62
- function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null {
63
- // Prefer animations that already have x/y
64
- for (const anim of animations) {
65
- if (anim.keyframes) {
66
- const hasPos = anim.keyframes.keyframes.some(
67
- (kf) => "x" in kf.properties || "y" in kf.properties,
68
- );
69
- if (hasPos) return anim;
70
- }
71
- const props = anim.properties;
72
- const fromProps = anim.fromProperties;
73
- if (anim.method === "fromTo") {
74
- if ("x" in props || "y" in props || (fromProps && ("x" in fromProps || "y" in fromProps))) {
75
- return anim;
76
- }
77
- } else if ("x" in props || "y" in props) {
78
- return anim;
79
- }
80
- }
81
- // Fall back to any keyframed animation — drag will add x/y to it
82
- for (const anim of animations) {
83
- if (anim.keyframes) return anim;
63
+ function animHasPosition(anim: GsapAnimation): boolean {
64
+ if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
65
+ return true;
66
+ if (anim.method === "fromTo") {
67
+ const from = anim.fromProperties;
68
+ return (
69
+ "x" in anim.properties || "y" in anim.properties || !!(from && ("x" in from || "y" in from))
70
+ );
84
71
  }
85
- // Fall back to any animation will be converted to keyframes
86
- return animations[0] ?? null;
72
+ return "x" in anim.properties || "y" in anim.properties;
73
+ }
74
+
75
+ function findGsapPositionAnimation(
76
+ animations: GsapAnimation[],
77
+ selector?: string,
78
+ ): GsapAnimation | null {
79
+ if (animations.length === 0) return null;
80
+ const currentTime = usePlayerStore.getState().currentTime;
81
+
82
+ const scored = animations
83
+ .filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
84
+ .map((a) => {
85
+ let score = 0;
86
+ if (animHasPosition(a)) score += 10;
87
+ if (a.keyframes) score += 5;
88
+ if (selector && a.targetSelector === selector) score += 8;
89
+ else if (a.targetSelector.includes(",")) score -= 5;
90
+ const pos = typeof a.position === "number" ? a.position : 0;
91
+ const dur = a.duration ?? 0;
92
+ if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
93
+ return { anim: a, score };
94
+ });
95
+ scored.sort((a, b) => b.score - a.score);
96
+ return scored[0]?.anim ?? animations[0];
87
97
  }
88
98
 
89
99
  // ── Selector resolution ────────────────────────────────────────────────────
@@ -94,94 +104,16 @@ function selectorForSelection(selection: DomEditSelection): string | null {
94
104
  return null;
95
105
  }
96
106
 
97
- // ── Percentage computation ─────────────────────────────────────────────────
98
-
99
- function computeCurrentPercentage(selection: DomEditSelection, animation?: GsapAnimation): number {
100
- const currentTime = usePlayerStore.getState().currentTime;
101
- if (animation) {
102
- const start = resolveTweenStart(animation);
103
- const duration = resolveTweenDuration(animation);
104
- if (start !== null) {
105
- return absoluteToPercentage(currentTime, start, duration);
106
- }
107
- }
108
- const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
109
- const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
110
- return elDuration > 0
111
- ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
112
- : 0;
113
- }
114
-
115
- // ── Dynamic keyframe materialization ──────────────────────────────────────
116
-
117
- async function materializeIfDynamic(
118
- anim: GsapAnimation,
119
- iframe: HTMLIFrameElement | null,
120
- commitMutation: GsapDragCommitCallbacks["commitMutation"],
121
- selection: DomEditSelection,
122
- ): Promise<string | void> {
123
- if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
124
-
125
- if (anim.hasUnresolvedSelector) {
126
- // Unroll: read ALL elements' keyframes from runtime and replace the loop
127
- const allScanned = scanAllRuntimeKeyframes(iframe);
128
- if (allScanned.size === 0) return;
129
- const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
130
- selector: `#${id}`,
131
- keyframes: data.keyframes,
132
- easeEach: data.easeEach,
133
- }));
134
- await commitMutation(
135
- selection,
136
- {
137
- type: "materialize-keyframes",
138
- animationId: anim.id,
139
- keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
140
- allElements,
141
- },
142
- { label: "Unroll dynamic animations", skipReload: true },
143
- );
144
- return `${anim.targetSelector}-to-0`;
145
- }
146
-
147
- const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
148
- if (!runtime || runtime.keyframes.length === 0) return;
149
- await commitMutation(
150
- selection,
151
- {
152
- type: "materialize-keyframes",
153
- animationId: anim.id,
154
- keyframes: runtime.keyframes,
155
- easeEach: runtime.easeEach,
156
- },
157
- { label: "Materialize dynamic keyframes", skipReload: true },
158
- );
159
- }
160
-
161
107
  // ── High-level intercept ───────────────────────────────────────────────────
162
108
 
163
- export interface GsapDragCommitCallbacks {
164
- commitMutation: (
165
- selection: DomEditSelection,
166
- mutation: Record<string, unknown>,
167
- options: {
168
- label: string;
169
- coalesceKey?: string;
170
- softReload?: boolean;
171
- skipReload?: boolean;
172
- beforeReload?: () => void;
173
- },
174
- ) => Promise<void>;
175
- }
109
+ export type { GsapDragCommitCallbacks };
176
110
 
177
111
  /**
178
112
  * Attempt to handle a drag commit via the GSAP script mutation path.
179
113
  *
180
114
  * Returns a Promise that resolves to true if the drag was handled via GSAP
181
115
  * (caller should skip the CSS path), or false if no GSAP position animation
182
- * exists. The promise resolves only AFTER the mutation has been persisted and
183
- * the preview soft-reloaded — the CSS offset stays visible until then so the
184
- * element doesn't snap back during the async gap.
116
+ * exists.
185
117
  */
186
118
  // fallow-ignore-next-line complexity
187
119
  export async function tryGsapDragIntercept(
@@ -192,16 +124,16 @@ export async function tryGsapDragIntercept(
192
124
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
193
125
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
194
126
  ): Promise<boolean> {
195
- let posAnim = findGsapPositionAnimation(animations);
127
+ const selector = selectorForSelection(selection);
128
+ if (!selector) return false;
129
+
130
+ let posAnim = findGsapPositionAnimation(animations, selector);
196
131
  if (!posAnim && fetchFallbackAnimations) {
197
132
  const fresh = await fetchFallbackAnimations();
198
- posAnim = findGsapPositionAnimation(fresh);
133
+ posAnim = findGsapPositionAnimation(fresh, selector);
199
134
  }
200
135
  if (!posAnim) return false;
201
136
 
202
- const selector = selectorForSelection(selection);
203
- if (!selector) return false;
204
-
205
137
  // Keyframe writes at 0%/100% when outside the tween range. Acceptable
206
138
  // trade-off — CSS path must NEVER touch GSAP-targeted elements because
207
139
  // changing the CSS offset corrupts all existing keyframes (baked mismatch).
@@ -215,294 +147,9 @@ export async function tryGsapDragIntercept(
215
147
  return true;
216
148
  }
217
149
 
218
- // ── Commit helpers ─────────────────────────────────────────────────────────
219
-
220
- /**
221
- * Compute the new GSAP position values from runtime-read positions + drag
222
- * offset, then commit the mutation to the GSAP script.
223
- *
224
- * `gsap.getProperty` reads from GSAP's internal cache (element._gsap), not
225
- * from the DOM transform matrix. The strip in `applyStudioPathOffset` does
226
- * not affect the cached values, so the formula is simply:
227
- * newValue = cachedGsapValue + dragOffset
228
- *
229
- * For flat tweens (to/set), the mutation would change the tween endpoint,
230
- * which is invisible at t=0. Instead, we convert to keyframes first so the
231
- * position is set at the exact seek percentage via a keyframe.
232
- */
233
- // fallow-ignore-next-line complexity
234
- async function commitGsapPositionFromDrag(
235
- selection: DomEditSelection,
236
- anim: GsapAnimation,
237
- studioOffset: { x: number; y: number },
238
- gsapPos: { x: number; y: number },
239
- iframe: HTMLIFrameElement | null,
240
- selector: string,
241
- callbacks: GsapDragCommitCallbacks,
242
- ): Promise<void> {
243
- // CSS composition: translate → rotate → transform. The studioOffset is in
244
- // pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate
245
- // space (CSS transform). Counter-rotate the offset to match GSAP's frame.
246
- const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
247
- const rotDeg = Number.parseFloat(rotStyle) || 0;
248
- const rad = (-rotDeg * Math.PI) / 180;
249
- const cos = Math.cos(rad);
250
- const sin = Math.sin(rad);
251
- const el = selection.element;
252
- const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
253
- const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
254
- const deltaX = studioOffset.x - origX;
255
- const deltaY = studioOffset.y - origY;
256
- const adjX = deltaX * cos - deltaY * sin;
257
- const adjY = deltaX * sin + deltaY * cos;
258
- // Use the GSAP base captured at drag start — the live gsapPos is corrupted
259
- // by the draft's gsap.set() calls during drag.
260
- const baseGsapX =
261
- Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x;
262
- const baseGsapY =
263
- Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y;
264
- const newX = Math.round(baseGsapX + adjX);
265
- const newY = Math.round(baseGsapY + adjY);
266
- // Restore the CSS offset to pre-drag value so the baked translate stays
267
- // consistent with existing keyframes. The drag is captured in the new keyframe.
268
- const restoreOffset = () => {
269
- el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
270
- el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
271
- el.removeAttribute("data-hf-drag-initial-offset-x");
272
- el.removeAttribute("data-hf-drag-initial-offset-y");
273
- };
274
-
275
- if (anim.keyframes) {
276
- const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
277
- const effectiveAnim = newId ? { ...anim, id: newId } : anim;
278
- const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
279
-
280
- // Check if current time is outside the tween's range — extend the tween
281
- // to cover the playhead, remap existing keyframes, then add the new one.
282
- const ct = usePlayerStore.getState().currentTime;
283
- const ts = resolveTweenStart(effectiveAnim);
284
- const td = resolveTweenDuration(effectiveAnim);
285
- if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
286
- await extendTweenAndAddKeyframe(
287
- selection,
288
- effectiveAnim,
289
- { ...runtimeProps, x: newX, y: newY },
290
- ct,
291
- ts,
292
- td,
293
- callbacks,
294
- restoreOffset,
295
- );
296
- } else {
297
- await commitKeyframedPosition(
298
- selection,
299
- effectiveAnim,
300
- { ...runtimeProps, x: newX, y: newY },
301
- callbacks,
302
- restoreOffset,
303
- );
304
- }
305
- } else if (anim.method === "from" || anim.method === "fromTo") {
306
- // from()/fromTo() — convert to keyframes in a single mutation, placing
307
- // the dragged position at the 100% (rest) keyframe. A single mutation
308
- // avoids the stable-id flip (from→to) that breaks chained mutations.
309
- await callbacks.commitMutation(
310
- selection,
311
- {
312
- type: "convert-to-keyframes",
313
- animationId: anim.id,
314
- resolvedFromValues: { x: newX, y: newY },
315
- },
316
- { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
317
- );
318
- } else {
319
- // Flat to()/set() — convert to keyframes then add at current percentage.
320
- const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
321
- await commitFlatViaKeyframes(
322
- selection,
323
- anim,
324
- { ...runtimeProps, x: newX, y: newY },
325
- callbacks,
326
- restoreOffset,
327
- );
328
- }
329
- }
330
-
331
- /**
332
- * Extend a tween's time range to cover `targetTime`, remap all existing
333
- * keyframe percentages to preserve their absolute positions, then add
334
- * a new keyframe at the target time.
335
- */
336
- async function extendTweenAndAddKeyframe(
337
- selection: DomEditSelection,
338
- anim: GsapAnimation,
339
- properties: Record<string, number>,
340
- targetTime: number,
341
- tweenStart: number,
342
- tweenDuration: number,
343
- callbacks: GsapDragCommitCallbacks,
344
- beforeReload?: () => void,
345
- ): Promise<void> {
346
- const tweenEnd = tweenStart + tweenDuration;
347
- const newStart = Math.min(targetTime, tweenStart);
348
- const newEnd = Math.max(targetTime, tweenEnd);
349
- const newDuration = Math.max(0.01, newEnd - newStart);
350
-
351
- // Step 1: Remap all existing keyframes to preserve their absolute times
352
- // in the new range, then add the new keyframe.
353
- const existingKfs = anim.keyframes?.keyframes ?? [];
354
- const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
355
- [];
356
- for (const kf of existingKfs) {
357
- const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
358
- const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
359
- remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
360
- }
361
-
362
- // Add the new keyframe at the target time
363
- const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
364
- remappedKfs.push({ percentage: targetPct, properties });
365
-
366
- // Sort and dedupe
367
- remappedKfs.sort((a, b) => a.percentage - b.percentage);
368
-
369
- // Step 2: Delete the old tween and create a new one with the extended range
370
- // and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair.
371
- await callbacks.commitMutation(
372
- selection,
373
- { type: "delete", animationId: anim.id },
374
- { label: "Extend tween range", skipReload: true },
375
- );
376
-
377
- const selector = anim.targetSelector;
378
- await callbacks.commitMutation(
379
- selection,
380
- {
381
- type: "add-with-keyframes",
382
- targetSelector: selector,
383
- position: Math.round(newStart * 1000) / 1000,
384
- duration: Math.round(newDuration * 1000) / 1000,
385
- keyframes: remappedKfs,
386
- },
387
- { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
388
- );
389
- }
390
-
391
- // fallow-ignore-next-line complexity
392
- async function commitKeyframedPosition(
393
- selection: DomEditSelection,
394
- anim: GsapAnimation,
395
- properties: Record<string, number>,
396
- callbacks: GsapDragCommitCallbacks,
397
- beforeReload?: () => void,
398
- ): Promise<void> {
399
- const pct = computeCurrentPercentage(selection, anim);
400
-
401
- await callbacks.commitMutation(
402
- selection,
403
- {
404
- type: "add-keyframe",
405
- animationId: anim.id,
406
- percentage: pct,
407
- properties,
408
- },
409
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
410
- );
411
- }
412
-
413
- /**
414
- * For flat to()/set() tweens, convert to keyframes first so we can place the
415
- * drag position at the current percentage. Without conversion, the mutation
416
- * only changes the tween endpoint, which is invisible at t=0.
417
- */
418
- // fallow-ignore-next-line complexity
419
- async function commitFlatViaKeyframes(
420
- selection: DomEditSelection,
421
- anim: GsapAnimation,
422
- properties: Record<string, number>,
423
- callbacks: GsapDragCommitCallbacks,
424
- beforeReload?: () => void,
425
- ): Promise<void> {
426
- await callbacks.commitMutation(
427
- selection,
428
- { type: "convert-to-keyframes", animationId: anim.id },
429
- { label: "Convert to keyframes for drag", skipReload: true },
430
- );
150
+ // ── Runtime property readers (re-exported for external callers) ───────────
431
151
 
432
- const pct = computeCurrentPercentage(selection, anim);
433
-
434
- await callbacks.commitMutation(
435
- selection,
436
- {
437
- type: "add-keyframe",
438
- animationId: anim.id,
439
- percentage: pct,
440
- properties,
441
- },
442
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
443
- );
444
- }
445
-
446
- // ── Runtime property reader ───────────────────────────────────────────────
447
-
448
- export function readGsapProperty(
449
- iframe: HTMLIFrameElement | null,
450
- selector: string | null,
451
- prop: string,
452
- ): number | null {
453
- if (!iframe?.contentWindow || !selector) return null;
454
- try {
455
- const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
456
- if (!gsap?.getProperty) return null;
457
- const el = iframe.contentDocument?.querySelector(selector);
458
- if (!el) return null;
459
- const val = Number(gsap.getProperty(el, prop));
460
- return Number.isFinite(val) ? Math.round(val) : null;
461
- } catch {
462
- return null;
463
- }
464
- }
465
-
466
- export function readAllAnimatedProperties(
467
- iframe: HTMLIFrameElement | null,
468
- selector: string,
469
- anim: GsapAnimation,
470
- ): Record<string, number> {
471
- const result: Record<string, number> = {};
472
- if (!iframe?.contentWindow) return result;
473
- let gsap: IframeGsap | undefined;
474
- try {
475
- gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
476
- } catch {
477
- return result;
478
- }
479
- if (!gsap?.getProperty) return result;
480
- let doc: Document | null = null;
481
- try {
482
- doc = iframe.contentDocument;
483
- } catch {
484
- return result;
485
- }
486
- const el = doc?.querySelector(selector);
487
- if (!el) return result;
488
-
489
- const propKeys = new Set<string>();
490
- if (anim.keyframes) {
491
- for (const kf of anim.keyframes.keyframes) {
492
- for (const p of Object.keys(kf.properties)) {
493
- if (typeof kf.properties[p] === "number") propKeys.add(p);
494
- }
495
- }
496
- } else {
497
- for (const p of Object.keys(anim.properties)) propKeys.add(p);
498
- }
499
-
500
- for (const prop of propKeys) {
501
- const val = Number(gsap.getProperty(el, prop));
502
- if (Number.isFinite(val)) result[prop] = Math.round(val);
503
- }
504
- return result;
505
- }
152
+ export { readGsapProperty, readAllAnimatedProperties };
506
153
 
507
154
  // ── Resize intercept ──────────────────────────────────────────────────────
508
155
 
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit.
3
+ */
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+
6
+ interface IframeGsap {
7
+ getProperty: (el: Element, prop: string) => number;
8
+ }
9
+
10
+ export function readGsapProperty(
11
+ iframe: HTMLIFrameElement | null,
12
+ selector: string | null,
13
+ prop: string,
14
+ ): number | null {
15
+ if (!iframe?.contentWindow || !selector) return null;
16
+ try {
17
+ const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
18
+ if (!gsap?.getProperty) return null;
19
+ const el = iframe.contentDocument?.querySelector(selector);
20
+ if (!el) return null;
21
+ const val = Number(gsap.getProperty(el, prop));
22
+ return Number.isFinite(val) ? Math.round(val) : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
29
+ const GSAP_CONFIG_KEYS = new Set([
30
+ "duration",
31
+ "ease",
32
+ "delay",
33
+ "stagger",
34
+ "id",
35
+ "onComplete",
36
+ "onUpdate",
37
+ "onStart",
38
+ "onRepeat",
39
+ "repeat",
40
+ "yoyo",
41
+ "repeatDelay",
42
+ "paused",
43
+ "immediateRender",
44
+ "lazy",
45
+ "overwrite",
46
+ "keyframes",
47
+ "parent",
48
+ ]);
49
+
50
+ export function readAllAnimatedProperties(
51
+ iframe: HTMLIFrameElement | null,
52
+ selector: string,
53
+ anim: GsapAnimation,
54
+ ): Record<string, number> {
55
+ const result: Record<string, number> = {};
56
+ if (!iframe?.contentWindow) return result;
57
+ let gsap: IframeGsap | undefined;
58
+ try {
59
+ gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
60
+ } catch {
61
+ return result;
62
+ }
63
+ if (!gsap?.getProperty) return result;
64
+ let doc: Document | null = null;
65
+ try {
66
+ doc = iframe.contentDocument;
67
+ } catch {
68
+ return result;
69
+ }
70
+ const el = doc?.querySelector(selector);
71
+ if (!el) return result;
72
+
73
+ const propKeys = new Set<string>();
74
+ if (anim.keyframes) {
75
+ for (const kf of anim.keyframes.keyframes) {
76
+ for (const p of Object.keys(kf.properties)) {
77
+ if (typeof kf.properties[p] === "number") propKeys.add(p);
78
+ }
79
+ }
80
+ } else {
81
+ for (const p of Object.keys(anim.properties)) propKeys.add(p);
82
+ }
83
+
84
+ for (const prop of propKeys) {
85
+ const val = Number(gsap.getProperty(el, prop));
86
+ if (Number.isFinite(val)) {
87
+ result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
88
+ }
89
+ }
90
+
91
+ const otherTweenProps = new Set<string>();
92
+ try {
93
+ const win = iframe.contentWindow as unknown as { __timelines?: Record<string, unknown> };
94
+ const timelines = win.__timelines;
95
+ if (timelines) {
96
+ for (const tl of Object.values(timelines)) {
97
+ const tlObj = tl as {
98
+ getChildren?: (
99
+ deep: boolean,
100
+ ) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
101
+ };
102
+ if (!tlObj?.getChildren) continue;
103
+ for (const child of tlObj.getChildren(true)) {
104
+ if (typeof child.targets !== "function") continue;
105
+ const targets = child.targets();
106
+ if (!targets.includes(el)) continue;
107
+ const vars = child.vars;
108
+ if (!vars) continue;
109
+ for (const k of Object.keys(vars)) {
110
+ if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ } catch (e) {
116
+ console.warn(
117
+ "Cross-tween guard failed — baseline capture may include values from other tweens",
118
+ e,
119
+ );
120
+ }
121
+ for (const p of propKeys) otherTweenProps.delete(p);
122
+
123
+ // Tier 1: Transform + visual properties with universal CSS defaults.
124
+ // Safe to compare against hardcoded values — these are always 0 or 1
125
+ // regardless of the element's stylesheet.
126
+ const UNIVERSAL_BASELINE: Record<string, number> = {
127
+ opacity: 1,
128
+ scale: 1,
129
+ scaleX: 1,
130
+ scaleY: 1,
131
+ scaleZ: 1,
132
+ rotation: 0,
133
+ rotationX: 0,
134
+ rotationY: 0,
135
+ skewX: 0,
136
+ skewY: 0,
137
+ z: 0,
138
+ xPercent: 0,
139
+ yPercent: 0,
140
+ transformPerspective: 0,
141
+ blur: 0,
142
+ brightness: 1,
143
+ contrast: 1,
144
+ saturate: 1,
145
+ hueRotate: 0,
146
+ grayscale: 0,
147
+ sepia: 0,
148
+ invert: 0,
149
+ };
150
+ for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
151
+ if (prop in result) continue;
152
+ if (otherTweenProps.has(prop)) continue;
153
+ const val = Number(gsap.getProperty(el, prop));
154
+ if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
155
+ result[prop] = Math.round(val * 1000) / 1000;
156
+ }
157
+ }
158
+
159
+ // Tier 2: Element-dependent properties — their "default" depends on the
160
+ // stylesheet, so we compare GSAP's runtime value against the element's
161
+ // computed CSS value. Only capture if GSAP has actively changed it.
162
+ const COMPUTED_BASELINE = [
163
+ "borderRadius",
164
+ "borderTopLeftRadius",
165
+ "borderTopRightRadius",
166
+ "borderBottomLeftRadius",
167
+ "borderBottomRightRadius",
168
+ "letterSpacing",
169
+ "wordSpacing",
170
+ "lineHeight",
171
+ "fontSize",
172
+ "outlineOffset",
173
+ "outlineWidth",
174
+ "strokeDashoffset",
175
+ "strokeWidth",
176
+ "backgroundPositionX",
177
+ "backgroundPositionY",
178
+ ];
179
+ let computedStyle: CSSStyleDeclaration | null = null;
180
+ try {
181
+ computedStyle = doc?.defaultView?.getComputedStyle(el) ?? null;
182
+ } catch {}
183
+ for (const prop of COMPUTED_BASELINE) {
184
+ if (prop in result) continue;
185
+ if (otherTweenProps.has(prop)) continue;
186
+ const gsapVal = Number(gsap.getProperty(el, prop));
187
+ if (!Number.isFinite(gsapVal)) continue;
188
+ let cssVal = NaN;
189
+ if (computedStyle) {
190
+ const raw = computedStyle.getPropertyValue(
191
+ prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),
192
+ );
193
+ cssVal = parseFloat(raw);
194
+ }
195
+ if (Number.isFinite(cssVal) && Math.round(gsapVal * 1000) === Math.round(cssVal * 1000))
196
+ continue;
197
+ result[prop] = Math.round(gsapVal * 1000) / 1000;
198
+ }
199
+
200
+ return result;
201
+ }