@hyperframes/studio 0.6.91 → 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 (47) 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/components/StudioPreviewArea.tsx +48 -13
  7. package/src/components/TimelineToolbar.tsx +0 -21
  8. package/src/components/editor/DomEditOverlay.tsx +79 -0
  9. package/src/components/editor/PropertyPanel.tsx +19 -10
  10. package/src/components/editor/gsapAnimatesProperty.ts +30 -0
  11. package/src/components/editor/manualEditingAvailability.ts +10 -6
  12. package/src/components/editor/manualEditsDom.ts +25 -5
  13. package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
  14. package/src/components/editor/manualEditsDomPatches.ts +17 -1
  15. package/src/components/editor/manualEditsSnapshot.ts +16 -0
  16. package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
  17. package/src/components/editor/useOffScreenIndicators.ts +197 -0
  18. package/src/components/nle/NLELayout.tsx +16 -14
  19. package/src/contexts/DomEditContext.tsx +4 -0
  20. package/src/hooks/gsapDragCommit.ts +119 -43
  21. package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
  22. package/src/hooks/gsapRuntimeBridge.ts +266 -41
  23. package/src/hooks/gsapRuntimeReaders.ts +16 -2
  24. package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
  25. package/src/hooks/useDomEditCommits.ts +7 -1
  26. package/src/hooks/useDomEditSession.ts +13 -0
  27. package/src/hooks/useEnableKeyframes.ts +3 -1
  28. package/src/hooks/useGestureCommit.ts +99 -13
  29. package/src/hooks/useGestureRecording.ts +18 -2
  30. package/src/hooks/useGsapScriptCommits.ts +24 -3
  31. package/src/hooks/useGsapSelectionHandlers.ts +19 -3
  32. package/src/hooks/useGsapTweenCache.ts +30 -10
  33. package/src/hooks/useRazorSplit.ts +2 -7
  34. package/src/player/components/ClipContextMenu.tsx +9 -4
  35. package/src/player/components/KeyframeDiamondContextMenu.tsx +14 -93
  36. package/src/player/components/Timeline.tsx +7 -3
  37. package/src/player/components/TimelineClipDiamonds.tsx +3 -1
  38. package/src/player/store/playerStore.ts +12 -0
  39. package/src/utils/globalTimeCompiler.test.ts +2 -2
  40. package/src/utils/globalTimeCompiler.ts +2 -1
  41. package/src/utils/gsapSoftReload.test.ts +16 -0
  42. package/src/utils/gsapSoftReload.ts +43 -8
  43. package/src/utils/rdpSimplify.ts +3 -2
  44. package/src/utils/timelineElementSplit.test.ts +50 -0
  45. package/src/utils/timelineElementSplit.ts +16 -0
  46. package/dist/assets/index-CgYcO2PV.js +0 -146
  47. package/dist/assets/index-D2NkPomd.css +0 -1
@@ -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.
@@ -7,7 +7,7 @@ import {
7
7
  canSplitElement,
8
8
  buildPatchTarget,
9
9
  readFileContent,
10
- SPLIT_BOUNDARY_EPSILON_S,
10
+ isSplitTimeWithinBounds,
11
11
  } from "../utils/timelineElementSplit";
12
12
  import type { RecordEditInput } from "./useTimelineEditing";
13
13
 
@@ -171,12 +171,7 @@ export function useRazorSplit({
171
171
  const pid = projectIdRef.current;
172
172
  if (!pid || !canSplitElement(element)) return;
173
173
 
174
- const clipStart = element.start;
175
- const clipEnd = element.start + element.duration;
176
- if (
177
- splitTime <= clipStart + SPLIT_BOUNDARY_EPSILON_S ||
178
- splitTime >= clipEnd - SPLIT_BOUNDARY_EPSILON_S
179
- ) {
174
+ if (!isSplitTimeWithinBounds(splitTime, element.start, element.duration)) {
180
175
  return;
181
176
  }
182
177
 
@@ -1,4 +1,5 @@
1
1
  import { memo } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import type { TimelineElement } from "../store/playerStore";
3
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
4
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
@@ -24,8 +25,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
24
25
  }: ClipContextMenuProps) {
25
26
  const menuRef = useContextMenuDismiss(onClose);
26
27
 
27
- const adjustedX = Math.min(x, window.innerWidth - 200);
28
- const adjustedY = Math.min(y, window.innerHeight - 200);
28
+ const menuWidth = 200;
29
+ const menuHeight = 80;
30
+ const overflowY = y + menuHeight - window.innerHeight;
31
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
32
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
29
33
 
30
34
  const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
31
35
  const canSplit =
@@ -37,7 +41,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
37
41
  ? `Split at ${currentTime.toFixed(2)}s`
38
42
  : "Split (move playhead inside clip)";
39
43
 
40
- return (
44
+ return createPortal(
41
45
  <div
42
46
  ref={menuRef}
43
47
  className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
@@ -78,6 +82,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
78
82
  <span>Delete</span>
79
83
  <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
80
84
  </button>
81
- </div>
85
+ </div>,
86
+ document.body,
82
87
  );
83
88
  });
@@ -1,5 +1,5 @@
1
- import { memo, useRef } from "react";
2
- import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
1
+ import { memo } from "react";
2
+ import { createPortal } from "react-dom";
3
3
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
4
 
5
5
  export interface KeyframeDiamondContextMenuState {
@@ -7,6 +7,7 @@ export interface KeyframeDiamondContextMenuState {
7
7
  y: number;
8
8
  elementId: string;
9
9
  percentage: number;
10
+ tweenPercentage?: number;
10
11
  currentEase?: string;
11
12
  }
12
13
 
@@ -15,105 +16,36 @@ interface KeyframeDiamondContextMenuProps {
15
16
  onClose: () => void;
16
17
  onDelete: (elementId: string, percentage: number) => void;
17
18
  onDeleteAll: (elementId: string) => void;
18
- onChangeEase: (elementId: string, percentage: number, ease: string) => void;
19
- onCopyProperties: (elementId: string, percentage: number) => void;
19
+ onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
20
+ onCopyProperties?: (elementId: string, percentage: number) => void;
20
21
  }
21
22
 
22
- const EASE_PRESETS = [
23
- "none",
24
- "power1.out",
25
- "power2.out",
26
- "power3.out",
27
- "power1.in",
28
- "power2.in",
29
- "power1.inOut",
30
- "power2.inOut",
31
- "back.out",
32
- "elastic.out",
33
- "bounce.out",
34
- "expo.out",
35
- ] as const;
36
-
37
23
  export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
38
24
  state,
39
25
  onClose,
40
26
  onDelete,
41
27
  onDeleteAll,
42
- onChangeEase,
43
- onCopyProperties,
44
28
  }: KeyframeDiamondContextMenuProps) {
45
29
  const menuRef = useContextMenuDismiss(onClose);
46
- const easeSubmenuRef = useRef<HTMLDivElement>(null);
47
-
48
- const adjustedX = Math.min(state.x, window.innerWidth - 200);
49
- const adjustedY = Math.min(state.y, window.innerHeight - 300);
50
30
 
51
- const currentEaseLabel = state.currentEase
52
- ? (EASE_LABELS[state.currentEase] ?? state.currentEase)
53
- : "Default";
31
+ const menuWidth = 200;
32
+ const menuHeight = 70;
33
+ const overflowY = state.y + menuHeight - window.innerHeight;
34
+ const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
35
+ const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
54
36
 
55
- return (
37
+ return createPortal(
56
38
  <div
57
39
  ref={menuRef}
58
40
  className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
59
41
  style={{ left: adjustedX, top: adjustedY }}
60
42
  >
61
- {/* Ease submenu */}
62
- <div className="relative group">
63
- <button
64
- type="button"
65
- className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
66
- >
67
- <span>
68
- Ease: <span className="text-neutral-500">{currentEaseLabel}</span>
69
- </span>
70
- <svg width="8" height="8" viewBox="0 0 8 8" className="text-neutral-500 ml-2">
71
- <path d="M3 1l4 3-4 3" fill="none" stroke="currentColor" strokeWidth="1.2" />
72
- </svg>
73
- </button>
74
- <div
75
- ref={easeSubmenuRef}
76
- className="absolute left-full top-0 ml-0.5 hidden group-hover:block bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px] max-h-[300px] overflow-y-auto"
77
- >
78
- {EASE_PRESETS.map((ease) => (
79
- <button
80
- key={ease}
81
- type="button"
82
- className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-neutral-800 cursor-pointer text-left ${
83
- ease === state.currentEase ? "text-white font-medium" : "text-neutral-300"
84
- }`}
85
- onClick={() => {
86
- onChangeEase(state.elementId, state.percentage, ease);
87
- onClose();
88
- }}
89
- >
90
- {ease === state.currentEase && (
91
- <svg
92
- width="8"
93
- height="8"
94
- viewBox="0 0 8 8"
95
- className="text-green-400 flex-shrink-0"
96
- >
97
- <path d="M1 4l2 2 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
98
- </svg>
99
- )}
100
- <span className={ease === state.currentEase ? "" : "ml-[16px]"}>
101
- {EASE_LABELS[ease] ?? ease}
102
- </span>
103
- </button>
104
- ))}
105
- </div>
106
- </div>
107
-
108
- {/* Separator */}
109
- <div className="my-1 border-t border-neutral-700/60" />
110
-
111
43
  {/* Delete */}
112
44
  <button
113
45
  type="button"
114
46
  className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
115
47
  onClick={() => {
116
- onDelete(state.elementId, state.percentage);
48
+ onDelete(state.elementId, state.tweenPercentage ?? state.percentage);
117
49
  onClose();
118
50
  }}
119
51
  >
@@ -130,18 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
130
62
  >
131
63
  Delete All Keyframes
132
64
  </button>
133
-
134
- {/* Copy Properties */}
135
- <button
136
- type="button"
137
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
138
- onClick={() => {
139
- onCopyProperties(state.elementId, state.percentage);
140
- onClose();
141
- }}
142
- >
143
- Copy Properties
144
- </button>
145
- </div>
65
+ </div>,
66
+ document.body,
146
67
  );
147
68
  });
@@ -448,6 +448,9 @@ export const Timeline = memo(function Timeline({
448
448
  onSelectElement?.(el);
449
449
  const absTime = el.start + (pct / 100) * el.duration;
450
450
  onSeek?.(absTime);
451
+ const kfData = keyframeCache?.get(elKey);
452
+ const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
453
+ usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
451
454
  }}
452
455
  onShiftClickKeyframe={(elId, pct) => {
453
456
  toggleSelectedKeyframe(`${elId}:${pct}`);
@@ -464,12 +467,13 @@ export const Timeline = memo(function Timeline({
464
467
  onSeek?.(absTime);
465
468
  }
466
469
  const kfData = keyframeCache.get(elId);
467
- const kf = kfData?.keyframes.find((k) => k.percentage === pct);
470
+ const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
468
471
  setKfContextMenu({
469
- x: e.clientX,
470
- y: e.clientY,
472
+ x: e.clientX + 4,
473
+ y: e.clientY + 2,
471
474
  elementId: elId,
472
475
  percentage: pct,
476
+ tweenPercentage: kf?.tweenPercentage,
473
477
  currentEase: kf?.ease ?? kfData?.ease,
474
478
  });
475
479
  }}