@hyperframes/studio 0.6.90 → 0.6.92

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 (58) hide show
  1. package/dist/assets/{index-DSLrl2tB.js → index-CDy8BuGq.js} +24 -24
  2. package/dist/assets/index-CmRIkCwI.js +251 -0
  3. package/dist/assets/index-rm9tn9nH.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2 -0
  7. package/src/components/StudioPreviewArea.tsx +54 -13
  8. package/src/components/TimelineToolbar.tsx +52 -35
  9. package/src/components/editor/DomEditOverlay.tsx +79 -0
  10. package/src/components/editor/PropertyPanel.tsx +19 -10
  11. package/src/components/editor/gsapAnimatesProperty.ts +30 -0
  12. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  13. package/src/components/editor/manualEditingAvailability.ts +16 -0
  14. package/src/components/editor/manualEditsDom.ts +25 -5
  15. package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
  16. package/src/components/editor/manualEditsDomPatches.ts +17 -1
  17. package/src/components/editor/manualEditsSnapshot.ts +16 -0
  18. package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
  19. package/src/components/editor/useOffScreenIndicators.ts +197 -0
  20. package/src/components/nle/NLELayout.tsx +22 -32
  21. package/src/components/nle/TimelineEditorNotice.tsx +2 -25
  22. package/src/contexts/DomEditContext.tsx +4 -0
  23. package/src/hooks/gsapDragCommit.ts +119 -43
  24. package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
  25. package/src/hooks/gsapRuntimeBridge.ts +266 -41
  26. package/src/hooks/gsapRuntimeReaders.ts +16 -2
  27. package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
  28. package/src/hooks/useAppHotkeys.ts +48 -1
  29. package/src/hooks/useContextMenuDismiss.ts +29 -0
  30. package/src/hooks/useDomEditCommits.ts +7 -1
  31. package/src/hooks/useDomEditSession.ts +20 -4
  32. package/src/hooks/useEnableKeyframes.ts +3 -1
  33. package/src/hooks/useGestureCommit.ts +99 -13
  34. package/src/hooks/useGestureRecording.ts +18 -2
  35. package/src/hooks/useGsapScriptCommits.ts +24 -3
  36. package/src/hooks/useGsapSelectionHandlers.ts +19 -3
  37. package/src/hooks/useGsapTweenCache.ts +30 -10
  38. package/src/hooks/useRazorSplit.ts +298 -0
  39. package/src/hooks/useTimelineEditing.ts +15 -98
  40. package/src/player/components/ClipContextMenu.tsx +14 -25
  41. package/src/player/components/KeyframeDiamondContextMenu.tsx +16 -112
  42. package/src/player/components/PlayheadIndicator.tsx +43 -0
  43. package/src/player/components/Timeline.tsx +45 -38
  44. package/src/player/components/TimelineCanvas.tsx +29 -22
  45. package/src/player/components/TimelineClipDiamonds.tsx +3 -1
  46. package/src/player/components/timelineCallbacks.ts +44 -0
  47. package/src/player/components/timelineDragDrop.ts +2 -14
  48. package/src/player/components/useTimelineZoom.ts +18 -0
  49. package/src/player/store/playerStore.ts +20 -0
  50. package/src/utils/globalTimeCompiler.test.ts +2 -2
  51. package/src/utils/globalTimeCompiler.ts +2 -1
  52. package/src/utils/gsapSoftReload.test.ts +16 -0
  53. package/src/utils/gsapSoftReload.ts +43 -8
  54. package/src/utils/rdpSimplify.ts +3 -2
  55. package/src/utils/timelineElementSplit.test.ts +50 -0
  56. package/src/utils/timelineElementSplit.ts +32 -0
  57. package/dist/assets/index-BKuDHMYl.js +0 -146
  58. package/dist/assets/index-D2NkPomd.css +0 -1
@@ -1,7 +1,10 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { usePlayerStore } from "../player";
4
- import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
4
+ import {
5
+ STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
6
+ STUDIO_GSAP_PANEL_ENABLED,
7
+ } from "../components/editor/manualEditingAvailability";
5
8
  import { type DomEditSelection } from "../components/editor/domEditing";
6
9
  import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
7
10
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
@@ -259,6 +262,7 @@ export function useDomEditSession({
259
262
  updateGsapProperty,
260
263
  updateGsapMeta,
261
264
  deleteGsapAnimation,
265
+ deleteAllForSelector,
262
266
  addGsapAnimation,
263
267
  addGsapProperty,
264
268
  removeGsapProperty,
@@ -326,7 +330,15 @@ export function useDomEditSession({
326
330
  // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
327
331
  const handleGsapAwarePathOffsetCommit = useCallback(
328
332
  async (selection: DomEditSelection, next: { x: number; y: number }) => {
329
- if (gsapCommitMutation) {
333
+ const hasGsapAnims = selectedGsapAnimations.length > 0;
334
+ if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
335
+ showToast(
336
+ "This element is GSAP-animated — dragging via CSS would corrupt keyframes",
337
+ "error",
338
+ );
339
+ return;
340
+ }
341
+ if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
330
342
  const handled = await tryGsapDragIntercept(
331
343
  selection,
332
344
  next,
@@ -353,6 +365,7 @@ export function useDomEditSession({
353
365
  previewIframeRef,
354
366
  projectId,
355
367
  gsapSourceFile,
368
+ showToast,
356
369
  ],
357
370
  );
358
371
 
@@ -372,7 +385,7 @@ export function useDomEditSession({
372
385
 
373
386
  const handleGsapAwareBoxSizeCommit = useCallback(
374
387
  async (selection: DomEditSelection, next: { width: number; height: number }) => {
375
- if (gsapCommitMutation) {
388
+ if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
376
389
  const handled = await tryGsapResizeIntercept(
377
390
  selection,
378
391
  next,
@@ -396,7 +409,7 @@ export function useDomEditSession({
396
409
 
397
410
  const handleGsapAwareRotationCommit = useCallback(
398
411
  async (selection: DomEditSelection, next: { angle: number }) => {
399
- if (gsapCommitMutation) {
412
+ if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
400
413
  const handled = await tryGsapRotationIntercept(
401
414
  selection,
402
415
  next.angle,
@@ -422,6 +435,7 @@ export function useDomEditSession({
422
435
  handleGsapUpdateProperty,
423
436
  handleGsapUpdateMeta,
424
437
  handleGsapDeleteAnimation,
438
+ handleGsapDeleteAllForElement,
425
439
  handleGsapAddAnimation,
426
440
  handleGsapAddProperty,
427
441
  handleGsapRemoveProperty,
@@ -439,6 +453,7 @@ export function useDomEditSession({
439
453
  updateGsapProperty,
440
454
  updateGsapMeta,
441
455
  deleteGsapAnimation,
456
+ deleteAllForSelector,
442
457
  addGsapAnimation,
443
458
  addGsapProperty,
444
459
  removeGsapProperty,
@@ -547,6 +562,7 @@ export function useDomEditSession({
547
562
  handleGsapUpdateProperty,
548
563
  handleGsapUpdateMeta,
549
564
  handleGsapDeleteAnimation,
565
+ handleGsapDeleteAllForElement,
550
566
  handleGsapAddAnimation,
551
567
  handleGsapAddProperty,
552
568
  handleGsapRemoveProperty,
@@ -52,10 +52,12 @@ function readElementPosition(
52
52
  const element = sel.element;
53
53
  if (!element?.isConnected || !gsap?.getProperty) return result;
54
54
 
55
+ const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
55
56
  const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
56
57
  for (const prop of props) {
57
58
  const val = Number(gsap.getProperty(element, prop));
58
- if (Number.isFinite(val)) result[prop] = Math.round(val);
59
+ if (!Number.isFinite(val)) continue;
60
+ result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
59
61
  }
60
62
 
61
63
  return result;
@@ -7,10 +7,13 @@ import { useGestureRecording } from "./useGestureRecording";
7
7
  import { simplifyGestureSamples } from "../utils/rdpSimplify";
8
8
  import { usePlayerStore } from "../player";
9
9
  import type { DomEditSelection } from "../components/editor/domEditing";
10
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
+ import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
10
12
 
11
13
  // Minimal subset of the session used by gesture commit
12
14
  interface GestureSessionRef {
13
15
  domEditSelection: DomEditSelection | null;
16
+ selectedGsapAnimations?: GsapAnimation[];
14
17
  commitMutation?: (
15
18
  mutation: Record<string, unknown>,
16
19
  options: { label: string; softReload?: boolean },
@@ -43,6 +46,9 @@ export function useGestureCommit({
43
46
  const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
44
47
  const recordingStartTimeRef = useRef(0);
45
48
  const commitInFlightRef = useRef(false);
49
+ // Capture selection at recording start so commit always targets the recorded element,
50
+ // even if the user's selection changes mid-recording.
51
+ const capturedSelectionRef = useRef<DomEditSelection | null>(null);
46
52
 
47
53
  // Unmount: clear auto-stop interval
48
54
  useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
@@ -50,7 +56,9 @@ export function useGestureCommit({
50
56
  // fallow-ignore-next-line complexity
51
57
  const stopAndCommitRecording = useCallback(async () => {
52
58
  clearInterval(recordingAutoStopRef.current);
53
- if (commitInFlightRef.current) return;
59
+ if (commitInFlightRef.current) {
60
+ return;
61
+ }
54
62
  commitInFlightRef.current = true;
55
63
  gestureStateRef.current = "idle";
56
64
  isGestureRecordingRef.current = false;
@@ -59,7 +67,7 @@ export function useGestureCommit({
59
67
  store.setIsPlaying(false);
60
68
  try {
61
69
  const liveSession = domEditSessionRef.current;
62
- const sel = liveSession.domEditSelection;
70
+ const sel = capturedSelectionRef.current;
63
71
  if (!sel) {
64
72
  if (frozenSamples.length > 2) {
65
73
  showToast("Selection lost during recording", "error");
@@ -77,7 +85,13 @@ export function useGestureCommit({
77
85
  return;
78
86
  }
79
87
 
80
- const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
88
+ // Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10)
89
+ // need a much tighter tolerance than positional properties (x/y in px).
90
+ const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => {
91
+ if (key === "opacity") return 0.01;
92
+ if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01;
93
+ return 5;
94
+ });
81
95
  const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
82
96
 
83
97
  // Ensure a 0% keyframe exists with the element's start-of-recording position
@@ -97,19 +111,90 @@ export function useGestureCommit({
97
111
  percentage: pct,
98
112
  properties: simplified.get(pct) as Record<string, number | string>,
99
113
  }));
100
-
101
- await liveSession.commitMutation(
102
- {
103
- type: "add-with-keyframes",
104
- targetSelector: selector,
105
- position: Math.round(recStart * 1000) / 1000,
106
- duration: Math.round(duration * 1000) / 1000,
107
- keyframes,
108
- },
109
- { label: "Gesture recording", softReload: true },
114
+ const hasPositionProps = keyframes.some((kf) =>
115
+ Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
110
116
  );
117
+ const allAnims = liveSession.selectedGsapAnimations ?? [];
118
+ const existingPositionTween = hasPositionProps
119
+ ? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
120
+ : undefined;
121
+ if (existingPositionTween) {
122
+ const tweenStart = existingPositionTween.resolvedStart ?? 0;
123
+ const tweenDur = existingPositionTween.duration ?? duration;
124
+ const tweenEnd = tweenStart + tweenDur;
125
+ const recEnd = recStart + duration;
126
+
127
+ // Only merge if the recording overlaps the existing tween's time range.
128
+ // No overlap → fall through to add-with-keyframes (creates a separate tween).
129
+ const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
130
+
131
+ if (overlaps) {
132
+ const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
133
+ const rangeStartPct =
134
+ tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
135
+ const rangeEndPct =
136
+ tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
137
+
138
+ const preserved = existingKfs
139
+ .filter(
140
+ (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
141
+ )
142
+ .map((kf) => ({
143
+ percentage: kf.percentage,
144
+ properties: kf.properties,
145
+ ...(kf.ease ? { ease: kf.ease } : {}),
146
+ }));
147
+
148
+ const mapped = keyframes.map((kf) => ({
149
+ percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
150
+ properties: kf.properties,
151
+ }));
152
+
153
+ const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
154
+
155
+ await liveSession.commitMutation(
156
+ {
157
+ type: "replace-with-keyframes",
158
+ animationId: existingPositionTween.id,
159
+ targetSelector: selector,
160
+ position:
161
+ typeof existingPositionTween.position === "number"
162
+ ? existingPositionTween.position
163
+ : tweenStart,
164
+ duration: tweenDur,
165
+ keyframes: merged,
166
+ },
167
+ { label: "Gesture recording (merge)", softReload: true },
168
+ );
169
+ } else {
170
+ await liveSession.commitMutation(
171
+ {
172
+ type: "add-with-keyframes",
173
+ targetSelector: selector,
174
+ position: Math.round(recStart * 1000) / 1000,
175
+ duration: Math.round(duration * 1000) / 1000,
176
+ keyframes,
177
+ },
178
+ { label: "Gesture recording (new range)", softReload: true },
179
+ );
180
+ }
181
+ } else {
182
+ await liveSession.commitMutation(
183
+ {
184
+ type: "add-with-keyframes",
185
+ targetSelector: selector,
186
+ position: Math.round(recStart * 1000) / 1000,
187
+ duration: Math.round(duration * 1000) / 1000,
188
+ keyframes,
189
+ },
190
+ { label: "Gesture recording", softReload: true },
191
+ );
192
+ }
111
193
  }
112
194
  showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
195
+ } catch (err) {
196
+ console.error("[GR:error]", err);
197
+ showToast(`Gesture commit failed: ${err}`, "error");
113
198
  } finally {
114
199
  store.requestSeek(recordingStartTimeRef.current);
115
200
  gestureRecording.clearSamples();
@@ -139,6 +224,7 @@ export function useGestureCommit({
139
224
  const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
140
225
  const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
141
226
  const elementEnd = elDur > 0 ? elStart + elDur : undefined;
227
+ capturedSelectionRef.current = sel;
142
228
  gestureRecording.startRecording(sel.element, iframe, elementEnd);
143
229
  gestureStateRef.current = "recording";
144
230
  isGestureRecordingRef.current = true;
@@ -126,8 +126,12 @@ function applyRuntimePreview(
126
126
 
127
127
  function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
128
128
  const sampleProps = { ...properties };
129
- if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x;
130
- if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y;
129
+ // Subtract both the CSS var offset AND the pointer-element snap offset
130
+ // so the first sample doesn't include the snap-to-cursor jump.
131
+ if ("x" in sampleProps)
132
+ sampleProps.x -= r.cssVarOffset.x + r.pointerElementOffset.x / (r.scale || 1);
133
+ if ("y" in sampleProps)
134
+ sampleProps.y -= r.cssVarOffset.y + r.pointerElementOffset.y / (r.scale || 1);
131
135
  r.samples.push({ time, properties: sampleProps });
132
136
  r.trail.push({ x: r.pointer.x, y: r.pointer.y });
133
137
  }
@@ -307,6 +311,18 @@ export function useGestureRecording() {
307
311
  };
308
312
 
309
313
  const handleWheel = (e: WheelEvent) => {
314
+ // Capture startPointer on first wheel if no pointermove has fired yet,
315
+ // preventing an enormous bogus first keyframe from stale startPointer.
316
+ if (!r.hasMoved) {
317
+ r.startPointer = { x: r.pointer.x, y: r.pointer.y };
318
+ r.pointerElementOffset = {
319
+ x: r.pointer.x - elCenterViewport.x,
320
+ y: r.pointer.y - elCenterViewport.y,
321
+ };
322
+ r.basePosition.x += r.pointerElementOffset.x / iframeScale;
323
+ r.basePosition.y += r.pointerElementOffset.y / iframeScale;
324
+ r.hasMoved = true;
325
+ }
310
326
  r.scrollDelta += e.deltaY;
311
327
  r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
312
328
  };
@@ -51,6 +51,7 @@ function ensureElementAddressable(selection: DomEditSelection): {
51
51
 
52
52
  interface MutationResult {
53
53
  ok: boolean;
54
+ changed?: boolean;
54
55
  parsed?: ParsedGsap;
55
56
  before?: string;
56
57
  after?: string;
@@ -131,9 +132,16 @@ export function useGsapScriptCommits({
131
132
  const pid = projectIdRef.current;
132
133
  if (!pid) return;
133
134
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
134
-
135
135
  const result = await mutateGsapScript(pid, targetPath, mutation);
136
- if (!result?.ok) return;
136
+ if (!result) {
137
+ if (options.skipReload) return;
138
+ throw new Error(`Mutation failed: ${mutation.type}`);
139
+ }
140
+
141
+ if (result.changed === false) {
142
+ if (options.skipReload) return;
143
+ return;
144
+ }
137
145
 
138
146
  domEditSaveTimestampRef.current = Date.now();
139
147
 
@@ -252,6 +260,16 @@ export function useGsapScriptCommits({
252
260
  },
253
261
  [commitMutation],
254
262
  );
263
+ const deleteAllForSelector = useCallback(
264
+ (selection: DomEditSelection, targetSelector: string) => {
265
+ void commitMutation(
266
+ selection,
267
+ { type: "delete-all-for-selector", targetSelector },
268
+ { label: "Delete all animations for element" },
269
+ );
270
+ },
271
+ [commitMutation],
272
+ );
255
273
  const addGsapAnimation = useCallback(
256
274
  // fallow-ignore-next-line complexity
257
275
  async (
@@ -441,7 +459,9 @@ export function useGsapScriptCommits({
441
459
  apply: () => {
442
460
  const prev = readKeyframeSnapshot(sf, elementId);
443
461
  if (prev) {
444
- const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage);
462
+ const newKeyframes = prev.keyframes.filter(
463
+ (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
464
+ );
445
465
  writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
446
466
  }
447
467
  return prev;
@@ -548,6 +568,7 @@ export function useGsapScriptCommits({
548
568
  updateGsapProperty,
549
569
  updateGsapMeta,
550
570
  deleteGsapAnimation,
571
+ deleteAllForSelector,
551
572
  addGsapAnimation,
552
573
  addGsapProperty,
553
574
  removeGsapProperty,
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { usePlayerStore } from "../player";
4
4
 
@@ -13,6 +13,7 @@ export function useGsapSelectionHandlers({
13
13
  updateGsapProperty,
14
14
  updateGsapMeta,
15
15
  deleteGsapAnimation,
16
+ deleteAllForSelector,
16
17
  addGsapAnimation,
17
18
  addGsapProperty,
18
19
  removeGsapProperty,
@@ -40,6 +41,7 @@ export function useGsapSelectionHandlers({
40
41
  updates: { duration?: number; ease?: string; position?: number },
41
42
  ) => void;
42
43
  deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
44
+ deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
43
45
  addGsapAnimation: (
44
46
  sel: DomEditSelection,
45
47
  method: "to" | "from" | "set" | "fromTo",
@@ -79,6 +81,9 @@ export function useGsapSelectionHandlers({
79
81
  handleDomManualEditsReset: (sel: DomEditSelection) => void;
80
82
  selectedGsapAnimations: { id: string; keyframes?: unknown }[];
81
83
  }) {
84
+ const lastSelectionRef = useRef<DomEditSelection | null>(null);
85
+ if (domEditSelection) lastSelectionRef.current = domEditSelection;
86
+
82
87
  const handleGsapUpdateProperty = useCallback(
83
88
  (animId: string, prop: string, value: number | string) => {
84
89
  if (!domEditSelection) return;
@@ -97,12 +102,22 @@ export function useGsapSelectionHandlers({
97
102
 
98
103
  const handleGsapDeleteAnimation = useCallback(
99
104
  (animId: string) => {
100
- if (!domEditSelection) return;
101
- deleteGsapAnimation(domEditSelection, animId);
105
+ const sel = domEditSelection ?? lastSelectionRef.current;
106
+ if (!sel) return;
107
+ deleteGsapAnimation(sel, animId);
102
108
  },
103
109
  [domEditSelection, deleteGsapAnimation],
104
110
  );
105
111
 
112
+ const handleGsapDeleteAllForElement = useCallback(
113
+ (targetSelector: string) => {
114
+ const sel = domEditSelection ?? lastSelectionRef.current;
115
+ if (!sel) return;
116
+ deleteAllForSelector(sel, targetSelector);
117
+ },
118
+ [domEditSelection, deleteAllForSelector],
119
+ );
120
+
106
121
  const handleGsapAddAnimation = useCallback(
107
122
  (method: "to" | "from" | "set" | "fromTo") => {
108
123
  if (!domEditSelection) return;
@@ -205,6 +220,7 @@ export function useGsapSelectionHandlers({
205
220
  handleGsapUpdateProperty,
206
221
  handleGsapUpdateMeta,
207
222
  handleGsapDeleteAnimation,
223
+ handleGsapDeleteAllForElement,
208
224
  handleGsapAddAnimation,
209
225
  handleGsapAddProperty,
210
226
  handleGsapRemoveProperty,
@@ -264,9 +264,11 @@ export function useGsapAnimationsForElement(
264
264
  (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
265
265
  );
266
266
  const elStart = timelineEl?.start ?? 0;
267
- const elDuration = timelineEl?.duration ?? 4;
267
+ const elDuration = timelineEl?.duration ?? 1;
268
268
 
269
- const allKeyframes: GsapKeyframesData["keyframes"] = [];
269
+ const allKeyframes: Array<
270
+ GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
271
+ > = [];
270
272
  let format: GsapKeyframesData["format"] = "percentage";
271
273
  let ease: string | undefined;
272
274
  let easeEach: string | undefined;
@@ -275,7 +277,8 @@ export function useGsapAnimationsForElement(
275
277
  if (!kf) continue;
276
278
  // Convert tween-relative percentages to clip-relative so diamonds
277
279
  // render at the correct position within the timeline clip.
278
- const tweenPos = typeof anim.position === "number" ? anim.position : 0;
280
+ const tweenPos =
281
+ anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
279
282
  const tweenDur = anim.duration ?? elDuration;
280
283
  for (const k of kf.keyframes) {
281
284
  const absTime = tweenPos + (k.percentage / 100) * tweenDur;
@@ -283,7 +286,12 @@ export function useGsapAnimationsForElement(
283
286
  elDuration > 0
284
287
  ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
285
288
  : k.percentage;
286
- allKeyframes.push({ ...k, percentage: clipPct });
289
+ allKeyframes.push({
290
+ ...k,
291
+ percentage: clipPct,
292
+ tweenPercentage: k.percentage,
293
+ propertyGroup: anim.propertyGroup,
294
+ });
287
295
  }
288
296
  format = kf.format;
289
297
  if (kf.ease) ease = kf.ease;
@@ -305,6 +313,9 @@ export function useGsapAnimationsForElement(
305
313
  };
306
314
  const { setKeyframeCache } = usePlayerStore.getState();
307
315
  setKeyframeCache(`${sourceFile}#${elementId}`, merged);
316
+ // PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
317
+ // so write a duplicate entry under the bare key for cross-component lookups.
318
+ setKeyframeCache(elementId, merged);
308
319
  }, [elementId, sourceFile, animations]);
309
320
 
310
321
  return { animations, multipleTimelines, unsupportedTimelinePattern };
@@ -327,13 +338,14 @@ export function usePopulateKeyframeCacheForFile(
327
338
  version: number,
328
339
  iframeRef?: React.RefObject<HTMLIFrameElement | null>,
329
340
  ): void {
341
+ const elementCount = usePlayerStore((s) => s.elements.length);
330
342
  const lastFetchKeyRef = useRef("");
331
343
 
332
344
  const runtimeScanDoneRef = useRef("");
333
345
  const astFetchDoneRef = useRef("");
334
346
 
335
347
  useEffect(() => {
336
- const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
348
+ const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
337
349
  if (fetchKey === lastFetchKeyRef.current) return;
338
350
  lastFetchKeyRef.current = fetchKey;
339
351
  runtimeScanDoneRef.current = "";
@@ -358,21 +370,26 @@ export function usePopulateKeyframeCacheForFile(
358
370
  if (!id) continue;
359
371
  const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
360
372
  if (!kfData) continue;
361
- // Convert tween-relative percentages to clip-relative.
362
- const tweenPos = typeof anim.position === "number" ? anim.position : 0;
373
+ const tweenPos =
374
+ anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
363
375
  const tweenDur = anim.duration ?? 1;
364
376
  const timelineEl = elements.find(
365
377
  (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
366
378
  );
367
379
  const elStart = timelineEl?.start ?? 0;
368
- const elDuration = timelineEl?.duration ?? 4;
380
+ const elDuration = timelineEl?.duration ?? 1;
369
381
  const clipKeyframes = kfData.keyframes.map((kf) => {
370
382
  const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
371
383
  const clipPct =
372
384
  elDuration > 0
373
385
  ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
374
386
  : kf.percentage;
375
- return { ...kf, percentage: clipPct };
387
+ return {
388
+ ...kf,
389
+ percentage: clipPct,
390
+ tweenPercentage: kf.percentage,
391
+ propertyGroup: anim.propertyGroup,
392
+ };
376
393
  });
377
394
  const existing = mergedByElement.get(id);
378
395
  if (existing) {
@@ -388,7 +405,10 @@ export function usePopulateKeyframeCacheForFile(
388
405
  }
389
406
  astFetchDoneRef.current = fetchKey;
390
407
  });
391
- }, [projectId, sourceFile, version]);
408
+ // elementCount is in the deps because new timeline elements (e.g. after a
409
+ // sub-composition expand) need their keyframe cache populated immediately;
410
+ // without it the effect won't re-run when elements appear/disappear.
411
+ }, [projectId, sourceFile, version, elementCount]);
392
412
 
393
413
  // Separate effect for runtime keyframe discovery — polls until the iframe
394
414
  // has loaded GSAP timelines, independent of the AST fetch lifecycle.