@hyperframes/studio 0.6.95 → 0.6.97

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 (50) hide show
  1. package/dist/assets/hyperframes-player-Daj5djxa.js +418 -0
  2. package/dist/assets/index-B0twsRu0.css +1 -0
  3. package/dist/assets/index-Cfye9xzo.js +251 -0
  4. package/dist/assets/{index-CAANLw9Q.js → index-HveJ0MuV.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/App.tsx +10 -5
  8. package/src/components/SaveQueuePausedBanner.tsx +23 -0
  9. package/src/components/StudioPreviewArea.tsx +7 -0
  10. package/src/components/StudioRightPanel.tsx +1 -38
  11. package/src/components/editor/DomEditOverlay.test.ts +169 -29
  12. package/src/components/editor/DomEditOverlay.tsx +13 -23
  13. package/src/components/editor/GestureRecordControl.tsx +98 -0
  14. package/src/components/editor/PropertyPanel.tsx +22 -38
  15. package/src/components/editor/domEditing.test.ts +84 -0
  16. package/src/components/editor/domEditingLayers.ts +19 -0
  17. package/src/components/editor/domEditingRootLayer.ts +64 -0
  18. package/src/components/editor/manualEditingAvailability.test.ts +1 -2
  19. package/src/components/editor/manualEditingAvailability.ts +0 -7
  20. package/src/contexts/DomEditContext.tsx +1 -6
  21. package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
  22. package/src/hooks/useDomEditCommits.ts +97 -123
  23. package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
  24. package/src/hooks/useDomEditSession.ts +59 -65
  25. package/src/hooks/useFileManager.ts +19 -5
  26. package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
  27. package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
  28. package/src/hooks/useGsapScriptCommits.ts +152 -140
  29. package/src/hooks/useGsapSelectionHandlers.ts +38 -8
  30. package/src/hooks/usePreviewPersistence.ts +90 -51
  31. package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
  32. package/src/hooks/useStudioContextValue.ts +3 -19
  33. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  34. package/src/player/lib/playbackAdapter.test.ts +86 -1
  35. package/src/player/lib/playbackAdapter.ts +62 -0
  36. package/src/utils/domEditSaveQueue.test.ts +117 -0
  37. package/src/utils/domEditSaveQueue.ts +87 -0
  38. package/src/utils/studioHelpers.ts +1 -1
  39. package/src/utils/studioSaveDiagnostics.test.ts +127 -0
  40. package/src/utils/studioSaveDiagnostics.ts +200 -0
  41. package/src/utils/studioUrlState.test.ts +0 -1
  42. package/src/utils/studioUrlState.ts +2 -8
  43. package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
  44. package/dist/assets/index-DujOjou6.js +0 -251
  45. package/dist/assets/index-rm9tn9nH.css +0 -1
  46. package/src/components/editor/EaseCurveEditor.tsx +0 -221
  47. package/src/components/editor/MotionPanel.tsx +0 -277
  48. package/src/components/editor/MotionPanelFields.tsx +0 -185
  49. package/src/components/editor/MotionPathOverlay.tsx +0 -146
  50. package/src/components/editor/SpringEaseEditor.tsx +0 -256
@@ -0,0 +1,19 @@
1
+ import { useCallback } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
4
+
5
+ export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
6
+ return useCallback(
7
+ (selection: DomEditSelection) => async () => {
8
+ const pid = projectId;
9
+ if (!pid) return [];
10
+ const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
11
+ if (!parsed) return [];
12
+ return getAnimationsForElement(parsed.animations, {
13
+ id: selection.id ?? null,
14
+ selector: selection.selector ?? null,
15
+ });
16
+ },
17
+ [projectId, gsapSourceFile],
18
+ );
19
+ }
@@ -0,0 +1,25 @@
1
+ import { useCallback } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+ import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
+
5
+ export function useGsapInteractionFailureTelemetry(
6
+ activeCompPath: string | null,
7
+ showToast: (message: string, tone?: "error" | "info") => void,
8
+ ) {
9
+ return useCallback(
10
+ (error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
11
+ trackStudioSaveFailure({
12
+ source: "gsap_commit",
13
+ error,
14
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
15
+ mutationType,
16
+ label,
17
+ targetId: selection.id,
18
+ targetSelector: selection.selector,
19
+ targetSourceFile: selection.sourceFile,
20
+ });
21
+ showToast("Failed to save animated edit.", "error");
22
+ },
23
+ [activeCompPath, showToast],
24
+ );
25
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
+ import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
5
  import type { EditHistoryKind } from "../utils/editHistory";
5
6
  import { applySoftReload } from "../utils/gsapSoftReload";
@@ -11,43 +12,18 @@ import {
11
12
  readKeyframeSnapshot,
12
13
  writeKeyframeCache,
13
14
  } from "./gsapKeyframeCacheHelpers";
14
-
15
- const PROPERTY_DEFAULTS: Record<string, number> = {
16
- opacity: 1,
17
- x: 0,
18
- y: 0,
19
- scale: 1,
20
- scaleX: 1,
21
- scaleY: 1,
22
- rotation: 0,
23
- width: 100,
24
- height: 100,
25
- };
26
-
27
- /**
28
- * Ensures the element has an id so it can be targeted by a GSAP selector.
29
- * If the element already has an id or a CSS selector, returns those.
30
- * Otherwise mints a unique id and sets it on the live element.
31
- */
32
- function ensureElementAddressable(selection: DomEditSelection): {
33
- selector: string;
34
- autoId?: string;
35
- } {
36
- if (selection.id) return { selector: `#${selection.id}` };
37
- if (selection.selector) return { selector: selection.selector };
38
-
39
- const el = selection.element;
40
- const doc = el.ownerDocument;
41
- const tag = el.tagName.toLowerCase();
42
- let id = tag;
43
- let n = 1;
44
- while (doc.getElementById(id)) {
45
- n += 1;
46
- id = `${tag}-${n}`;
47
- }
48
- el.setAttribute("id", id);
49
- return { selector: `#${id}`, autoId: id };
50
- }
15
+ import {
16
+ useGsapSaveFailureTelemetry,
17
+ useSafeGsapCommitMutation,
18
+ } from "./useSafeGsapCommitMutation";
19
+ import {
20
+ GsapMutationHttpError,
21
+ assignGsapTargetAutoIdIfNeeded,
22
+ ensureElementAddressable,
23
+ formatGsapMutationRejectionToast,
24
+ PROPERTY_DEFAULTS,
25
+ readJsonResponseBody,
26
+ } from "./gsapScriptCommitHelpers";
51
27
 
52
28
  interface MutationResult {
53
29
  ok: boolean;
@@ -62,22 +38,44 @@ async function mutateGsapScript(
62
38
  projectId: string,
63
39
  sourceFile: string,
64
40
  mutation: Record<string, unknown>,
65
- ): Promise<MutationResult | null> {
66
- try {
67
- const res = await fetch(
68
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
69
- {
70
- method: "POST",
71
- headers: { "Content-Type": "application/json" },
72
- body: JSON.stringify(mutation),
73
- },
74
- );
75
- if (!res.ok) return null;
76
- return (await res.json()) as MutationResult;
77
- } catch {
78
- return null;
41
+ ): Promise<MutationResult> {
42
+ const res = await fetch(
43
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
44
+ {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify(mutation),
48
+ },
49
+ );
50
+ if (!res.ok) {
51
+ throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res));
79
52
  }
53
+ const result = (await res.json()) as MutationResult;
54
+ if (!result.ok) {
55
+ throw new Error(`Failed to update GSAP in ${sourceFile}`);
56
+ }
57
+ return result;
80
58
  }
59
+
60
+ function executeOptimisticKeyframeCacheUpdate(options: {
61
+ sourceFile: string;
62
+ elementId: string | null | undefined;
63
+ apply: (entry: KeyframeCacheEntry) => KeyframeCacheEntry;
64
+ persist: () => Promise<void>;
65
+ }): Promise<void> {
66
+ return executeOptimistic<KeyframeCacheEntry | undefined>({
67
+ apply: () => {
68
+ const prev = readKeyframeSnapshot(options.sourceFile, options.elementId);
69
+ if (prev) writeKeyframeCache(options.sourceFile, options.elementId, options.apply(prev));
70
+ return prev;
71
+ },
72
+ persist: options.persist,
73
+ rollback: (prev) => {
74
+ writeKeyframeCache(options.sourceFile, options.elementId, prev);
75
+ },
76
+ });
77
+ }
78
+
81
79
  interface GsapScriptCommitsParams {
82
80
  projectIdRef: React.MutableRefObject<string | null>;
83
81
  activeCompPath: string | null;
@@ -94,10 +92,11 @@ interface GsapScriptCommitsParams {
94
92
  reloadPreview: () => void;
95
93
  onCacheInvalidate: () => void;
96
94
  onFileContentChanged?: (path: string, content: string) => void;
95
+ showToast: (message: string, tone?: "error" | "info") => void;
97
96
  }
98
97
  const DEBOUNCE_MS = 150;
99
98
 
100
- // fallow-ignore-next-line complexity unit-size
99
+ // fallow-ignore-next-line complexity
101
100
  export function useGsapScriptCommits({
102
101
  projectIdRef,
103
102
  activeCompPath,
@@ -107,6 +106,7 @@ export function useGsapScriptCommits({
107
106
  reloadPreview,
108
107
  onCacheInvalidate,
109
108
  onFileContentChanged,
109
+ showToast,
110
110
  }: GsapScriptCommitsParams) {
111
111
  const pendingPropertyEditRef = useRef<{
112
112
  selection: DomEditSelection;
@@ -131,11 +131,27 @@ export function useGsapScriptCommits({
131
131
  ) => {
132
132
  const pid = projectIdRef.current;
133
133
  if (!pid) return;
134
+ const unsafeFields = findUnsafeMutationValues(mutation);
135
+ if (unsafeFields.length > 0) {
136
+ showToast?.(
137
+ "Couldn't read element layout — try again at a different playhead time",
138
+ "error",
139
+ );
140
+ if (options.skipReload) return;
141
+ throw new Error(
142
+ `Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`,
143
+ );
144
+ }
134
145
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
135
- const result = await mutateGsapScript(pid, targetPath, mutation);
136
- if (!result) {
146
+ let result: MutationResult;
147
+ try {
148
+ result = await mutateGsapScript(pid, targetPath, mutation);
149
+ } catch (error) {
150
+ if (error instanceof GsapMutationHttpError) {
151
+ showToast?.(formatGsapMutationRejectionToast(error), "error");
152
+ }
137
153
  if (options.skipReload) return;
138
- throw new Error(`Mutation failed: ${mutation.type}`);
154
+ throw error;
139
155
  }
140
156
 
141
157
  if (result.changed === false) {
@@ -195,14 +211,23 @@ export function useGsapScriptCommits({
195
211
  reloadPreview,
196
212
  onCacheInvalidate,
197
213
  onFileContentChanged,
214
+ showToast,
198
215
  ],
199
216
  );
217
+
218
+ const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
219
+ const commitMutationSafely = useSafeGsapCommitMutation(
220
+ commitMutation,
221
+ trackGsapSaveFailure,
222
+ showToast,
223
+ );
224
+
200
225
  const flushPendingPropertyEdit = useCallback(() => {
201
226
  const pending = pendingPropertyEditRef.current;
202
227
  if (!pending) return;
203
228
  pendingPropertyEditRef.current = null;
204
229
  const { selection, animationId, property, value } = pending;
205
- void commitMutation(
230
+ commitMutationSafely(
206
231
  selection,
207
232
  { type: "update-property", animationId, property, value },
208
233
  {
@@ -211,7 +236,7 @@ export function useGsapScriptCommits({
211
236
  softReload: true,
212
237
  },
213
238
  );
214
- }, [commitMutation]);
239
+ }, [commitMutationSafely]);
215
240
 
216
241
  const updateGsapProperty = useCallback(
217
242
  (
@@ -239,7 +264,7 @@ export function useGsapScriptCommits({
239
264
  animationId: string,
240
265
  updates: { duration?: number; ease?: string; position?: number },
241
266
  ) => {
242
- void commitMutation(
267
+ commitMutationSafely(
243
268
  selection,
244
269
  { type: "update-meta", animationId, updates },
245
270
  {
@@ -248,17 +273,17 @@ export function useGsapScriptCommits({
248
273
  },
249
274
  );
250
275
  },
251
- [commitMutation],
276
+ [commitMutationSafely],
252
277
  );
253
278
  const deleteGsapAnimation = useCallback(
254
279
  (selection: DomEditSelection, animationId: string) => {
255
- void commitMutation(
280
+ commitMutationSafely(
256
281
  selection,
257
282
  { type: "delete", animationId, stripStudioEdits: true },
258
283
  { label: "Delete GSAP animation" },
259
284
  );
260
285
  },
261
- [commitMutation],
286
+ [commitMutationSafely],
262
287
  );
263
288
  const deleteAllForSelector = useCallback(
264
289
  (selection: DomEditSelection, targetSelector: string) => {
@@ -283,25 +308,14 @@ export function useGsapScriptCommits({
283
308
  const pid = projectIdRef.current;
284
309
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
285
310
  if (!pid) return;
286
- const res = await fetch(
287
- `/api/projects/${encodeURIComponent(pid)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
288
- {
289
- method: "POST",
290
- headers: { "Content-Type": "application/json" },
291
- body: JSON.stringify({
292
- target: {
293
- id: selection.id,
294
- hfId: selection.hfId,
295
- selector: selection.selector,
296
- selectorIndex: selection.selectorIndex,
297
- },
298
- operations: [{ type: "html-attribute", property: "id", value: autoId }],
299
- }),
300
- },
301
- );
302
- if (!res.ok) return;
303
- const data = (await res.json()) as { changed?: boolean };
304
- if (!data.changed) return;
311
+ const assigned = await assignGsapTargetAutoIdIfNeeded({
312
+ projectId: pid,
313
+ targetPath,
314
+ selection,
315
+ autoId,
316
+ showToast,
317
+ });
318
+ if (!assigned) return;
305
319
  }
306
320
 
307
321
  const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
@@ -330,7 +344,7 @@ export function useGsapScriptCommits({
330
344
  { label: `Add GSAP ${method} animation` },
331
345
  );
332
346
  },
333
- [commitMutation, projectIdRef, activeCompPath],
347
+ [commitMutation, projectIdRef, activeCompPath, showToast],
334
348
  );
335
349
  const addGsapProperty = useCallback(
336
350
  // fallow-ignore-next-line complexity
@@ -344,23 +358,23 @@ export function useGsapScriptCommits({
344
358
  const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
345
359
  defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1;
346
360
  }
347
- void commitMutation(
361
+ commitMutationSafely(
348
362
  selection,
349
363
  { type: "add-property", animationId, property, defaultValue },
350
364
  { label: `Add GSAP ${property}` },
351
365
  );
352
366
  },
353
- [commitMutation],
367
+ [commitMutationSafely],
354
368
  );
355
369
  const removeGsapProperty = useCallback(
356
370
  (selection: DomEditSelection, animationId: string, property: string) => {
357
- void commitMutation(
371
+ commitMutationSafely(
358
372
  selection,
359
373
  { type: "remove-property", animationId, property },
360
374
  { label: `Remove GSAP ${property}` },
361
375
  );
362
376
  },
363
- [commitMutation],
377
+ [commitMutationSafely],
364
378
  );
365
379
  const updateGsapFromProperty = useCallback(
366
380
  (
@@ -369,7 +383,7 @@ export function useGsapScriptCommits({
369
383
  property: string,
370
384
  value: number | string,
371
385
  ) => {
372
- void commitMutation(
386
+ commitMutationSafely(
373
387
  selection,
374
388
  { type: "update-from-property", animationId, property, value },
375
389
  {
@@ -378,28 +392,28 @@ export function useGsapScriptCommits({
378
392
  },
379
393
  );
380
394
  },
381
- [commitMutation],
395
+ [commitMutationSafely],
382
396
  );
383
397
  const addGsapFromProperty = useCallback(
384
398
  (selection: DomEditSelection, animationId: string, property: string) => {
385
399
  const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
386
- void commitMutation(
400
+ commitMutationSafely(
387
401
  selection,
388
402
  { type: "add-from-property", animationId, property, defaultValue },
389
403
  { label: `Add GSAP from-${property}` },
390
404
  );
391
405
  },
392
- [commitMutation],
406
+ [commitMutationSafely],
393
407
  );
394
408
  const removeGsapFromProperty = useCallback(
395
409
  (selection: DomEditSelection, animationId: string, property: string) => {
396
- void commitMutation(
410
+ commitMutationSafely(
397
411
  selection,
398
412
  { type: "remove-from-property", animationId, property },
399
413
  { label: `Remove GSAP from-${property}` },
400
414
  );
401
415
  },
402
- [commitMutation],
416
+ [commitMutationSafely],
403
417
  );
404
418
  const addKeyframe = useCallback(
405
419
  (
@@ -411,30 +425,31 @@ export function useGsapScriptCommits({
411
425
  ) => {
412
426
  const sf = selection.sourceFile || activeCompPath || "index.html";
413
427
  const elementId = selection.id;
414
- void executeOptimistic<KeyframeCacheEntry | undefined>({
415
- apply: () => {
416
- const prev = readKeyframeSnapshot(sf, elementId);
417
- if (prev) {
418
- const newKeyframes = [
419
- ...prev.keyframes,
420
- { percentage, properties: { [property]: value } },
421
- ].sort((a, b) => a.percentage - b.percentage);
422
- writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
423
- }
424
- return prev;
425
- },
426
- persist: () =>
427
- commitMutation(
428
- selection,
429
- { type: "add-keyframe", animationId, percentage, properties: { [property]: value } },
430
- { label: `Add keyframe at ${percentage}%`, softReload: true },
428
+ const mutation = {
429
+ type: "add-keyframe",
430
+ animationId,
431
+ percentage,
432
+ properties: { [property]: value },
433
+ };
434
+ void executeOptimisticKeyframeCacheUpdate({
435
+ sourceFile: sf,
436
+ elementId,
437
+ apply: (prev) => ({
438
+ ...prev,
439
+ keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
440
+ (a, b) => a.percentage - b.percentage,
431
441
  ),
432
- rollback: (prev) => {
433
- writeKeyframeCache(sf, elementId, prev);
434
- },
442
+ }),
443
+ persist: () =>
444
+ commitMutation(selection, mutation, {
445
+ label: `Add keyframe at ${percentage}%`,
446
+ softReload: true,
447
+ }),
448
+ }).catch((error) => {
449
+ trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
435
450
  });
436
451
  },
437
- [commitMutation, activeCompPath],
452
+ [commitMutation, activeCompPath, trackGsapSaveFailure],
438
453
  );
439
454
  const addKeyframeBatch = useCallback(
440
455
  (
@@ -455,29 +470,26 @@ export function useGsapScriptCommits({
455
470
  (selection: DomEditSelection, animationId: string, percentage: number) => {
456
471
  const sf = selection.sourceFile || activeCompPath || "index.html";
457
472
  const elementId = selection.id;
458
- void executeOptimistic<KeyframeCacheEntry | undefined>({
459
- apply: () => {
460
- const prev = readKeyframeSnapshot(sf, elementId);
461
- if (prev) {
462
- const newKeyframes = prev.keyframes.filter(
463
- (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
464
- );
465
- writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
466
- }
467
- return prev;
468
- },
469
- persist: () =>
470
- commitMutation(
471
- selection,
472
- { type: "remove-keyframe", animationId, percentage },
473
- { label: `Remove keyframe at ${percentage}%`, softReload: true },
473
+ const mutation = { type: "remove-keyframe", animationId, percentage };
474
+ void executeOptimisticKeyframeCacheUpdate({
475
+ sourceFile: sf,
476
+ elementId,
477
+ apply: (prev) => ({
478
+ ...prev,
479
+ keyframes: prev.keyframes.filter(
480
+ (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
474
481
  ),
475
- rollback: (prev) => {
476
- writeKeyframeCache(sf, elementId, prev);
477
- },
482
+ }),
483
+ persist: () =>
484
+ commitMutation(selection, mutation, {
485
+ label: `Remove keyframe at ${percentage}%`,
486
+ softReload: true,
487
+ }),
488
+ }).catch((error) => {
489
+ trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
478
490
  });
479
491
  },
480
- [commitMutation, activeCompPath],
492
+ [commitMutation, activeCompPath, trackGsapSaveFailure],
481
493
  );
482
494
  const convertToKeyframes = useCallback(
483
495
  (
@@ -495,13 +507,13 @@ export function useGsapScriptCommits({
495
507
  );
496
508
  const removeAllKeyframes = useCallback(
497
509
  (selection: DomEditSelection, animationId: string) => {
498
- void commitMutation(
510
+ commitMutationSafely(
499
511
  selection,
500
512
  { type: "remove-all-keyframes", animationId },
501
513
  { label: "Remove all keyframes", softReload: true },
502
514
  );
503
515
  },
504
- [commitMutation],
516
+ [commitMutationSafely],
505
517
  );
506
518
  const setArcPath = useCallback(
507
519
  (
@@ -517,13 +529,13 @@ export function useGsapScriptCommits({
517
529
  }>;
518
530
  },
519
531
  ) => {
520
- void commitMutation(
532
+ commitMutationSafely(
521
533
  selection,
522
534
  { type: "set-arc-path" as const, animationId, ...config },
523
535
  { label: config.enabled ? "Enable arc path" : "Disable arc path", softReload: true },
524
536
  );
525
537
  },
526
- [commitMutation],
538
+ [commitMutationSafely],
527
539
  );
528
540
  const updateArcSegment = useCallback(
529
541
  (
@@ -536,23 +548,23 @@ export function useGsapScriptCommits({
536
548
  cp2?: { x: number; y: number };
537
549
  },
538
550
  ) => {
539
- void commitMutation(
551
+ commitMutationSafely(
540
552
  selection,
541
553
  { type: "update-arc-segment" as const, animationId, segmentIndex, ...update },
542
554
  { label: "Update arc segment", softReload: true },
543
555
  );
544
556
  },
545
- [commitMutation],
557
+ [commitMutationSafely],
546
558
  );
547
559
  const removeArcPath = useCallback(
548
560
  (selection: DomEditSelection, animationId: string) => {
549
- void commitMutation(
561
+ commitMutationSafely(
550
562
  selection,
551
563
  { type: "remove-arc-path" as const, animationId },
552
564
  { label: "Remove arc path", softReload: true },
553
565
  );
554
566
  },
555
- [commitMutation],
567
+ [commitMutationSafely],
556
568
  );
557
569
  const commitKeyframeAtTime = useCallback(
558
570
  (
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { usePlayerStore } from "../player";
4
+ import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
5
 
5
6
  /**
6
7
  * Thin useCallback wrappers that guard on `domEditSelection` before
@@ -46,7 +47,7 @@ export function useGsapSelectionHandlers({
46
47
  sel: DomEditSelection,
47
48
  method: "to" | "from" | "set" | "fromTo",
48
49
  time: number,
49
- ) => void;
50
+ ) => Promise<void>;
50
51
  addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
51
52
  removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
52
53
  updateGsapFromProperty: (
@@ -75,7 +76,7 @@ export function useGsapSelectionHandlers({
75
76
  sel: DomEditSelection,
76
77
  animId: string,
77
78
  resolvedFromValues?: Record<string, number | string>,
78
- ) => void;
79
+ ) => Promise<void>;
79
80
  removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
80
81
 
81
82
  handleDomManualEditsReset: (sel: DomEditSelection) => void;
@@ -84,6 +85,22 @@ export function useGsapSelectionHandlers({
84
85
  const lastSelectionRef = useRef<DomEditSelection | null>(null);
85
86
  if (domEditSelection) lastSelectionRef.current = domEditSelection;
86
87
 
88
+ const trackGsapHandlerFailure = useCallback(
89
+ (error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
90
+ trackStudioSaveFailure({
91
+ source: "gsap_commit",
92
+ error,
93
+ filePath: selection.sourceFile ?? undefined,
94
+ mutationType,
95
+ label,
96
+ targetId: selection.id,
97
+ targetSelector: selection.selector,
98
+ targetSourceFile: selection.sourceFile,
99
+ });
100
+ },
101
+ [],
102
+ );
103
+
87
104
  const handleGsapUpdateProperty = useCallback(
88
105
  (animId: string, prop: string, value: number | string) => {
89
106
  if (!domEditSelection) return;
@@ -121,12 +138,16 @@ export function useGsapSelectionHandlers({
121
138
  const handleGsapAddAnimation = useCallback(
122
139
  (method: "to" | "from" | "set" | "fromTo") => {
123
140
  if (!domEditSelection) return;
124
- addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime);
141
+ void addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime).catch(
142
+ (error) => {
143
+ trackGsapHandlerFailure(error, domEditSelection, "add", `Add GSAP ${method} animation`);
144
+ },
145
+ );
125
146
  if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
126
147
  handleDomManualEditsReset(domEditSelection);
127
148
  }
128
149
  },
129
- [domEditSelection, addGsapAnimation, handleDomManualEditsReset],
150
+ [domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
130
151
  );
131
152
 
132
153
  const handleGsapAddProperty = useCallback(
@@ -180,9 +201,11 @@ export function useGsapSelectionHandlers({
180
201
  const handleGsapAddKeyframeBatch = useCallback(
181
202
  (animId: string, percentage: number, properties: Record<string, number | string>) => {
182
203
  if (!domEditSelection) return Promise.resolve();
183
- return addKeyframeBatch(domEditSelection, animId, percentage, properties);
204
+ return addKeyframeBatch(domEditSelection, animId, percentage, properties).catch((error) => {
205
+ trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
206
+ });
184
207
  },
185
- [domEditSelection, addKeyframeBatch],
208
+ [domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
186
209
  );
187
210
  const handleGsapRemoveKeyframe = useCallback(
188
211
  (animId: string, percentage: number) => {
@@ -195,9 +218,16 @@ export function useGsapSelectionHandlers({
195
218
  const handleGsapConvertToKeyframes = useCallback(
196
219
  (animId: string, resolvedFromValues?: Record<string, number | string>) => {
197
220
  if (!domEditSelection) return Promise.resolve();
198
- return convertToKeyframes(domEditSelection, animId, resolvedFromValues);
221
+ return convertToKeyframes(domEditSelection, animId, resolvedFromValues).catch((error) => {
222
+ trackGsapHandlerFailure(
223
+ error,
224
+ domEditSelection,
225
+ "convert-to-keyframes",
226
+ "Convert to keyframes",
227
+ );
228
+ });
199
229
  },
200
- [domEditSelection, convertToKeyframes],
230
+ [domEditSelection, convertToKeyframes, trackGsapHandlerFailure],
201
231
  );
202
232
 
203
233
  const handleGsapRemoveAllKeyframes = useCallback(