@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
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
+ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
2
3
  import { usePlayerStore } from "../player";
3
4
  import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
4
5
  import { FONT_EXT } from "../utils/mediaTypes";
@@ -6,6 +7,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
6
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
8
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
8
9
  import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
10
+ import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
9
11
  import {
10
12
  buildDomEditPatchTarget,
11
13
  getDomEditTargetKey,
@@ -27,23 +29,40 @@ import {
27
29
  buildClearPathOffsetPatches,
28
30
  buildClearBoxSizePatches,
29
31
  buildClearRotationPatches,
30
- buildMotionPatches,
31
- buildClearMotionPatches,
32
32
  } from "../components/editor/manualEditsDom";
33
- import {
34
- writeStudioMotionToElement,
35
- clearStudioMotionFromElement,
36
- applyStudioMotionFromDom,
37
- type StudioGsapMotion,
38
- } from "../components/editor/studioMotion";
39
33
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
40
34
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
41
35
  import type { EditHistoryKind } from "../utils/editHistory";
36
+ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
42
37
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
43
38
 
44
39
  // ── Helpers ──
45
40
  type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
46
41
 
42
+ function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
43
+ return fields.map((field) => field.path).join(", ");
44
+ }
45
+
46
+ async function readErrorResponseBody(
47
+ response: Response,
48
+ ): Promise<{ error?: string; fields?: string[] } | null> {
49
+ const contentType = response.headers.get("content-type") ?? "";
50
+ if (!contentType.includes("application/json")) return null;
51
+ return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
52
+ }
53
+
54
+ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
55
+ if (!body?.error) return "Couldn't save edit";
56
+ const fields = Array.isArray(body.fields)
57
+ ? body.fields.filter((field): field is string => typeof field === "string")
58
+ : [];
59
+ const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
60
+ return `Couldn't save edit: ${body.error}${suffix}`;
61
+ }
62
+
63
+ export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
64
+ "This element is GSAP-animated — dragging via CSS would corrupt keyframes";
65
+
47
66
  // fallow-ignore-next-line complexity
48
67
  function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
49
68
  // When the GSAP drag intercept is disabled for debugging, treat every
@@ -183,7 +202,9 @@ export function useDomEditCommits({
183
202
  const readResponse = await fetch(
184
203
  `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
185
204
  );
186
- if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
205
+ if (!readResponse.ok) {
206
+ throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
207
+ }
187
208
  const readData = (await readResponse.json()) as { content?: string };
188
209
  const originalContent = readData.content;
189
210
  if (typeof originalContent !== "string") {
@@ -193,6 +214,13 @@ export function useDomEditCommits({
193
214
  if (options?.shouldSave && !options.shouldSave()) return;
194
215
 
195
216
  const patchTarget = buildDomEditPatchTarget(selection);
217
+ const patchBody = { target: patchTarget, operations };
218
+ const unsafeFields = findUnsafeDomPatchValues(patchBody);
219
+ if (unsafeFields.length > 0) {
220
+ const fields = formatUnsafeFieldList(unsafeFields);
221
+ showToast("Couldn't save edit because it contains invalid layout values", "error");
222
+ throw new Error(`DOM patch contains unsafe values: ${fields}`);
223
+ }
196
224
 
197
225
  // Mark the save timestamp before the file write so the SSE file-change
198
226
  // handler suppresses the reload even if the event arrives before the
@@ -204,10 +232,13 @@ export function useDomEditCommits({
204
232
  {
205
233
  method: "POST",
206
234
  headers: { "Content-Type": "application/json" },
207
- body: JSON.stringify({ target: patchTarget, operations }),
235
+ body: JSON.stringify(patchBody),
208
236
  },
209
237
  );
210
- if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
238
+ if (!patchResponse.ok) {
239
+ showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
240
+ throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
241
+ }
211
242
 
212
243
  const patchData = (await patchResponse.json()) as {
213
244
  ok?: boolean;
@@ -265,6 +296,7 @@ export function useDomEditCommits({
265
296
  projectIdRef,
266
297
  domEditSaveTimestampRef,
267
298
  reloadPreview,
299
+ showToast,
268
300
  ],
269
301
  );
270
302
 
@@ -290,93 +322,88 @@ export function useDomEditCommits({
290
322
  resolveImportedFontAsset,
291
323
  });
292
324
 
293
- // ── Position patch helper ──
294
-
295
- // fallow-ignore-next-line complexity
296
- const commitPositionPatchToHtml = useCallback(
297
- (
298
- selection: DomEditSelection,
299
- patches: PatchOperation[],
300
- options: { label: string; coalesceKey: string; skipRefresh?: boolean },
301
- ) => {
302
- void queueDomEditSave(async () => {
303
- await persistDomEditOperations(selection, patches, {
304
- label: options.label,
305
- coalesceKey: options.coalesceKey,
306
- skipRefresh: options.skipRefresh ?? true,
307
- });
308
- // fallow-ignore-next-line complexity
309
- }).catch((error) => {
310
- const message = error instanceof Error ? error.message : "Failed to save position";
311
- showToast(message);
312
- trackStudioEvent("save_failure", {
313
- source: "dom_edit",
314
- label: options.label,
315
- error_message: message,
316
- target_id: selection.id ?? undefined,
317
- target_selector: selection.selector ?? undefined,
318
- target_source_file: selection.sourceFile ?? undefined,
319
- });
320
- });
321
- },
322
- [persistDomEditOperations, queueDomEditSave, showToast],
323
- );
325
+ const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
326
+ activeCompPath,
327
+ persistDomEditOperations,
328
+ queueDomEditSave,
329
+ showToast,
330
+ });
324
331
 
325
332
  // ── Position commits ──
326
333
 
327
334
  const handleDomPathOffsetCommit = useCallback(
328
335
  (selection: DomEditSelection, next: { x: number; y: number }) => {
336
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
337
+ const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
338
+ showToast(error.message, "error");
339
+ return Promise.reject(error);
340
+ }
329
341
  applyStudioPathOffset(selection.element, next);
330
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
331
- commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
342
+ return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
332
343
  label: "Move layer",
333
344
  coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
334
345
  });
335
346
  },
336
- [commitPositionPatchToHtml, previewIframeRef],
347
+ [commitPositionPatchToHtml, previewIframeRef, showToast],
337
348
  );
338
349
 
339
350
  const handleDomGroupPathOffsetCommit = useCallback(
340
351
  (updates: DomEditGroupPathOffsetCommit[]) => {
341
- if (updates.length === 0) return;
352
+ if (updates.length === 0) return Promise.resolve();
353
+ const blockedUpdate = updates.find(({ selection }) =>
354
+ isElementGsapTargeted(previewIframeRef.current, selection.element),
355
+ );
356
+ if (blockedUpdate) {
357
+ const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
358
+ showToast(error.message, "error");
359
+ return Promise.reject(error);
360
+ }
342
361
  const coalesceKey = updates
343
362
  .map((u) => getDomEditTargetKey(u.selection))
344
363
  .sort()
345
364
  .join(":");
346
- for (const { selection, next } of updates) {
365
+ const saves = updates.map(({ selection, next }) => {
347
366
  applyStudioPathOffset(selection.element, next);
348
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue;
349
- commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
367
+ return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
350
368
  label: `Move ${updates.length} layers`,
351
369
  coalesceKey: `group-path-offset:${coalesceKey}`,
352
370
  });
353
- }
371
+ });
372
+ return Promise.all(saves).then(() => undefined);
354
373
  },
355
- [commitPositionPatchToHtml, previewIframeRef],
374
+ [commitPositionPatchToHtml, previewIframeRef, showToast],
356
375
  );
357
376
 
358
377
  const handleDomBoxSizeCommit = useCallback(
359
378
  (selection: DomEditSelection, next: { width: number; height: number }) => {
379
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
380
+ const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
381
+ showToast(error.message, "error");
382
+ return Promise.reject(error);
383
+ }
360
384
  applyStudioBoxSize(selection.element, next);
361
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
362
- commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
385
+ return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
363
386
  label: "Resize layer box",
364
387
  coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
365
388
  });
366
389
  },
367
- [commitPositionPatchToHtml, previewIframeRef],
390
+ [commitPositionPatchToHtml, previewIframeRef, showToast],
368
391
  );
369
392
 
370
393
  const handleDomRotationCommit = useCallback(
371
394
  (selection: DomEditSelection, next: { angle: number }) => {
395
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
396
+ const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
397
+ showToast(error.message, "error");
398
+ return Promise.reject(error);
399
+ }
372
400
  applyStudioRotation(selection.element, next);
373
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
374
- commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
401
+ return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
375
402
  label: "Rotate layer",
376
403
  coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
377
404
  });
378
405
  },
379
- [commitPositionPatchToHtml, previewIframeRef],
406
+ [commitPositionPatchToHtml, previewIframeRef, showToast],
380
407
  );
381
408
 
382
409
  const handleDomManualEditsReset = useCallback(
@@ -391,73 +418,15 @@ export function useDomEditCommits({
391
418
  clearStudioBoxSize(element);
392
419
  clearStudioRotation(element);
393
420
  // skipRefresh:false triggers reloadPreview() which re-syncs selection on load
394
- commitPositionPatchToHtml(selection, clearPatches, {
421
+ void commitPositionPatchToHtml(selection, clearPatches, {
395
422
  label: "Reset layer edits",
396
423
  coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
397
424
  skipRefresh: false,
398
- });
425
+ }).catch(() => undefined);
399
426
  },
400
427
  [commitPositionPatchToHtml],
401
428
  );
402
429
 
403
- // ── Motion commits (HTML-attribute–backed) ──
404
-
405
- // fallow-ignore-next-line complexity
406
- const handleDomMotionCommit = useCallback(
407
- (
408
- selection: DomEditSelection,
409
- motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
410
- ) => {
411
- // 1. Write motion data as JSON attribute on the element
412
- writeStudioMotionToElement(selection.element, motion);
413
- // 2. Apply the GSAP timeline from DOM attributes
414
- let doc: Document | null = null;
415
- try {
416
- doc = previewIframeRef.current?.contentDocument ?? null;
417
- } catch {
418
- // cross-origin guard
419
- }
420
- if (doc) applyStudioMotionFromDom(doc);
421
- // 3. Build patches and persist to HTML
422
- const patches = buildMotionPatches(selection.element);
423
- commitPositionPatchToHtml(selection, patches, {
424
- label: "Set GSAP motion",
425
- coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
426
- });
427
- refreshDomEditSelectionFromPreview(selection);
428
- },
429
- [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
430
- );
431
-
432
- // fallow-ignore-next-line complexity
433
- const handleDomMotionClear = useCallback(
434
- (selection: DomEditSelection) => {
435
- const clearPatches = buildClearMotionPatches(selection.element);
436
- // Get gsap from the preview window for proper cleanup
437
- let gsap: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void } | undefined;
438
- try {
439
- gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap;
440
- } catch {
441
- // cross-origin guard
442
- }
443
- clearStudioMotionFromElement(selection.element, gsap);
444
- let doc: Document | null = null;
445
- try {
446
- doc = previewIframeRef.current?.contentDocument ?? null;
447
- } catch {
448
- // cross-origin guard
449
- }
450
- if (doc) applyStudioMotionFromDom(doc);
451
- commitPositionPatchToHtml(selection, clearPatches, {
452
- label: "Clear GSAP motion",
453
- coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
454
- skipRefresh: false,
455
- });
456
- refreshDomEditSelectionFromPreview(selection);
457
- },
458
- [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
459
- );
460
-
461
430
  // fallow-ignore-next-line complexity
462
431
  const handleDomEditElementDelete = useCallback(
463
432
  // fallow-ignore-next-line complexity
@@ -471,7 +440,9 @@ export function useDomEditCommits({
471
440
  const response = await fetch(
472
441
  `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
473
442
  );
474
- if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
443
+ if (!response.ok) {
444
+ throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
445
+ }
475
446
 
476
447
  const data = (await response.json()) as { content?: string };
477
448
  const originalContent = data.content;
@@ -492,7 +463,12 @@ export function useDomEditCommits({
492
463
  body: JSON.stringify({ target: patchTarget }),
493
464
  },
494
465
  );
495
- if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
466
+ if (!removeResponse.ok) {
467
+ throw await createStudioSaveHttpError(
468
+ removeResponse,
469
+ `Failed to delete element from ${targetPath}`,
470
+ );
471
+ }
496
472
 
497
473
  const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
498
474
  const patchedContent =
@@ -556,7 +532,7 @@ export function useDomEditCommits({
556
532
  } catch {
557
533
  /* cross-origin or detached — skip */
558
534
  }
559
- commitPositionPatchToHtml(
535
+ void commitPositionPatchToHtml(
560
536
  {
561
537
  element: entry.element,
562
538
  id: entry.id ?? null,
@@ -571,7 +547,7 @@ export function useDomEditCommits({
571
547
  coalesceKey,
572
548
  skipRefresh: i < entries.length - 1,
573
549
  },
574
- );
550
+ ).catch(() => undefined);
575
551
  }
576
552
  },
577
553
  [commitPositionPatchToHtml],
@@ -592,8 +568,6 @@ export function useDomEditCommits({
592
568
  handleDomBoxSizeCommit,
593
569
  handleDomRotationCommit,
594
570
  handleDomManualEditsReset,
595
- handleDomMotionCommit,
596
- handleDomMotionClear,
597
571
  handleDomEditElementDelete,
598
572
  handleDomZIndexReorderCommit,
599
573
  };
@@ -0,0 +1,53 @@
1
+ import { useCallback } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+ import type { PatchOperation } from "../utils/sourcePatcher";
4
+ import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
5
+ import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
6
+ import type { PersistDomEditOperations } from "./useDomEditCommits";
7
+
8
+ interface UseDomEditPositionPatchCommitParams {
9
+ activeCompPath: string | null;
10
+ persistDomEditOperations: PersistDomEditOperations;
11
+ queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
12
+ showToast: (message: string, tone?: "error" | "info") => void;
13
+ }
14
+
15
+ interface PositionPatchOptions {
16
+ label: string;
17
+ coalesceKey: string;
18
+ skipRefresh?: boolean;
19
+ }
20
+
21
+ export function useDomEditPositionPatchCommit({
22
+ activeCompPath,
23
+ persistDomEditOperations,
24
+ queueDomEditSave,
25
+ showToast,
26
+ }: UseDomEditPositionPatchCommitParams) {
27
+ return useCallback(
28
+ (selection: DomEditSelection, patches: PatchOperation[], options: PositionPatchOptions) => {
29
+ return queueDomEditSave(async () => {
30
+ await persistDomEditOperations(selection, patches, {
31
+ label: options.label,
32
+ coalesceKey: options.coalesceKey,
33
+ skipRefresh: options.skipRefresh ?? true,
34
+ });
35
+ }).catch((error) => {
36
+ if (error instanceof DomEditSaveQueueOpenError) return;
37
+ showToast(error instanceof Error ? error.message : "Failed to save position");
38
+ trackStudioSaveFailure({
39
+ source: "dom_edit",
40
+ error,
41
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
42
+ mutationType: "position",
43
+ label: options.label,
44
+ targetId: selection.id,
45
+ targetSelector: selection.selector,
46
+ targetSourceFile: selection.sourceFile,
47
+ });
48
+ throw error;
49
+ });
50
+ },
51
+ [activeCompPath, persistDomEditOperations, queueDomEditSave, showToast],
52
+ );
53
+ }
@@ -15,14 +15,12 @@ import type { SidebarTab } from "../components/sidebar/LeftSidebar";
15
15
  import { useAskAgentModal } from "./useAskAgentModal";
16
16
  import { useDomSelection } from "./useDomSelection";
17
17
  import { usePreviewInteraction } from "./usePreviewInteraction";
18
- import { useDomEditCommits } from "./useDomEditCommits";
18
+ import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, useDomEditCommits } from "./useDomEditCommits";
19
19
  import { useGsapScriptCommits } from "./useGsapScriptCommits";
20
20
  import {
21
21
  useGsapAnimationsForElement,
22
22
  useGsapCacheVersion,
23
23
  usePopulateKeyframeCacheForFile,
24
- fetchParsedAnimations,
25
- getAnimationsForElement,
26
24
  } from "./useGsapTweenCache";
27
25
  import {
28
26
  tryGsapDragIntercept,
@@ -30,6 +28,8 @@ import {
30
28
  tryGsapRotationIntercept,
31
29
  } from "./gsapRuntimeBridge";
32
30
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
31
+ import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
32
+ import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
33
33
  import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
34
34
 
35
35
  // ── Types ──
@@ -285,6 +285,7 @@ export function useDomEditSession({
285
285
  reloadPreview,
286
286
  onCacheInvalidate: bumpGsapCache,
287
287
  onFileContentChanged: updateEditingFileContent,
288
+ showToast,
288
289
  });
289
290
 
290
291
  // ── Commit handlers (delegated to useDomEditCommits) ──
@@ -303,8 +304,6 @@ export function useDomEditSession({
303
304
  handleDomBoxSizeCommit,
304
305
  handleDomRotationCommit,
305
306
  handleDomManualEditsReset,
306
- handleDomMotionCommit,
307
- handleDomMotionClear,
308
307
  handleDomEditElementDelete,
309
308
  handleDomZIndexReorderCommit,
310
309
  } = useDomEditCommits({
@@ -327,76 +326,66 @@ export function useDomEditSession({
327
326
  buildDomSelectionFromTarget,
328
327
  });
329
328
 
329
+ const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
330
+
331
+ const makeFetchFallback = useGsapAnimationFetchFallback(projectId, gsapSourceFile);
332
+
330
333
  // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
331
334
  const handleGsapAwarePathOffsetCommit = useCallback(
332
335
  async (selection: DomEditSelection, next: { x: number; y: number }) => {
333
336
  const hasGsapAnims = selectedGsapAnimations.length > 0;
334
337
  if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
335
- showToast(
336
- "This element is GSAP-animated — dragging via CSS would corrupt keyframes",
337
- "error",
338
- );
339
- return;
338
+ showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error");
339
+ throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
340
340
  }
341
341
  if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
342
- const handled = await tryGsapDragIntercept(
343
- selection,
344
- next,
345
- selectedGsapAnimations,
346
- previewIframeRef.current,
347
- gsapCommitMutation,
348
- async () => {
349
- const pid = projectId;
350
- if (!pid) return [];
351
- const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
352
- if (!parsed) return [];
353
- const target = { id: selection.id ?? null, selector: selection.selector ?? null };
354
- return getAnimationsForElement(parsed.animations, target);
355
- },
356
- );
357
- if (handled) return;
342
+ try {
343
+ const handled = await tryGsapDragIntercept(
344
+ selection,
345
+ next,
346
+ selectedGsapAnimations,
347
+ previewIframeRef.current,
348
+ gsapCommitMutation,
349
+ makeFetchFallback(selection),
350
+ );
351
+ if (handled) return;
352
+ } catch (error) {
353
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
354
+ throw error;
355
+ }
358
356
  }
359
- handleDomPathOffsetCommit(selection, next);
357
+ return handleDomPathOffsetCommit(selection, next);
360
358
  },
361
359
  [
362
360
  handleDomPathOffsetCommit,
363
361
  selectedGsapAnimations,
364
362
  gsapCommitMutation,
365
363
  previewIframeRef,
366
- projectId,
367
- gsapSourceFile,
364
+ makeFetchFallback,
365
+ trackGsapInteractionFailure,
368
366
  showToast,
369
367
  ],
370
368
  );
371
369
 
372
- const makeFetchFallback = useCallback(
373
- (selection: DomEditSelection) => async () => {
374
- const pid = projectId;
375
- if (!pid) return [];
376
- const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
377
- if (!parsed) return [];
378
- return getAnimationsForElement(parsed.animations, {
379
- id: selection.id ?? null,
380
- selector: selection.selector ?? null,
381
- });
382
- },
383
- [projectId, gsapSourceFile],
384
- );
385
-
386
370
  const handleGsapAwareBoxSizeCommit = useCallback(
387
371
  async (selection: DomEditSelection, next: { width: number; height: number }) => {
388
372
  if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
389
- const handled = await tryGsapResizeIntercept(
390
- selection,
391
- next,
392
- selectedGsapAnimations,
393
- previewIframeRef.current,
394
- gsapCommitMutation,
395
- makeFetchFallback(selection),
396
- );
397
- if (handled) return;
373
+ try {
374
+ const handled = await tryGsapResizeIntercept(
375
+ selection,
376
+ next,
377
+ selectedGsapAnimations,
378
+ previewIframeRef.current,
379
+ gsapCommitMutation,
380
+ makeFetchFallback(selection),
381
+ );
382
+ if (handled) return;
383
+ } catch (error) {
384
+ trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
385
+ throw error;
386
+ }
398
387
  }
399
- handleDomBoxSizeCommit(selection, next);
388
+ return handleDomBoxSizeCommit(selection, next);
400
389
  },
401
390
  [
402
391
  handleDomBoxSizeCommit,
@@ -404,23 +393,29 @@ export function useDomEditSession({
404
393
  gsapCommitMutation,
405
394
  previewIframeRef,
406
395
  makeFetchFallback,
396
+ trackGsapInteractionFailure,
407
397
  ],
408
398
  );
409
399
 
410
400
  const handleGsapAwareRotationCommit = useCallback(
411
401
  async (selection: DomEditSelection, next: { angle: number }) => {
412
402
  if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
413
- const handled = await tryGsapRotationIntercept(
414
- selection,
415
- next.angle,
416
- selectedGsapAnimations,
417
- previewIframeRef.current,
418
- gsapCommitMutation,
419
- makeFetchFallback(selection),
420
- );
421
- if (handled) return;
403
+ try {
404
+ const handled = await tryGsapRotationIntercept(
405
+ selection,
406
+ next.angle,
407
+ selectedGsapAnimations,
408
+ previewIframeRef.current,
409
+ gsapCommitMutation,
410
+ makeFetchFallback(selection),
411
+ );
412
+ if (handled) return;
413
+ } catch (error) {
414
+ trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
415
+ throw error;
416
+ }
422
417
  }
423
- handleDomRotationCommit(selection, next);
418
+ return handleDomRotationCommit(selection, next);
424
419
  },
425
420
  [
426
421
  handleDomRotationCommit,
@@ -428,6 +423,7 @@ export function useDomEditSession({
428
423
  gsapCommitMutation,
429
424
  previewIframeRef,
430
425
  makeFetchFallback,
426
+ trackGsapInteractionFailure,
431
427
  ],
432
428
  );
433
429
 
@@ -536,8 +532,6 @@ export function useDomEditSession({
536
532
  handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
537
533
  handleDomRotationCommit: handleGsapAwareRotationCommit,
538
534
  handleDomManualEditsReset,
539
- handleDomMotionCommit,
540
- handleDomMotionClear,
541
535
  handleDomTextCommit,
542
536
  handleDomTextFieldStyleCommit,
543
537
  handleDomAddTextField,
@@ -6,6 +6,11 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
6
6
  import type { EditHistoryKind } from "../utils/editHistory";
7
7
  import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
+ import {
10
+ createStudioSaveHttpError,
11
+ retryStudioSave,
12
+ StudioSaveNetworkError,
13
+ } from "../utils/studioSaveDiagnostics";
9
14
 
10
15
  // ── Types ──
11
16
 
@@ -97,12 +102,21 @@ export function useFileManager({
97
102
  const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
98
103
  const pid = projectIdRef.current;
99
104
  if (!pid) throw new Error("No active project");
100
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
101
- method: "PUT",
102
- headers: { "Content-Type": "text/plain" },
103
- body: content,
105
+ await retryStudioSave(async () => {
106
+ let response: Response;
107
+ try {
108
+ response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
109
+ method: "PUT",
110
+ headers: { "Content-Type": "text/plain" },
111
+ body: content,
112
+ });
113
+ } catch (error) {
114
+ throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
115
+ cause: error,
116
+ });
117
+ }
118
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
104
119
  });
105
- if (!response.ok) throw new Error(`Failed to save ${path}`);
106
120
  if (editingPathRef.current === path) {
107
121
  setEditingFile({ path, content });
108
122
  }