@hyperframes/studio 0.6.87 → 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-BA19FAPN.js +0 -143
  49. package/dist/assets/index-CGlIm_-E.css +0 -1
@@ -18,6 +18,128 @@ interface AccumulatedState {
18
18
  z: number;
19
19
  }
20
20
 
21
+ interface BasePosition {
22
+ baseX: number;
23
+ baseY: number;
24
+ baseOpacity: number;
25
+ baseScale: number;
26
+ cssOffX: number;
27
+ cssOffY: number;
28
+ }
29
+
30
+ interface GsapRuntime {
31
+ seek: (t: number) => void;
32
+ set: (target: string, vars: Record<string, number>) => void;
33
+ selector: string;
34
+ element: HTMLElement;
35
+ startTime: number;
36
+ maxSeekTime: number;
37
+ savedVisibility: string;
38
+ savedTranslate: string;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Extracted helpers — pure functions, no refs, no React.
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function readBasePosition(element: HTMLElement, iframeEl: HTMLIFrameElement): BasePosition {
46
+ let baseOpacity = 1;
47
+ let baseScale = 1;
48
+ let baseX = 0;
49
+ let baseY = 0;
50
+
51
+ try {
52
+ const gsap = (
53
+ iframeEl.contentWindow as Window & {
54
+ gsap?: { getProperty: (el: Element, prop: string) => number };
55
+ }
56
+ ).gsap;
57
+ if (gsap?.getProperty) {
58
+ baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
59
+ baseScale = Number(gsap.getProperty(element, "scaleX")) || 1;
60
+ baseX = Number(gsap.getProperty(element, "x")) || 0;
61
+ baseY = Number(gsap.getProperty(element, "y")) || 0;
62
+ }
63
+ } catch {
64
+ /* cross-origin guard */
65
+ }
66
+
67
+ // Path-offset CSS vars live on the element regardless of whether
68
+ // translate is currently var-based or "none" (GSAP-baked).
69
+ const cssOffX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
70
+ const cssOffY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
71
+ const translateVal = element.style.translate ?? "";
72
+ if (translateVal.includes("var(")) {
73
+ baseX += cssOffX;
74
+ baseY += cssOffY;
75
+ }
76
+
77
+ return { baseX, baseY, baseOpacity, baseScale, cssOffX, cssOffY };
78
+ }
79
+
80
+ function connectGsapRuntime(
81
+ element: HTMLElement,
82
+ iframeEl: HTMLIFrameElement,
83
+ selector: string | null,
84
+ elementEndTime: number | undefined,
85
+ ): GsapRuntime | null {
86
+ try {
87
+ const win = iframeEl.contentWindow as Window & {
88
+ gsap?: { set: (t: string, v: Record<string, number>) => void };
89
+ __timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
90
+ __player?: { getTime: () => number };
91
+ };
92
+ const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
93
+ if (win?.gsap?.set && tl?.seek && selector) {
94
+ const tlDuration = tl.duration();
95
+ return {
96
+ seek: tl.seek.bind(tl),
97
+ set: win.gsap.set.bind(win.gsap),
98
+ selector,
99
+ element,
100
+ startTime: win.__player?.getTime() ?? 0,
101
+ maxSeekTime:
102
+ elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
103
+ savedVisibility: element.style.visibility,
104
+ savedTranslate: element.style.getPropertyValue("translate"),
105
+ };
106
+ }
107
+ } catch {
108
+ /* cross-origin or missing runtime */
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function applyRuntimePreview(
114
+ runtime: GsapRuntime,
115
+ time: number,
116
+ properties: Record<string, number>,
117
+ ): void {
118
+ const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
119
+ runtime.seek(seekTime);
120
+ runtime.element.style.setProperty("translate", "none");
121
+ runtime.set(runtime.selector, { ...properties });
122
+ runtime.element.style.visibility = "visible";
123
+ liveTime.notify(seekTime);
124
+ usePlayerStore.getState().setCurrentTime(seekTime);
125
+ }
126
+
127
+ function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
128
+ const sampleProps = { ...properties };
129
+ if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x;
130
+ if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y;
131
+ r.samples.push({ time, properties: sampleProps });
132
+ r.trail.push({ x: r.pointer.x, y: r.pointer.y });
133
+ }
134
+
135
+ function computeIframeScale(iframeEl: HTMLIFrameElement): number {
136
+ const iframeRect = iframeEl.getBoundingClientRect();
137
+ const doc = iframeEl.contentDocument;
138
+ const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
139
+ const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
140
+ return declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
141
+ }
142
+
21
143
  function resolveGestureProperties(
22
144
  dx: number,
23
145
  dy: number,
@@ -63,6 +185,53 @@ function resolveGestureProperties(
63
185
  };
64
186
  }
65
187
 
188
+ // ---------------------------------------------------------------------------
189
+ // Grouped mutable state carried across the recording session.
190
+ // Replaces 14 individual useRef calls with a single ref object.
191
+ // ---------------------------------------------------------------------------
192
+
193
+ interface RecordingRefs {
194
+ pointer: { x: number; y: number };
195
+ startPointer: { x: number; y: number };
196
+ hasMoved: boolean;
197
+ scrollDelta: number;
198
+ modifiers: Modifiers;
199
+ accumulated: AccumulatedState;
200
+ basePosition: { x: number; y: number };
201
+ cssVarOffset: { x: number; y: number };
202
+ scale: number;
203
+ pointerElementOffset: { x: number; y: number };
204
+ runtime: GsapRuntime | null;
205
+ rafId: number;
206
+ samples: GestureSample[];
207
+ trail: Array<{ x: number; y: number }>;
208
+ cleanup: (() => void) | null;
209
+ }
210
+
211
+ function createRecordingRefs(): RecordingRefs {
212
+ return {
213
+ pointer: { x: 0, y: 0 },
214
+ startPointer: { x: 0, y: 0 },
215
+ hasMoved: false,
216
+ scrollDelta: 0,
217
+ modifiers: { shift: false, alt: false, meta: false },
218
+ accumulated: { opacity: 1, scale: 1, z: 0 },
219
+ basePosition: { x: 0, y: 0 },
220
+ cssVarOffset: { x: 0, y: 0 },
221
+ scale: 1,
222
+ pointerElementOffset: { x: 0, y: 0 },
223
+ runtime: null,
224
+ rafId: 0,
225
+ samples: [],
226
+ trail: [],
227
+ cleanup: null,
228
+ };
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Hook
233
+ // ---------------------------------------------------------------------------
234
+
66
235
  export function useGestureRecording() {
67
236
  const [isRecording, setIsRecording] = useState(false);
68
237
  const [recordingDuration, setRecordingDuration] = useState(0);
@@ -71,34 +240,18 @@ export function useGestureRecording() {
71
240
  // startRecording and stopRecording check this ref, not the useState value.
72
241
  const isRecordingRef = useRef(false);
73
242
 
74
- const pointerRef = useRef({ x: 0, y: 0 });
75
- const startPointerRef = useRef({ x: 0, y: 0 });
76
- const scrollDeltaRef = useRef(0);
77
- const modifiersRef = useRef<Modifiers>({ shift: false, alt: false, meta: false });
78
- const accumulatedRef = useRef<AccumulatedState>({ opacity: 1, scale: 1, z: 0 });
79
- const basePositionRef = useRef({ x: 0, y: 0 });
80
- const scaleRef = useRef(1);
81
- const hasMovedRef = useRef(false);
82
- const pointerElementOffsetRef = useRef({ x: 0, y: 0 });
83
- const runtimeRef = useRef<{
84
- seek: (t: number) => void;
85
- set: (target: string, vars: Record<string, number>) => void;
86
- selector: string;
87
- element: HTMLElement;
88
- startTime: number;
89
- maxSeekTime: number;
90
- } | null>(null);
91
-
92
- const rafIdRef = useRef(0);
93
- const samplesRef = useRef<GestureSample[]>([]);
94
- const trailRef = useRef<Array<{ x: number; y: number }>>([]);
95
- const cleanupRef = useRef<(() => void) | null>(null);
243
+ const refs = useRef<RecordingRefs>(createRecordingRefs());
244
+
245
+ // Stable reference aliases for the return value — consumers read these directly.
246
+ const samplesRef = useRef<GestureSample[]>(refs.current.samples);
247
+ const trailRef = useRef<Array<{ x: number; y: number }>>(refs.current.trail);
96
248
 
97
249
  // Unmount safety: cancel RAF + remove listeners if component tears down mid-recording.
98
250
  useEffect(() => {
251
+ const r = refs.current;
99
252
  return () => {
100
- cleanupRef.current?.();
101
- cleanupRef.current = null;
253
+ r.cleanup?.();
254
+ r.cleanup = null;
102
255
  isRecordingRef.current = false;
103
256
  };
104
257
  }, []);
@@ -108,109 +261,58 @@ export function useGestureRecording() {
108
261
  if (isRecordingRef.current) return;
109
262
  isRecordingRef.current = true;
110
263
 
111
- samplesRef.current = [];
112
- trailRef.current = [];
113
- hasMovedRef.current = false;
264
+ const r = refs.current;
265
+ r.samples = [];
266
+ r.trail = [];
267
+ r.hasMoved = false;
268
+ r.scrollDelta = 0;
269
+ samplesRef.current = r.samples;
270
+ trailRef.current = r.trail;
114
271
  setRecordingDuration(0);
115
- scrollDeltaRef.current = 0;
116
-
117
- let baseOpacity = 1;
118
- let baseScaleVal = 1;
119
- let baseX = 0;
120
- let baseY = 0;
121
- try {
122
- const gsap = (
123
- iframeEl.contentWindow as Window & {
124
- gsap?: { getProperty: (el: Element, prop: string) => number };
125
- }
126
- ).gsap;
127
- if (gsap?.getProperty) {
128
- baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
129
- baseScaleVal = Number(gsap.getProperty(element, "scaleX")) || 1;
130
- baseX = Number(gsap.getProperty(element, "x")) || 0;
131
- baseY = Number(gsap.getProperty(element, "y")) || 0;
132
- }
133
- } catch {
134
- /* cross-origin guard */
135
- }
136
- // When reapplyPathOffsets has run (translate restored to var-based),
137
- // GSAP's cache was stripped — gsapX is 0 but the element is visually
138
- // at CSSLeft + translate(offset). gsap.set wipes translate, so we need
139
- // baseX to include the offset. When translate is "none" (GSAP owns it),
140
- // gsapX already includes the baked offset — don't add.
141
- const translateVal = element.style.translate ?? "";
142
- if (translateVal.includes("var(")) {
143
- const offX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
144
- const offY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
145
- baseX += offX;
146
- baseY += offY;
272
+
273
+ // --- Phase 1: Read base position from GSAP + CSS vars ---
274
+ const base = readBasePosition(element, iframeEl);
275
+ r.cssVarOffset = { x: base.cssOffX, y: base.cssOffY };
276
+ r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 };
277
+ r.basePosition = { x: base.baseX, y: base.baseY };
278
+
279
+ if (base.cssOffX || base.cssOffY) {
280
+ element.style.setProperty("--hf-studio-offset-x", "0px");
281
+ element.style.setProperty("--hf-studio-offset-y", "0px");
147
282
  }
148
- accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 };
149
- basePositionRef.current = { x: baseX, y: baseY };
150
283
 
284
+ // --- Phase 2: Connect to the iframe GSAP runtime ---
151
285
  const selector = element.id ? `#${element.id}` : null;
152
- try {
153
- const win = iframeEl.contentWindow as Window & {
154
- gsap?: { set: (t: string, v: Record<string, number>) => void };
155
- __timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
156
- __player?: { getTime: () => number };
157
- };
158
- const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
159
- if (win?.gsap?.set && tl?.seek && selector) {
160
- const tlDuration = tl.duration();
161
- runtimeRef.current = {
162
- seek: tl.seek.bind(tl),
163
- set: win.gsap.set.bind(win.gsap),
164
- selector,
165
- element,
166
- startTime: win.__player?.getTime() ?? 0,
167
- maxSeekTime:
168
- elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
169
- };
170
- }
171
- } catch {
172
- runtimeRef.current = null;
173
- }
286
+ r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
174
287
 
175
- const iframeRect = iframeEl.getBoundingClientRect();
176
- const doc = iframeEl.contentDocument;
177
- const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
178
- const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
179
- scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
288
+ // --- Phase 3: Compute iframe viewport → composition scale ---
289
+ r.scale = computeIframeScale(iframeEl);
180
290
 
181
- // Compute the offset between the element's visual center and the pointer
182
- // so the element tracks the pointer exactly during recording (no jump).
291
+ // --- Phase 4: Element center for pointer-element offset ---
292
+ // element.getBoundingClientRect() is in the iframe's viewport.
293
+ // Convert to the studio (parent) viewport using the iframe's position and scale.
294
+ const iframeRect = iframeEl.getBoundingClientRect();
183
295
  const elRect = element.getBoundingClientRect();
296
+ const iframeScale = r.scale || 1;
184
297
  const elCenterViewport = {
185
- x: elRect.left + elRect.width / 2,
186
- y: elRect.top + elRect.height / 2,
298
+ x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale,
299
+ y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale,
187
300
  };
188
- pointerElementOffsetRef.current = { x: 0, y: 0 }; // reset; set on first move
301
+ r.pointerElementOffset = { x: 0, y: 0 };
189
302
 
303
+ // --- Phase 5: Attach event listeners ---
190
304
  const handlePointerMove = (e: PointerEvent) => {
191
- pointerRef.current = { x: e.clientX, y: e.clientY };
192
- modifiersRef.current = {
193
- shift: e.shiftKey,
194
- alt: e.altKey,
195
- meta: e.metaKey || e.ctrlKey,
196
- };
305
+ r.pointer = { x: e.clientX, y: e.clientY };
306
+ r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
197
307
  };
198
308
 
199
309
  const handleWheel = (e: WheelEvent) => {
200
- scrollDeltaRef.current += e.deltaY;
201
- modifiersRef.current = {
202
- shift: e.shiftKey,
203
- alt: e.altKey,
204
- meta: e.metaKey || e.ctrlKey,
205
- };
310
+ r.scrollDelta += e.deltaY;
311
+ r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
206
312
  };
207
313
 
208
314
  const handleKeyChange = (e: KeyboardEvent) => {
209
- modifiersRef.current = {
210
- shift: e.shiftKey,
211
- alt: e.altKey,
212
- meta: e.metaKey || e.ctrlKey,
213
- };
315
+ r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
214
316
  };
215
317
 
216
318
  document.addEventListener("pointermove", handlePointerMove, { passive: true });
@@ -218,86 +320,70 @@ export function useGestureRecording() {
218
320
  document.addEventListener("keydown", handleKeyChange, { passive: true });
219
321
  document.addEventListener("keyup", handleKeyChange, { passive: true });
220
322
 
221
- startPointerRef.current = { ...pointerRef.current };
222
323
  const startMs = performance.now();
223
324
 
224
- let startCaptured = false;
325
+ r.startPointer = { ...r.pointer };
225
326
  const captureStart = (e: PointerEvent) => {
226
- if (!startCaptured) {
227
- startPointerRef.current = { x: e.clientX, y: e.clientY };
228
- // Compute the offset between the pointer and the element center
229
- // so the element follows the pointer without jumping.
230
- pointerElementOffsetRef.current = {
231
- x: e.clientX - elCenterViewport.x,
232
- y: e.clientY - elCenterViewport.y,
233
- };
234
- startCaptured = true;
235
- hasMovedRef.current = true;
327
+ if (!r.hasMoved) {
328
+ r.startPointer = { x: e.clientX, y: e.clientY };
329
+ const offX = e.clientX - elCenterViewport.x;
330
+ const offY = e.clientY - elCenterViewport.y;
331
+ r.pointerElementOffset = { x: offX, y: offY };
332
+ r.basePosition.x += offX / iframeScale;
333
+ r.basePosition.y += offY / iframeScale;
334
+ r.hasMoved = true;
236
335
  }
237
336
  };
238
337
  document.addEventListener("pointermove", captureStart, { passive: true, once: true });
239
338
 
339
+ // --- Phase 6: RAF tick loop ---
240
340
  const tick = () => {
241
341
  if (!isRecordingRef.current) return;
242
342
  const now = performance.now();
243
343
  const time = (now - startMs) / 1000;
244
- const scale = scaleRef.current || 1;
245
- const dx = (pointerRef.current.x - startPointerRef.current.x) / scale;
246
- const dy = (pointerRef.current.y - startPointerRef.current.y) / scale;
247
- const scrollDelta = scrollDeltaRef.current;
248
-
249
- // Skip zero-displacement samples before the pointer has moved.
250
- if (!hasMovedRef.current && dx === 0 && dy === 0 && scrollDelta === 0) {
251
- rafIdRef.current = requestAnimationFrame(tick);
344
+
345
+ const scale = r.scale || 1;
346
+ const dx = (r.pointer.x - r.startPointer.x) / scale;
347
+ const dy = (r.pointer.y - r.startPointer.y) / scale;
348
+ const scrollDelta = r.scrollDelta;
349
+
350
+ if (!r.hasMoved && dx === 0 && dy === 0 && scrollDelta === 0) {
351
+ r.rafId = requestAnimationFrame(tick);
252
352
  return;
253
353
  }
254
- hasMovedRef.current = true;
354
+ r.hasMoved = true;
255
355
 
256
356
  const { properties, nextState } = resolveGestureProperties(
257
357
  dx,
258
358
  dy,
259
359
  scrollDelta,
260
- modifiersRef.current,
261
- accumulatedRef.current,
360
+ r.modifiers,
361
+ r.accumulated,
262
362
  );
263
- if ("x" in properties) properties.x = Math.round(basePositionRef.current.x + properties.x);
264
- if ("y" in properties) properties.y = Math.round(basePositionRef.current.y + properties.y);
265
-
266
- accumulatedRef.current = nextState;
267
- scrollDeltaRef.current = 0;
268
-
269
- // Manual seek on the raw GSAP timeline (not the Studio player wrapper,
270
- // which triggers React state updates). After seek renders all elements
271
- // at the correct time, gsap.set overrides the recorded element so it
272
- // follows the pointer. The browser paints the set values on this frame;
273
- // next tick's seek will overwrite, but we re-apply immediately.
274
- if (runtimeRef.current) {
363
+ if ("x" in properties) properties.x = Math.round(r.basePosition.x + properties.x);
364
+ if ("y" in properties) properties.y = Math.round(r.basePosition.y + properties.y);
365
+
366
+ r.accumulated = nextState;
367
+ r.scrollDelta = 0;
368
+
369
+ if (r.runtime) {
275
370
  try {
276
- const seekTime = Math.min(
277
- runtimeRef.current.startTime + time,
278
- runtimeRef.current.maxSeekTime,
279
- );
280
- runtimeRef.current.seek(seekTime);
281
- runtimeRef.current.set(runtimeRef.current.selector, { ...properties });
282
- runtimeRef.current.element.style.visibility = "visible";
283
- liveTime.notify(seekTime);
284
- usePlayerStore.getState().setCurrentTime(seekTime);
371
+ applyRuntimePreview(r.runtime, time, properties);
285
372
  } catch {
286
- runtimeRef.current = null;
373
+ r.runtime = null;
287
374
  }
288
375
  }
289
376
 
290
- samplesRef.current.push({ time, properties });
291
- trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y });
377
+ recordSample(r, time, properties);
292
378
  setRecordingDuration(time);
293
- rafIdRef.current = requestAnimationFrame(tick);
379
+ r.rafId = requestAnimationFrame(tick);
294
380
  };
295
381
 
296
382
  setIsRecording(true);
297
- rafIdRef.current = requestAnimationFrame(tick);
383
+ r.rafId = requestAnimationFrame(tick);
298
384
 
299
- cleanupRef.current = () => {
300
- cancelAnimationFrame(rafIdRef.current);
385
+ r.cleanup = () => {
386
+ cancelAnimationFrame(r.rafId);
301
387
  document.removeEventListener("pointermove", handlePointerMove);
302
388
  document.removeEventListener("wheel", handleWheel);
303
389
  document.removeEventListener("keydown", handleKeyChange);
@@ -311,21 +397,37 @@ export function useGestureRecording() {
311
397
  const stopRecording = useCallback((): GestureSample[] => {
312
398
  if (!isRecordingRef.current) return [];
313
399
  isRecordingRef.current = false;
314
- runtimeRef.current = null;
315
- cleanupRef.current?.();
316
- cleanupRef.current = null;
317
- const frozen = samplesRef.current.slice();
400
+ const r = refs.current;
401
+ if (r.runtime) {
402
+ const { element: el, savedVisibility, savedTranslate } = r.runtime;
403
+ el.style.visibility = savedVisibility;
404
+ el.style.setProperty("translate", savedTranslate || "");
405
+ }
406
+ if (r.cssVarOffset.x || r.cssVarOffset.y) {
407
+ const el = r.runtime?.element;
408
+ if (el) {
409
+ el.style.setProperty("--hf-studio-offset-x", `${r.cssVarOffset.x}px`);
410
+ el.style.setProperty("--hf-studio-offset-y", `${r.cssVarOffset.y}px`);
411
+ }
412
+ }
413
+ r.runtime = null;
414
+ r.cleanup?.();
415
+ r.cleanup = null;
416
+ const frozen = r.samples.slice();
318
417
  setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0);
319
418
  setIsRecording(false);
320
419
  return frozen;
321
420
  }, []); // No deps — uses refs only
322
421
 
323
422
  const clearSamples = useCallback(() => {
324
- samplesRef.current = [];
325
- trailRef.current = [];
423
+ const r = refs.current;
424
+ r.samples = [];
425
+ r.trail = [];
426
+ samplesRef.current = r.samples;
427
+ trailRef.current = r.trail;
326
428
  setRecordingDuration(0);
327
- accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
328
- scrollDeltaRef.current = 0;
429
+ r.accumulated = { opacity: 1, scale: 1, z: 0 };
430
+ r.scrollDelta = 0;
329
431
  }, []);
330
432
 
331
433
  return {
@@ -4,8 +4,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
5
  import { applySoftReload } from "../utils/gsapSoftReload";
6
6
  import { executeOptimistic } from "../utils/optimisticUpdate";
7
- import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
+ import type { KeyframeCacheEntry } from "../player/store/playerStore";
8
8
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
9
+ import {
10
+ updateKeyframeCacheFromParsed,
11
+ readKeyframeSnapshot,
12
+ writeKeyframeCache,
13
+ } from "./gsapKeyframeCacheHelpers";
9
14
 
10
15
  const PROPERTY_DEFAULTS: Record<string, number> = {
11
16
  opacity: 1,
@@ -72,84 +77,6 @@ async function mutateGsapScript(
72
77
  return null;
73
78
  }
74
79
  }
75
- function updateKeyframeCacheFromParsed(
76
- animations: GsapAnimation[],
77
- targetPath: string,
78
- selectionId: string | undefined,
79
- mutation: Record<string, unknown>,
80
- ): void {
81
- const { setKeyframeCache, elements } = usePlayerStore.getState();
82
- const idsWithKeyframes = new Set<string>();
83
- const merged = new Map<string, KeyframeCacheEntry>();
84
- for (const anim of animations) {
85
- const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
86
- if (!id || !anim.keyframes) continue;
87
- idsWithKeyframes.add(id);
88
-
89
- // Convert tween-relative percentages to clip-relative so diamonds
90
- // render at the correct position within the timeline clip.
91
- const tweenPos = typeof anim.position === "number" ? anim.position : 0;
92
- const tweenDur = anim.duration ?? 1;
93
- const timelineEl = elements.find(
94
- (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
95
- );
96
- const elStart = timelineEl?.start ?? 0;
97
- const elDuration = timelineEl?.duration ?? 4;
98
- const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
99
- const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
100
- const clipPct =
101
- elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
102
- return { ...kf, percentage: clipPct };
103
- });
104
-
105
- const existing = merged.get(id);
106
- if (existing) {
107
- const byPct = new Map<number, (typeof existing.keyframes)[0]>();
108
- for (const kf of [...existing.keyframes, ...clipKeyframes]) {
109
- const prev = byPct.get(kf.percentage);
110
- if (prev) {
111
- prev.properties = { ...prev.properties, ...kf.properties };
112
- if (kf.ease) prev.ease = kf.ease;
113
- } else {
114
- byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
115
- }
116
- }
117
- existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
118
- } else {
119
- merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
120
- }
121
- }
122
- for (const [id, entry] of merged) {
123
- setKeyframeCache(`${targetPath}#${id}`, entry);
124
- setKeyframeCache(id, entry);
125
- if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
126
- }
127
- const targetId =
128
- (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
129
- selectionId;
130
- if (targetId && !idsWithKeyframes.has(targetId)) {
131
- setKeyframeCache(`${targetPath}#${targetId}`, undefined);
132
- if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
133
- }
134
- }
135
- function buildCacheKey(sourceFile: string, elementId: string): string {
136
- return `${sourceFile}#${elementId}`;
137
- }
138
- function readKeyframeSnapshot(
139
- sourceFile: string,
140
- elementId: string | null | undefined,
141
- ): KeyframeCacheEntry | undefined {
142
- if (!elementId) return undefined;
143
- return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
144
- }
145
- function writeKeyframeCache(
146
- sourceFile: string,
147
- elementId: string | null | undefined,
148
- data: KeyframeCacheEntry | undefined,
149
- ): void {
150
- if (!elementId) return;
151
- usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
152
- }
153
80
  interface GsapScriptCommitsParams {
154
81
  projectIdRef: React.MutableRefObject<string | null>;
155
82
  activeCompPath: string | null;
@@ -319,7 +246,7 @@ export function useGsapScriptCommits({
319
246
  (selection: DomEditSelection, animationId: string) => {
320
247
  void commitMutation(
321
248
  selection,
322
- { type: "delete", animationId },
249
+ { type: "delete", animationId, stripStudioEdits: true },
323
250
  { label: "Delete GSAP animation" },
324
251
  );
325
252
  },