@hyperframes/studio 0.6.73 → 0.6.75

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 (63) hide show
  1. package/dist/assets/index-DcyZuBcU.css +1 -0
  2. package/dist/assets/index-uB_W2GDl.js +140 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/LayersPanel.test.ts +135 -0
  16. package/src/components/editor/LayersPanel.tsx +151 -15
  17. package/src/components/editor/PropertyPanel.tsx +293 -140
  18. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  19. package/src/components/editor/SnapToolbar.tsx +163 -0
  20. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  21. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  23. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  25. package/src/components/editor/manualEditingAvailability.ts +6 -0
  26. package/src/components/editor/manualEditsDom.ts +56 -2
  27. package/src/components/editor/manualOffsetDrag.ts +19 -3
  28. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  29. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  30. package/src/components/editor/snapEngine.test.ts +657 -0
  31. package/src/components/editor/snapEngine.ts +575 -0
  32. package/src/components/editor/snapTargetCollection.ts +147 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  34. package/src/components/editor/useLayerDrag.ts +213 -0
  35. package/src/components/nle/NLELayout.tsx +18 -0
  36. package/src/contexts/DomEditContext.tsx +27 -0
  37. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  38. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  39. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  40. package/src/hooks/useAppHotkeys.ts +63 -1
  41. package/src/hooks/useDomEditCommits.ts +88 -4
  42. package/src/hooks/useDomEditSession.ts +179 -65
  43. package/src/hooks/useGsapScriptCommits.ts +144 -7
  44. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  45. package/src/hooks/useGsapTweenCache.ts +174 -3
  46. package/src/hooks/useTimelineEditing.ts +93 -0
  47. package/src/icons/SystemIcons.tsx +2 -0
  48. package/src/player/components/ClipContextMenu.tsx +99 -0
  49. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  50. package/src/player/components/Timeline.test.ts +2 -1
  51. package/src/player/components/Timeline.tsx +108 -68
  52. package/src/player/components/TimelineCanvas.tsx +47 -1
  53. package/src/player/components/TimelineClip.tsx +8 -3
  54. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  55. package/src/player/components/timelineDragDrop.ts +103 -0
  56. package/src/player/components/timelineLayout.ts +1 -1
  57. package/src/player/store/playerStore.ts +42 -0
  58. package/src/utils/editHistory.ts +1 -1
  59. package/src/utils/optimisticUpdate.test.ts +53 -0
  60. package/src/utils/optimisticUpdate.ts +18 -0
  61. package/src/utils/studioUiPreferences.ts +17 -0
  62. package/dist/assets/index-CrxThtSJ.css +0 -1
  63. package/dist/assets/index-Dc2HfqON.js +0 -140
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
+ import { usePlayerStore } from "../player";
3
4
  import {
4
5
  STUDIO_INSPECTOR_PANELS_ENABLED,
5
6
  STUDIO_GSAP_PANEL_ENABLED,
@@ -16,7 +17,20 @@ import { useDomSelection } from "./useDomSelection";
16
17
  import { usePreviewInteraction } from "./usePreviewInteraction";
17
18
  import { useDomEditCommits } from "./useDomEditCommits";
18
19
  import { useGsapScriptCommits } from "./useGsapScriptCommits";
19
- import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache";
20
+ import {
21
+ useGsapAnimationsForElement,
22
+ useGsapCacheVersion,
23
+ usePopulateKeyframeCacheForFile,
24
+ fetchParsedAnimations,
25
+ getAnimationsForElement,
26
+ } from "./useGsapTweenCache";
27
+ import {
28
+ tryGsapDragIntercept,
29
+ tryGsapResizeIntercept,
30
+ tryGsapRotationIntercept,
31
+ } from "./gsapRuntimeBridge";
32
+ import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
33
+ import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
20
34
 
21
35
  // ── Types ──
22
36
 
@@ -194,17 +208,37 @@ export function useDomEditSession({
194
208
  onClickToSource,
195
209
  });
196
210
 
211
+ // Sync DOM selection → timeline selectedElementId so that clip selection
212
+ // highlights and diamond playhead fills work on cold-load URL restore.
213
+ useEffect(() => {
214
+ if (!domEditSelection?.id) return;
215
+ const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
216
+ const matchKey = elements.find(
217
+ (el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
218
+ );
219
+ const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
220
+ if (key && key !== selectedElementId) setSelectedElementId(key);
221
+ }, [domEditSelection?.id]);
222
+
197
223
  // ── GSAP script editing ──
198
224
 
199
225
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
200
226
 
227
+ const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html";
228
+
229
+ usePopulateKeyframeCacheForFile(
230
+ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
231
+ gsapSourceFile,
232
+ gsapCacheVersion,
233
+ );
234
+
201
235
  const {
202
236
  animations: selectedGsapAnimations,
203
237
  multipleTimelines: gsapMultipleTimelines,
204
238
  unsupportedTimelinePattern: gsapUnsupportedTimelinePattern,
205
239
  } = useGsapAnimationsForElement(
206
240
  STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
207
- domEditSelection?.sourceFile || activeCompPath || "index.html",
241
+ gsapSourceFile,
208
242
  domEditSelection
209
243
  ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
210
244
  : null,
@@ -212,6 +246,7 @@ export function useDomEditSession({
212
246
  );
213
247
 
214
248
  const {
249
+ commitMutation: gsapCommitMutation,
215
250
  updateGsapProperty,
216
251
  updateGsapMeta,
217
252
  deleteGsapAnimation,
@@ -221,6 +256,10 @@ export function useDomEditSession({
221
256
  updateGsapFromProperty,
222
257
  addGsapFromProperty,
223
258
  removeGsapFromProperty,
259
+ addKeyframe,
260
+ removeKeyframe,
261
+ convertToKeyframes,
262
+ removeAllKeyframes,
224
263
  } = useGsapScriptCommits({
225
264
  projectIdRef,
226
265
  activeCompPath,
@@ -250,6 +289,7 @@ export function useDomEditSession({
250
289
  handleDomMotionCommit,
251
290
  handleDomMotionClear,
252
291
  handleDomEditElementDelete,
292
+ handleDomZIndexReorderCommit,
253
293
  } = useDomEditCommits({
254
294
  activeCompPath,
255
295
  previewIframeRef,
@@ -270,77 +310,144 @@ export function useDomEditSession({
270
310
  buildDomSelectionFromTarget,
271
311
  });
272
312
 
273
- const handleGsapUpdateProperty = useCallback(
274
- (animId: string, prop: string, value: number | string) => {
275
- if (!domEditSelection) return;
276
- updateGsapProperty(domEditSelection, animId, prop, value);
277
- },
278
- [domEditSelection, updateGsapProperty],
279
- );
280
-
281
- const handleGsapUpdateMeta = useCallback(
282
- (animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
283
- if (!domEditSelection) return;
284
- updateGsapMeta(domEditSelection, animId, updates);
285
- },
286
- [domEditSelection, updateGsapMeta],
287
- );
288
-
289
- const handleGsapDeleteAnimation = useCallback(
290
- (animId: string) => {
291
- if (!domEditSelection) return;
292
- deleteGsapAnimation(domEditSelection, animId);
293
- },
294
- [domEditSelection, deleteGsapAnimation],
295
- );
296
-
297
- const handleGsapAddAnimation = useCallback(
298
- (method: "to" | "from" | "set" | "fromTo") => {
299
- if (!domEditSelection) return;
300
- addGsapAnimation(domEditSelection, method, currentTime);
313
+ // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
314
+ const handleGsapAwarePathOffsetCommit = useCallback(
315
+ async (selection: DomEditSelection, next: { x: number; y: number }) => {
316
+ if (gsapCommitMutation) {
317
+ const handled = await tryGsapDragIntercept(
318
+ selection,
319
+ next,
320
+ selectedGsapAnimations,
321
+ previewIframeRef.current,
322
+ gsapCommitMutation,
323
+ async () => {
324
+ const pid = projectId;
325
+ if (!pid) return [];
326
+ const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
327
+ if (!parsed) return [];
328
+ const target = { id: selection.id ?? null, selector: selection.selector ?? null };
329
+ return getAnimationsForElement(parsed.animations, target);
330
+ },
331
+ );
332
+ if (handled) return;
333
+ }
334
+ handleDomPathOffsetCommit(selection, next);
301
335
  },
302
- [domEditSelection, addGsapAnimation, currentTime],
336
+ [
337
+ handleDomPathOffsetCommit,
338
+ selectedGsapAnimations,
339
+ gsapCommitMutation,
340
+ previewIframeRef,
341
+ projectId,
342
+ gsapSourceFile,
343
+ ],
303
344
  );
304
345
 
305
- const handleGsapAddProperty = useCallback(
306
- (animId: string, prop: string) => {
307
- if (!domEditSelection) return;
308
- addGsapProperty(domEditSelection, animId, prop);
346
+ const makeFetchFallback = useCallback(
347
+ (selection: DomEditSelection) => async () => {
348
+ const pid = projectId;
349
+ if (!pid) return [];
350
+ const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
351
+ if (!parsed) return [];
352
+ return getAnimationsForElement(parsed.animations, {
353
+ id: selection.id ?? null,
354
+ selector: selection.selector ?? null,
355
+ });
309
356
  },
310
- [domEditSelection, addGsapProperty],
357
+ [projectId, gsapSourceFile],
311
358
  );
312
359
 
313
- const handleGsapRemoveProperty = useCallback(
314
- (animId: string, prop: string) => {
315
- if (!domEditSelection) return;
316
- removeGsapProperty(domEditSelection, animId, prop);
360
+ const handleGsapAwareBoxSizeCommit = useCallback(
361
+ async (selection: DomEditSelection, next: { width: number; height: number }) => {
362
+ if (gsapCommitMutation) {
363
+ const handled = await tryGsapResizeIntercept(
364
+ selection,
365
+ next,
366
+ selectedGsapAnimations,
367
+ previewIframeRef.current,
368
+ gsapCommitMutation,
369
+ makeFetchFallback(selection),
370
+ );
371
+ if (handled) return;
372
+ }
373
+ handleDomBoxSizeCommit(selection, next);
317
374
  },
318
- [domEditSelection, removeGsapProperty],
375
+ [
376
+ handleDomBoxSizeCommit,
377
+ selectedGsapAnimations,
378
+ gsapCommitMutation,
379
+ previewIframeRef,
380
+ makeFetchFallback,
381
+ ],
319
382
  );
320
383
 
321
- const handleGsapUpdateFromProperty = useCallback(
322
- (animId: string, prop: string, value: number | string) => {
323
- if (!domEditSelection) return;
324
- updateGsapFromProperty(domEditSelection, animId, prop, value);
384
+ const handleGsapAwareRotationCommit = useCallback(
385
+ async (selection: DomEditSelection, next: { angle: number }) => {
386
+ if (gsapCommitMutation) {
387
+ const handled = await tryGsapRotationIntercept(
388
+ selection,
389
+ next.angle,
390
+ selectedGsapAnimations,
391
+ previewIframeRef.current,
392
+ gsapCommitMutation,
393
+ makeFetchFallback(selection),
394
+ );
395
+ if (handled) return;
396
+ }
397
+ handleDomRotationCommit(selection, next);
325
398
  },
326
- [domEditSelection, updateGsapFromProperty],
399
+ [
400
+ handleDomRotationCommit,
401
+ selectedGsapAnimations,
402
+ gsapCommitMutation,
403
+ previewIframeRef,
404
+ makeFetchFallback,
405
+ ],
327
406
  );
328
407
 
329
- const handleGsapAddFromProperty = useCallback(
330
- (animId: string, prop: string) => {
331
- if (!domEditSelection) return;
332
- addGsapFromProperty(domEditSelection, animId, prop);
333
- },
334
- [domEditSelection, addGsapFromProperty],
335
- );
408
+ const {
409
+ handleGsapUpdateProperty,
410
+ handleGsapUpdateMeta,
411
+ handleGsapDeleteAnimation,
412
+ handleGsapAddAnimation,
413
+ handleGsapAddProperty,
414
+ handleGsapRemoveProperty,
415
+ handleGsapUpdateFromProperty,
416
+ handleGsapAddFromProperty,
417
+ handleGsapRemoveFromProperty,
418
+ handleGsapAddKeyframe,
419
+ handleGsapRemoveKeyframe,
420
+ handleGsapConvertToKeyframes,
421
+ handleGsapRemoveAllKeyframes,
422
+ handleResetSelectedElementKeyframes,
423
+ } = useGsapSelectionHandlers({
424
+ domEditSelection,
425
+ updateGsapProperty,
426
+ updateGsapMeta,
427
+ deleteGsapAnimation,
428
+ addGsapAnimation,
429
+ addGsapProperty,
430
+ removeGsapProperty,
431
+ updateGsapFromProperty,
432
+ addGsapFromProperty,
433
+ removeGsapFromProperty,
434
+ addKeyframe,
435
+ removeKeyframe,
436
+ convertToKeyframes,
437
+ removeAllKeyframes,
438
+ currentTime,
439
+ handleDomManualEditsReset,
440
+ selectedGsapAnimations,
441
+ });
336
442
 
337
- const handleGsapRemoveFromProperty = useCallback(
338
- (animId: string, prop: string) => {
339
- if (!domEditSelection) return;
340
- removeGsapFromProperty(domEditSelection, animId, prop);
341
- },
342
- [domEditSelection, removeGsapFromProperty],
343
- );
443
+ const commitAnimatedProperty = useAnimatedPropertyCommit({
444
+ selectedGsapAnimations,
445
+ gsapCommitMutation,
446
+ addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
447
+ convertToKeyframes: (sel, animId) => convertToKeyframes(sel, animId),
448
+ previewIframeRef,
449
+ bumpGsapCache,
450
+ });
344
451
 
345
452
  // Sync selection from preview document on load / refresh
346
453
  // eslint-disable-next-line no-restricted-syntax
@@ -431,10 +538,8 @@ export function useDomEditSession({
431
538
  agentModalAnchorPoint,
432
539
  copiedAgentPrompt,
433
540
  agentPromptSelectionContext,
434
-
435
541
  // Refs
436
542
  domEditSelectionRef,
437
-
438
543
  // Callbacks
439
544
  handleTimelineElementSelect,
440
545
  handlePreviewCanvasMouseDown,
@@ -445,10 +550,11 @@ export function useDomEditSession({
445
550
  handleDomStyleCommit,
446
551
  handleDomAttributeCommit,
447
552
  handleDomHtmlAttributeCommit,
448
- handleDomPathOffsetCommit,
553
+ handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
449
554
  handleDomGroupPathOffsetCommit,
450
- handleDomBoxSizeCommit,
451
- handleDomRotationCommit,
555
+ handleDomZIndexReorderCommit,
556
+ handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
557
+ handleDomRotationCommit: handleGsapAwareRotationCommit,
452
558
  handleDomManualEditsReset,
453
559
  handleDomMotionCommit,
454
560
  handleDomMotionClear,
@@ -482,5 +588,13 @@ export function useDomEditSession({
482
588
  handleGsapUpdateFromProperty,
483
589
  handleGsapAddFromProperty,
484
590
  handleGsapRemoveFromProperty,
591
+ handleGsapAddKeyframe,
592
+ handleGsapRemoveKeyframe,
593
+ handleGsapConvertToKeyframes,
594
+ handleGsapRemoveAllKeyframes,
595
+ handleResetSelectedElementKeyframes,
596
+ commitAnimatedProperty,
597
+ invalidateGsapCache: bumpGsapCache,
598
+ previewIframeRef,
485
599
  };
486
600
  }
@@ -3,6 +3,8 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
5
  import { applySoftReload } from "../utils/gsapSoftReload";
6
+ import { executeOptimistic } from "../utils/optimisticUpdate";
7
+ import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
6
8
 
7
9
  const PROPERTY_DEFAULTS: Record<string, number> = {
8
10
  opacity: 1,
@@ -70,6 +72,27 @@ async function mutateGsapScript(
70
72
  }
71
73
  }
72
74
 
75
+ function buildCacheKey(sourceFile: string, elementId: string): string {
76
+ return `${sourceFile}#${elementId}`;
77
+ }
78
+
79
+ function readKeyframeSnapshot(
80
+ sourceFile: string,
81
+ elementId: string | null | undefined,
82
+ ): KeyframeCacheEntry | undefined {
83
+ if (!elementId) return undefined;
84
+ return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
85
+ }
86
+
87
+ function writeKeyframeCache(
88
+ sourceFile: string,
89
+ elementId: string | null | undefined,
90
+ data: KeyframeCacheEntry | undefined,
91
+ ): void {
92
+ if (!elementId) return;
93
+ usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
94
+ }
95
+
73
96
  interface GsapScriptCommitsParams {
74
97
  projectIdRef: React.MutableRefObject<string | null>;
75
98
  activeCompPath: string | null;
@@ -113,7 +136,13 @@ export function useGsapScriptCommits({
113
136
  async (
114
137
  selection: DomEditSelection,
115
138
  mutation: Record<string, unknown>,
116
- options: { label: string; coalesceKey?: string; softReload?: boolean },
139
+ options: {
140
+ label: string;
141
+ coalesceKey?: string;
142
+ softReload?: boolean;
143
+ skipReload?: boolean;
144
+ beforeReload?: () => void;
145
+ },
117
146
  ) => {
118
147
  const pid = projectIdRef.current;
119
148
  if (!pid) return;
@@ -135,6 +164,21 @@ export function useGsapScriptCommits({
135
164
 
136
165
  onCacheInvalidate();
137
166
 
167
+ if (result.parsed?.animations) {
168
+ const { setKeyframeCache } = usePlayerStore.getState();
169
+ for (const anim of result.parsed.animations) {
170
+ if (!anim.keyframes) continue;
171
+ const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
172
+ if (!id) continue;
173
+ setKeyframeCache(`${targetPath}#${id}`, anim.keyframes);
174
+ if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
175
+ }
176
+ }
177
+
178
+ if (options.skipReload) return;
179
+
180
+ options.beforeReload?.();
181
+
138
182
  if (options.softReload && result.scriptText) {
139
183
  if (!applySoftReload(previewIframeRef.current, result.scriptText)) {
140
184
  reloadPreview();
@@ -225,7 +269,7 @@ export function useGsapScriptCommits({
225
269
  async (
226
270
  selection: DomEditSelection,
227
271
  method: "to" | "from" | "set" | "fromTo",
228
- currentTime?: number,
272
+ _currentTime?: number,
229
273
  ) => {
230
274
  const { selector, autoId } = ensureElementAddressable(selection);
231
275
 
@@ -253,12 +297,15 @@ export function useGsapScriptCommits({
253
297
  if (!data.changed) return;
254
298
  }
255
299
 
256
- const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0);
300
+ const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
301
+ const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
302
+ const position = Math.round(elStart * 1000) / 1000;
303
+ const duration = Math.round(elDuration * 1000) / 1000;
257
304
  const toDefaults: Record<string, Record<string, number>> = {
258
305
  from: { opacity: 0 },
259
- to: { opacity: 1 },
306
+ to: { x: 0, y: 0, opacity: 1 },
260
307
  set: { opacity: 1 },
261
- fromTo: { opacity: 1 },
308
+ fromTo: { x: 0, y: 0, opacity: 1 },
262
309
  };
263
310
 
264
311
  await commitMutation(
@@ -267,8 +314,8 @@ export function useGsapScriptCommits({
267
314
  type: "add",
268
315
  targetSelector: selector,
269
316
  method,
270
- position: start,
271
- duration: method === "set" ? undefined : 0.5,
317
+ position,
318
+ duration: method === "set" ? undefined : duration,
272
319
  ease: method === "set" ? undefined : "power2.out",
273
320
  properties: toDefaults[method] ?? { opacity: 1 },
274
321
  fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
@@ -353,7 +400,93 @@ export function useGsapScriptCommits({
353
400
  [commitMutation],
354
401
  );
355
402
 
403
+ const addKeyframe = useCallback(
404
+ (
405
+ selection: DomEditSelection,
406
+ animationId: string,
407
+ percentage: number,
408
+ property: string,
409
+ value: number | string,
410
+ ) => {
411
+ const sf = selection.sourceFile || activeCompPath || "index.html";
412
+ const elementId = selection.id;
413
+ void executeOptimistic<KeyframeCacheEntry | undefined>({
414
+ apply: () => {
415
+ const prev = readKeyframeSnapshot(sf, elementId);
416
+ if (prev) {
417
+ const newKeyframes = [
418
+ ...prev.keyframes,
419
+ { percentage, properties: { [property]: value } },
420
+ ].sort((a, b) => a.percentage - b.percentage);
421
+ writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
422
+ }
423
+ return prev;
424
+ },
425
+ persist: () =>
426
+ commitMutation(
427
+ selection,
428
+ { type: "add-keyframe", animationId, percentage, properties: { [property]: value } },
429
+ { label: `Add keyframe at ${percentage}%`, softReload: true },
430
+ ),
431
+ rollback: (prev) => {
432
+ writeKeyframeCache(sf, elementId, prev);
433
+ },
434
+ });
435
+ },
436
+ [commitMutation, activeCompPath],
437
+ );
438
+
439
+ const removeKeyframe = useCallback(
440
+ (selection: DomEditSelection, animationId: string, percentage: number) => {
441
+ const sf = selection.sourceFile || activeCompPath || "index.html";
442
+ const elementId = selection.id;
443
+ void executeOptimistic<KeyframeCacheEntry | undefined>({
444
+ apply: () => {
445
+ const prev = readKeyframeSnapshot(sf, elementId);
446
+ if (prev) {
447
+ const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage);
448
+ writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
449
+ }
450
+ return prev;
451
+ },
452
+ persist: () =>
453
+ commitMutation(
454
+ selection,
455
+ { type: "remove-keyframe", animationId, percentage },
456
+ { label: `Remove keyframe at ${percentage}%`, softReload: true },
457
+ ),
458
+ rollback: (prev) => {
459
+ writeKeyframeCache(sf, elementId, prev);
460
+ },
461
+ });
462
+ },
463
+ [commitMutation, activeCompPath],
464
+ );
465
+
466
+ const convertToKeyframes = useCallback(
467
+ (selection: DomEditSelection, animationId: string) => {
468
+ void commitMutation(
469
+ selection,
470
+ { type: "convert-to-keyframes", animationId },
471
+ { label: "Convert to keyframes" },
472
+ );
473
+ },
474
+ [commitMutation],
475
+ );
476
+
477
+ const removeAllKeyframes = useCallback(
478
+ (selection: DomEditSelection, animationId: string) => {
479
+ void commitMutation(
480
+ selection,
481
+ { type: "remove-all-keyframes", animationId },
482
+ { label: "Remove all keyframes", softReload: true },
483
+ );
484
+ },
485
+ [commitMutation],
486
+ );
487
+
356
488
  return {
489
+ commitMutation,
357
490
  updateGsapProperty,
358
491
  updateGsapMeta,
359
492
  deleteGsapAnimation,
@@ -363,5 +496,9 @@ export function useGsapScriptCommits({
363
496
  updateGsapFromProperty,
364
497
  addGsapFromProperty,
365
498
  removeGsapFromProperty,
499
+ addKeyframe,
500
+ removeKeyframe,
501
+ convertToKeyframes,
502
+ removeAllKeyframes,
366
503
  };
367
504
  }