@hyperframes/studio 0.6.122-alpha.0 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-i-MLqhmm.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-Baz2T-9U.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -1,6 +1,16 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { usePlayerStore, liveTime } from "../player/store/playerStore";
3
3
 
4
+ // `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack),
5
+ // so guard the access like the telemetry client does.
6
+ function isDevBuild(): boolean {
7
+ try {
8
+ return import.meta.env.DEV === true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
4
14
  export interface GestureSample {
5
15
  time: number;
6
16
  properties: Record<string, number>;
@@ -29,7 +39,7 @@ interface BasePosition {
29
39
 
30
40
  interface GsapRuntime {
31
41
  seek: (t: number) => void;
32
- set: (target: string, vars: Record<string, number>) => void;
42
+ set: (target: string, vars: Record<string, number | string>) => void;
33
43
  selector: string;
34
44
  element: HTMLElement;
35
45
  startTime: number;
@@ -85,11 +95,18 @@ function connectGsapRuntime(
85
95
  ): GsapRuntime | null {
86
96
  try {
87
97
  const win = iframeEl.contentWindow as Window & {
88
- gsap?: { set: (t: string, v: Record<string, number>) => void };
98
+ gsap?: { set: (t: string, v: Record<string, number | string>) => void };
89
99
  __timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
90
100
  __player?: { getTime: () => number };
91
101
  };
92
- const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
102
+ // Pick the first REAL timeline. `__timelines` also carries the studio's
103
+ // `__proxied` marker (a boolean, no `.seek`); `Object.values(...)[0]` would grab
104
+ // it and fail the connect — the cause of the no-live-preview gesture bug.
105
+ const tl = win?.__timelines
106
+ ? (Object.entries(win.__timelines).find(
107
+ ([key, value]) => key !== "__proxied" && typeof value?.seek === "function",
108
+ )?.[1] ?? null)
109
+ : null;
93
110
  if (win?.gsap?.set && tl?.seek && selector) {
94
111
  const tlDuration = tl.duration();
95
112
  return {
@@ -105,7 +122,7 @@ function connectGsapRuntime(
105
122
  };
106
123
  }
107
124
  } catch {
108
- /* cross-origin or missing runtime */
125
+ /* connect failed */
109
126
  }
110
127
  return null;
111
128
  }
@@ -125,14 +142,14 @@ function applyRuntimePreview(
125
142
  }
126
143
 
127
144
  function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
128
- const sampleProps = { ...properties };
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);
135
- r.samples.push({ time, properties: sampleProps });
145
+ // Record the FULL position the live preview shows (element centered on the
146
+ // pointer, with any manual path offset folded into basePosition). Do NOT
147
+ // subtract the path offset: when this gesture commits as a position tween the
148
+ // server strips the element's --hf-studio-offset (the tween owns position — see
149
+ // stripStudioEditsFromTarget in studio-api), so the keyframes must already
150
+ // include it. Subtracting it made the committed gesture play shoved off by the
151
+ // offset (the offset was removed twice).
152
+ r.samples.push({ time, properties: { ...properties } });
136
153
  r.trail.push({ x: r.pointer.x, y: r.pointer.y });
137
154
  }
138
155
 
@@ -204,7 +221,6 @@ interface RecordingRefs {
204
221
  basePosition: { x: number; y: number };
205
222
  cssVarOffset: { x: number; y: number };
206
223
  scale: number;
207
- pointerElementOffset: { x: number; y: number };
208
224
  runtime: GsapRuntime | null;
209
225
  rafId: number;
210
226
  samples: GestureSample[];
@@ -223,7 +239,6 @@ function createRecordingRefs(): RecordingRefs {
223
239
  basePosition: { x: 0, y: 0 },
224
240
  cssVarOffset: { x: 0, y: 0 },
225
241
  scale: 1,
226
- pointerElementOffset: { x: 0, y: 0 },
227
242
  runtime: null,
228
243
  rafId: 0,
229
244
  samples: [],
@@ -280,30 +295,21 @@ export function useGestureRecording() {
280
295
  r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 };
281
296
  r.basePosition = { x: base.baseX, y: base.baseY };
282
297
 
298
+ // --- Phase 2: iframe → studio scale, measured BEFORE clearing the path offset ---
299
+ // The pointer deltas in the RAF loop are in studio-viewport pixels; divide by
300
+ // this scale to convert them to the iframe's composition pixels.
301
+ r.scale = computeIframeScale(iframeEl);
302
+
303
+ // Now clear the optimistic path offset (already folded into baseX/baseY).
283
304
  if (base.cssOffX || base.cssOffY) {
284
305
  element.style.setProperty("--hf-studio-offset-x", "0px");
285
306
  element.style.setProperty("--hf-studio-offset-y", "0px");
286
307
  }
287
308
 
288
- // --- Phase 2: Connect to the iframe GSAP runtime ---
309
+ // --- Phase 3: Connect to the iframe GSAP runtime ---
289
310
  const selector = element.id ? `#${element.id}` : null;
290
311
  r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
291
312
 
292
- // --- Phase 3: Compute iframe viewport → composition scale ---
293
- r.scale = computeIframeScale(iframeEl);
294
-
295
- // --- Phase 4: Element center for pointer-element offset ---
296
- // element.getBoundingClientRect() is in the iframe's viewport.
297
- // Convert to the studio (parent) viewport using the iframe's position and scale.
298
- const iframeRect = iframeEl.getBoundingClientRect();
299
- const elRect = element.getBoundingClientRect();
300
- const iframeScale = r.scale || 1;
301
- const elCenterViewport = {
302
- x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale,
303
- y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale,
304
- };
305
- r.pointerElementOffset = { x: 0, y: 0 };
306
-
307
313
  // --- Phase 5: Attach event listeners ---
308
314
  const handlePointerMove = (e: PointerEvent) => {
309
315
  r.pointer = { x: e.clientX, y: e.clientY };
@@ -315,12 +321,6 @@ export function useGestureRecording() {
315
321
  // preventing an enormous bogus first keyframe from stale startPointer.
316
322
  if (!r.hasMoved) {
317
323
  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
324
  r.hasMoved = true;
325
325
  }
326
326
  r.scrollDelta += e.deltaY;
@@ -341,12 +341,12 @@ export function useGestureRecording() {
341
341
  r.startPointer = { ...r.pointer };
342
342
  const captureStart = (e: PointerEvent) => {
343
343
  if (!r.hasMoved) {
344
+ // Anchor the delta at the grab point — the element then moves by the
345
+ // pointer's *movement* from its actual position (preserving both the
346
+ // manual-drag start position and the grab offset). Do NOT snap the
347
+ // element's center to the pointer: that discarded the manual position
348
+ // and made the recorded 0% keyframe wrong.
344
349
  r.startPointer = { x: e.clientX, y: e.clientY };
345
- const offX = e.clientX - elCenterViewport.x;
346
- const offY = e.clientY - elCenterViewport.y;
347
- r.pointerElementOffset = { x: offX, y: offY };
348
- r.basePosition.x += offX / iframeScale;
349
- r.basePosition.y += offY / iframeScale;
350
350
  r.hasMoved = true;
351
351
  }
352
352
  };
@@ -385,12 +385,19 @@ export function useGestureRecording() {
385
385
  if (r.runtime) {
386
386
  try {
387
387
  applyRuntimePreview(r.runtime, time, properties);
388
- } catch {
388
+ } catch (err) {
389
+ // Preview failed — disable it for the rest of the gesture (recording
390
+ // continues). Surface in dev so a dead preview isn't silent; `r.runtime`
391
+ // is nulled below so this warns at most once per gesture.
392
+ if (isDevBuild()) {
393
+ console.warn("[GR] live preview disabled — runtime threw:", err);
394
+ }
389
395
  r.runtime = null;
390
396
  }
391
397
  }
392
398
 
393
399
  recordSample(r, time, properties);
400
+
394
401
  setRecordingDuration(time);
395
402
  r.rafId = requestAnimationFrame(tick);
396
403
  };
@@ -418,6 +425,18 @@ export function useGestureRecording() {
418
425
  const { element: el, savedVisibility, savedTranslate } = r.runtime;
419
426
  el.style.visibility = savedVisibility;
420
427
  el.style.setProperty("translate", savedTranslate || "");
428
+ // Drop the gesture's inline gsap transform before re-applying the path
429
+ // offset below, so the two don't briefly stack (the recorded keyframes
430
+ // already encode the full position, offset included). On commit the
431
+ // re-seek lands on the gesture's first keyframe; on cancel this leaves the
432
+ // element at its pre-recording position.
433
+ try {
434
+ r.runtime.set(r.runtime.selector, {
435
+ clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
436
+ });
437
+ } catch {
438
+ /* runtime gone */
439
+ }
421
440
  }
422
441
  if (r.cssVarOffset.x || r.cssVarOffset.y) {
423
442
  const el = r.runtime?.element;
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
+ import { selectElementAnimationsOrRetry } from "./useGsapAnimationFetchFallback";
4
+
5
+ const anim = (targetSelector: string): GsapAnimation =>
6
+ ({ id: targetSelector, targetSelector, properties: {} }) as unknown as GsapAnimation;
7
+ const parsed = (anims: GsapAnimation[]): ParsedGsap => ({ animations: anims }) as ParsedGsap;
8
+ const target = { id: "puck-a", selector: "#puck-a" };
9
+
10
+ describe("selectElementAnimationsOrRetry", () => {
11
+ it("signals fetch-error (short retry) when the fetch itself failed (null)", () => {
12
+ // A null parse means fetchParsedAnimations hit a 404/network/JSON failure —
13
+ // not a parse-warming race, so it must NOT be conflated with a cold parse.
14
+ expect(selectElementAnimationsOrRetry(null, target)).toEqual({ kind: "fetch-error" });
15
+ });
16
+
17
+ it("signals cold (full retry budget) when the parse is reachable but has zero total animations", () => {
18
+ expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({ kind: "cold" });
19
+ });
20
+
21
+ it("resolves the matching animations from a warm parse", () => {
22
+ const outcome = selectElementAnimationsOrRetry(
23
+ parsed([anim("#puck-a"), anim("#other")]),
24
+ target,
25
+ );
26
+ expect(outcome.kind).toBe("resolved");
27
+ expect(outcome.kind === "resolved" && outcome.animations.map((a) => a.targetSelector)).toEqual([
28
+ "#puck-a",
29
+ ]);
30
+ });
31
+
32
+ it("resolves to [] (no retry) for a warm parse with no match — element genuinely has no animation", () => {
33
+ const outcome = selectElementAnimationsOrRetry(parsed([anim("#other")]), target);
34
+ expect(outcome).toEqual({ kind: "resolved", animations: [] });
35
+ });
36
+ });
@@ -1,18 +1,74 @@
1
1
  import { useCallback } from "react";
2
+ import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
2
3
  import type { DomEditSelection } from "../components/editor/domEditing";
3
4
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
4
5
 
6
+ // A cold parse is the initial-load race: the endpoint is reachable but its parse
7
+ // isn't warm yet (zero total animations). It's worth waiting out (~600ms).
8
+ const COLD_PARSE_RETRIES = 5;
9
+ const COLD_PARSE_DELAY_MS = 120;
10
+ // A hard fetch error (404/403/network/JSON failure → `fetchParsedAnimations`
11
+ // returns null) is NOT a parse-warming race, so it shouldn't burn the full
12
+ // cold-parse budget. One short retry covers a transient blip; beyond that the
13
+ // endpoint genuinely isn't serving this file, so fall through to "no animation".
14
+ const FETCH_ERROR_RETRIES = 1;
15
+ const FETCH_ERROR_DELAY_MS = 120;
16
+
17
+ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
18
+
19
+ /**
20
+ * Outcome of resolving an element's animations from a single parse result.
21
+ * - `resolved`: a definitive answer (matched animations, or `[]` when the parse
22
+ * is warm but this element has no animation — create a new one, don't retry).
23
+ * - `fetch-error`: `fetchParsedAnimations` returned null (HTTP/network/JSON
24
+ * failure) — retry briefly, not for the full cold-parse budget.
25
+ * - `cold`: the parse came back reachable but with zero total animations — the
26
+ * initial-load warming race, worth the full cold-parse retry budget.
27
+ */
28
+ export type ElementAnimationsOutcome =
29
+ | { kind: "resolved"; animations: GsapAnimation[] }
30
+ | { kind: "fetch-error" }
31
+ | { kind: "cold" };
32
+
33
+ /**
34
+ * Classify a parse result for one element. Differentiates a hard fetch failure
35
+ * (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
36
+ * so the caller can apply the right retry budget to each.
37
+ */
38
+ export function selectElementAnimationsOrRetry(
39
+ parsed: ParsedGsap | null,
40
+ target: { id: string | null; selector: string | null },
41
+ ): ElementAnimationsOutcome {
42
+ if (!parsed) return { kind: "fetch-error" };
43
+ if (parsed.animations.length === 0) return { kind: "cold" };
44
+ return { kind: "resolved", animations: getAnimationsForElement(parsed.animations, target) };
45
+ }
46
+
5
47
  export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
6
48
  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
- });
49
+ (selection: DomEditSelection) => async (): Promise<GsapAnimation[]> => {
50
+ if (!projectId) return [];
51
+ const target = { id: selection.id ?? null, selector: selection.selector ?? null };
52
+ // A drag can fire before the async parse is warm; a cold parse must retry
53
+ // rather than fall through to the no-animation path (which duplicates the
54
+ // tween). A hard fetch error is a different failure — retry only briefly.
55
+ let coldAttempts = 0;
56
+ let errorAttempts = 0;
57
+ for (;;) {
58
+ const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
59
+ const outcome = selectElementAnimationsOrRetry(parsed, target);
60
+ if (outcome.kind === "resolved") return outcome.animations;
61
+ if (outcome.kind === "fetch-error") {
62
+ if (errorAttempts >= FETCH_ERROR_RETRIES) return [];
63
+ errorAttempts++;
64
+ await delay(FETCH_ERROR_DELAY_MS);
65
+ continue;
66
+ }
67
+ // cold
68
+ if (coldAttempts >= COLD_PARSE_RETRIES) return [];
69
+ coldAttempts++;
70
+ await delay(COLD_PARSE_DELAY_MS);
71
+ }
16
72
  },
17
73
  [projectId, gsapSourceFile],
18
74
  );
@@ -101,7 +101,7 @@ export function useGsapAnimationOps({
101
101
  void commitMutation(
102
102
  selection,
103
103
  { type: "delete-all-for-selector", targetSelector },
104
- { label: "Delete all animations for element" },
104
+ { label: "Delete all animations for element", softReload: true },
105
105
  );
106
106
  },
107
107
  [commitMutation, activeCompPath, sdkSession, sdkDeps],
@@ -8,16 +8,19 @@
8
8
  * from the rest of the editing orchestration.
9
9
  */
10
10
  import { useCallback } from "react";
11
+ import { editLog } from "../utils/editDebugLog";
11
12
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
13
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
- import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
14
- import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
15
14
  import {
16
15
  tryGsapDragIntercept,
17
16
  tryGsapResizeIntercept,
18
17
  tryGsapRotationIntercept,
19
18
  } from "./gsapRuntimeBridge";
20
19
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
20
+ import {
21
+ useGsapSaveFailureTelemetry,
22
+ useSafeGsapCommitMutation,
23
+ } from "./useSafeGsapCommitMutation";
21
24
  import type { CommitMutation } from "./gsapScriptCommitTypes";
22
25
 
23
26
  export interface UseGsapAwareEditingParams {
@@ -35,15 +38,10 @@ export interface UseGsapAwareEditingParams {
35
38
  label: string,
36
39
  ) => void;
37
40
  // DOM fallbacks (from useDomEditCommits)
38
- handleDomPathOffsetCommit: (
39
- selection: DomEditSelection,
40
- next: { x: number; y: number },
41
- ) => Promise<void>;
42
41
  handleDomBoxSizeCommit: (
43
42
  selection: DomEditSelection,
44
43
  next: { width: number; height: number },
45
44
  ) => Promise<void>;
46
- handleDomRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void>;
47
45
  // GSAP script commit ops (from useGsapScriptCommits)
48
46
  addGsapAnimation: (
49
47
  sel: DomEditSelection,
@@ -85,9 +83,7 @@ export function useGsapAwareEditing({
85
83
  bumpGsapCache,
86
84
  makeFetchFallback,
87
85
  trackGsapInteractionFailure,
88
- handleDomPathOffsetCommit,
89
86
  handleDomBoxSizeCommit,
90
- handleDomRotationCommit,
91
87
  addGsapAnimation,
92
88
  convertToKeyframes,
93
89
  setArcPath,
@@ -96,55 +92,42 @@ export function useGsapAwareEditing({
96
92
  // ── GSAP-aware geometry commits ──
97
93
 
98
94
  const handleGsapAwarePathOffsetCommit = useCallback(
99
- async (selection: DomEditSelection, next: { x: number; y: number }) => {
100
- const hasGsapAnims = selectedGsapAnimations.length > 0;
101
- console.log(
102
- "[drag:3] handleGsapAwarePathOffsetCommit",
103
- JSON.stringify({
104
- sel: selection.id,
105
- offset: next,
106
- hasGsapAnims,
107
- interceptEnabled: STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
108
- animCount: selectedGsapAnimations.length,
109
- animIds: selectedGsapAnimations.map((a) => a.id).slice(0, 5),
110
- }),
111
- );
112
- if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
113
- showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error");
114
- throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
115
- }
116
- if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
95
+ async (
96
+ selection: DomEditSelection,
97
+ next: { x: number; y: number },
98
+ modifiers?: { altKey?: boolean },
99
+ ) => {
100
+ editLog("manual-drag:move", { id: selection.id, next, altKey: modifiers?.altKey });
101
+ if (gsapCommitMutation) {
117
102
  try {
118
- const handled = await tryGsapDragIntercept(
103
+ await tryGsapDragIntercept(
119
104
  selection,
120
105
  next,
121
106
  selectedGsapAnimations,
122
107
  previewIframeRef.current,
123
108
  gsapCommitMutation,
124
109
  makeFetchFallback(selection),
110
+ modifiers,
125
111
  );
126
- if (handled) return;
127
112
  } catch (error) {
128
113
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
129
114
  throw error;
130
115
  }
131
116
  }
132
- return handleDomPathOffsetCommit(selection, next);
133
117
  },
134
118
  [
135
- handleDomPathOffsetCommit,
136
119
  selectedGsapAnimations,
137
120
  gsapCommitMutation,
138
121
  previewIframeRef,
139
122
  makeFetchFallback,
140
123
  trackGsapInteractionFailure,
141
- showToast,
142
124
  ],
143
125
  );
144
126
 
145
127
  const handleGsapAwareBoxSizeCommit = useCallback(
146
128
  async (selection: DomEditSelection, next: { width: number; height: number }) => {
147
- if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
129
+ editLog("manual-drag:resize", { id: selection.id, next });
130
+ if (gsapCommitMutation) {
148
131
  try {
149
132
  const handled = await tryGsapResizeIntercept(
150
133
  selection,
@@ -174,9 +157,13 @@ export function useGsapAwareEditing({
174
157
 
175
158
  const handleGsapAwareRotationCommit = useCallback(
176
159
  async (selection: DomEditSelection, next: { angle: number }) => {
177
- if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
160
+ editLog("manual-drag:rotate", { id: selection.id, next });
161
+ if (gsapCommitMutation) {
178
162
  try {
179
- const handled = await tryGsapRotationIntercept(
163
+ // Single source of truth for rotation too: tryGsapRotationIntercept handles
164
+ // tweened elements (keyframes) and static ones (a tl.set), so there's no
165
+ // CSS-var fallback. It returns false only for a selectorless element (no-op).
166
+ await tryGsapRotationIntercept(
180
167
  selection,
181
168
  next.angle,
182
169
  selectedGsapAnimations,
@@ -184,16 +171,13 @@ export function useGsapAwareEditing({
184
171
  gsapCommitMutation,
185
172
  makeFetchFallback(selection),
186
173
  );
187
- if (handled) return;
188
174
  } catch (error) {
189
175
  trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
190
176
  throw error;
191
177
  }
192
178
  }
193
- return handleDomRotationCommit(selection, next);
194
179
  },
195
180
  [
196
- handleDomRotationCommit,
197
181
  selectedGsapAnimations,
198
182
  gsapCommitMutation,
199
183
  previewIframeRef,
@@ -232,13 +216,27 @@ export function useGsapAwareEditing({
232
216
  );
233
217
 
234
218
  // ── Thin commitMutation facade ──
219
+ // Routes through the canonical safe wrapper so a server-save failure surfaces a
220
+ // toast + save telemetry instead of silently reverting — parity with the
221
+ // arc/keyframe/animation ops that all go through useSafeGsapCommitMutation.
222
+
223
+ const noopCommit = useCallback<CommitMutation>(async () => {}, []);
224
+ const trackGsapSaveFailure = useGsapSaveFailureTelemetry(null);
225
+ const safeGsapCommit = useSafeGsapCommitMutation(
226
+ gsapCommitMutation ?? noopCommit,
227
+ trackGsapSaveFailure,
228
+ showToast,
229
+ );
235
230
 
236
231
  const commitMutation = useCallback(
237
232
  async (mutation: Record<string, unknown>, options: { label: string; softReload?: boolean }) => {
238
233
  if (!domEditSelection) return;
239
- await gsapCommitMutation?.(domEditSelection, mutation, options);
234
+ // Return (await) the safe-commit chain so consumers that `await
235
+ // session.commitMutation(...)` (gesture recording, enable-keyframes) run
236
+ // their post-actions only after the server save has settled.
237
+ await safeGsapCommit(domEditSelection, mutation, options);
240
238
  },
241
- [domEditSelection, gsapCommitMutation],
239
+ [domEditSelection, safeGsapCommit],
242
240
  );
243
241
 
244
242
  // Unroll all computed (helper/loop) tweens in the active timeline into literal