@hyperframes/studio 0.6.121 → 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.
- package/dist/assets/index-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-20PAVS4k.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- 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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
r.samples.push({ time, properties:
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
editLog("manual-drag:rotate", { id: selection.id, next });
|
|
161
|
+
if (gsapCommitMutation) {
|
|
178
162
|
try {
|
|
179
|
-
|
|
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
|
-
|
|
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,
|
|
239
|
+
[domEditSelection, safeGsapCommit],
|
|
242
240
|
);
|
|
243
241
|
|
|
244
242
|
// Unroll all computed (helper/loop) tweens in the active timeline into literal
|