@hyperframes/studio 0.7.90 → 0.7.93
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/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -30,6 +30,11 @@ export type ElementAnimationsOutcome =
|
|
|
30
30
|
| { kind: "fetch-error" }
|
|
31
31
|
| { kind: "cold" };
|
|
32
32
|
|
|
33
|
+
export interface GsapAnimationFetchOptions {
|
|
34
|
+
/** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
|
|
35
|
+
failOnFetchError?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/**
|
|
34
39
|
* Classify a parse result for one element. Differentiates a hard fetch failure
|
|
35
40
|
* (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
|
|
@@ -44,32 +49,48 @@ export function selectElementAnimationsOrRetry(
|
|
|
44
49
|
return { kind: "resolved", animations: getAnimationsForElement(parsed.animations, target) };
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
// Retry policy deliberately distinguishes cold parses from hard fetch errors.
|
|
53
|
+
// fallow-ignore-next-line complexity
|
|
54
|
+
async function fetchElementAnimationsWithRetry(
|
|
55
|
+
projectId: string,
|
|
56
|
+
gsapSourceFile: string,
|
|
57
|
+
target: { id: string | null; selector: string | null },
|
|
58
|
+
failOnFetchError: boolean,
|
|
59
|
+
): Promise<GsapAnimation[]> {
|
|
60
|
+
let coldAttempts = 0;
|
|
61
|
+
let errorAttempts = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
|
|
64
|
+
const outcome = selectElementAnimationsOrRetry(parsed, target);
|
|
65
|
+
if (outcome.kind === "resolved") return outcome.animations;
|
|
66
|
+
if (outcome.kind === "fetch-error") {
|
|
67
|
+
if (errorAttempts >= FETCH_ERROR_RETRIES) {
|
|
68
|
+
if (failOnFetchError) throw new Error("GSAP animation ownership could not be verified");
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
errorAttempts++;
|
|
72
|
+
await delay(FETCH_ERROR_DELAY_MS);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (coldAttempts >= COLD_PARSE_RETRIES) return [];
|
|
76
|
+
coldAttempts++;
|
|
77
|
+
await delay(COLD_PARSE_DELAY_MS);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
|
|
48
82
|
return useCallback(
|
|
49
|
-
(selection: DomEditSelection
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
}
|
|
72
|
-
},
|
|
83
|
+
(selection: DomEditSelection, options?: GsapAnimationFetchOptions) =>
|
|
84
|
+
async (): Promise<GsapAnimation[]> => {
|
|
85
|
+
if (!projectId) return [];
|
|
86
|
+
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
|
|
87
|
+
return fetchElementAnimationsWithRetry(
|
|
88
|
+
projectId,
|
|
89
|
+
gsapSourceFile,
|
|
90
|
+
target,
|
|
91
|
+
options?.failOnFetchError === true,
|
|
92
|
+
);
|
|
93
|
+
},
|
|
73
94
|
[projectId, gsapSourceFile],
|
|
74
95
|
);
|
|
75
96
|
}
|
|
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
|
|
|
14
14
|
drag: vi.fn(),
|
|
15
15
|
readPosition: vi.fn(),
|
|
16
16
|
setPosition: vi.fn(),
|
|
17
|
+
commitAnimatedProperty: vi.fn(),
|
|
18
|
+
commitAnimatedProperties: vi.fn(),
|
|
17
19
|
}));
|
|
18
20
|
|
|
19
21
|
vi.mock("./gsapResizeIntercept", () => ({ tryGsapResizeIntercept: mocks.resize }));
|
|
@@ -28,8 +30,8 @@ vi.mock("./gsapPositionDetection", () => ({
|
|
|
28
30
|
vi.mock("../utils/elementGsap", () => ({ setElementGsapPosition: mocks.setPosition }));
|
|
29
31
|
vi.mock("./useAnimatedPropertyCommit", () => ({
|
|
30
32
|
useAnimatedPropertyCommit: () => ({
|
|
31
|
-
commitAnimatedProperty:
|
|
32
|
-
commitAnimatedProperties:
|
|
33
|
+
commitAnimatedProperty: mocks.commitAnimatedProperty,
|
|
34
|
+
commitAnimatedProperties: mocks.commitAnimatedProperties,
|
|
33
35
|
}),
|
|
34
36
|
}));
|
|
35
37
|
vi.mock("./useSafeGsapCommitMutation", () => ({
|
|
@@ -78,22 +80,51 @@ function mountResizeHandler(animations: GsapAnimation[]) {
|
|
|
78
80
|
return { selection, fallback, commitMutation, resize: resize!, root };
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
type AwareEditingParams = Parameters<typeof useGsapAwareEditing>[0];
|
|
84
|
+
|
|
85
|
+
function mountGroupHandler({
|
|
86
|
+
gsapCommitMutation,
|
|
87
|
+
makeFetchFallback,
|
|
88
|
+
trackGsapInteractionFailure = vi.fn(),
|
|
89
|
+
}: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
|
|
90
|
+
Partial<Pick<AwareEditingParams, "trackGsapInteractionFailure">>) {
|
|
91
|
+
let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
|
|
92
|
+
function Harness() {
|
|
93
|
+
groupCommit = useGsapAwareEditing({
|
|
94
|
+
domEditSelection: null,
|
|
95
|
+
selectedGsapAnimations: [],
|
|
96
|
+
gsapCommitMutation,
|
|
97
|
+
previewIframeRef: { current: null },
|
|
98
|
+
showToast: vi.fn(),
|
|
99
|
+
bumpGsapCache: vi.fn(),
|
|
100
|
+
makeFetchFallback,
|
|
101
|
+
trackGsapInteractionFailure,
|
|
102
|
+
handleDomBoxSizeCommit: vi.fn(),
|
|
103
|
+
addGsapAnimation: vi.fn(),
|
|
104
|
+
convertToKeyframes: vi.fn(),
|
|
105
|
+
setArcPath: vi.fn(),
|
|
106
|
+
updateArcSegment: vi.fn(),
|
|
107
|
+
}).handleGsapAwareGroupPathOffsetCommit;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const root = mountReactHarness(<Harness />);
|
|
111
|
+
return { groupCommit: (updates: DomEditGroupPathOffsetCommit[]) => groupCommit(updates), root };
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
describe("useGsapAwareEditing anchored resize", () => {
|
|
82
|
-
it("
|
|
83
|
-
mocks.resize.mockResolvedValue(
|
|
115
|
+
it("rejects a blocked resize instead of falling through to a competing DOM write", async () => {
|
|
116
|
+
mocks.resize.mockResolvedValue({ status: "blocked", reason: "source-uneditable" });
|
|
84
117
|
const h = mountResizeHandler([]);
|
|
85
|
-
await
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
{ x: -50, y: -25 },
|
|
90
|
-
);
|
|
118
|
+
await expect(
|
|
119
|
+
act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 })),
|
|
120
|
+
).rejects.toMatchObject({ reason: "source-uneditable" });
|
|
121
|
+
expect(h.fallback).not.toHaveBeenCalled();
|
|
91
122
|
act(() => h.root.unmount());
|
|
92
123
|
});
|
|
93
124
|
|
|
94
125
|
it("persists the anchor exactly once through GSAP position when size route handles resize", async () => {
|
|
95
|
-
mocks.resize.mockResolvedValue(
|
|
96
|
-
mocks.drag.mockResolvedValue(
|
|
126
|
+
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
127
|
+
mocks.drag.mockResolvedValue({ status: "persisted" });
|
|
97
128
|
const h = mountResizeHandler([]);
|
|
98
129
|
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
|
|
99
130
|
expect(h.fallback).not.toHaveBeenCalled();
|
|
@@ -103,12 +134,12 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
103
134
|
});
|
|
104
135
|
|
|
105
136
|
it("settles the live GSAP position before resize persistence reaches its first await", async () => {
|
|
106
|
-
let resolveResize!: (
|
|
107
|
-
const pendingResize = new Promise<
|
|
137
|
+
let resolveResize!: (outcome: { status: "persisted" }) => void;
|
|
138
|
+
const pendingResize = new Promise<{ status: "persisted" }>((resolve) => {
|
|
108
139
|
resolveResize = resolve;
|
|
109
140
|
});
|
|
110
141
|
mocks.resize.mockReturnValue(pendingResize);
|
|
111
|
-
mocks.drag.mockResolvedValue(
|
|
142
|
+
mocks.drag.mockResolvedValue({ status: "persisted" });
|
|
112
143
|
mocks.readPosition.mockReturnValue({ x: 120.4, y: 80.2 });
|
|
113
144
|
const h = mountResizeHandler([]);
|
|
114
145
|
h.selection.element.setAttribute("data-hf-drag-gsap-base-x", "120.4");
|
|
@@ -126,7 +157,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
126
157
|
mocks.resize.mock.invocationCallOrder[0]!,
|
|
127
158
|
);
|
|
128
159
|
|
|
129
|
-
resolveResize(
|
|
160
|
+
resolveResize({ status: "persisted" });
|
|
130
161
|
await act(() => commit);
|
|
131
162
|
act(() => h.root.unmount());
|
|
132
163
|
});
|
|
@@ -134,7 +165,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
134
165
|
it("passes a transaction-scoped commit wrapper into the resize path", async () => {
|
|
135
166
|
mocks.resize.mockImplementation(async (selection, _size, _animations, _iframe, commit) => {
|
|
136
167
|
await commit(selection, { type: "resize" }, { label: "Resize", softReload: true });
|
|
137
|
-
return
|
|
168
|
+
return { status: "persisted" };
|
|
138
169
|
});
|
|
139
170
|
const h = mountResizeHandler([]);
|
|
140
171
|
|
|
@@ -164,9 +195,12 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
164
195
|
m: unknown,
|
|
165
196
|
o: { coalesceKey?: string; label?: string; softReload?: boolean },
|
|
166
197
|
) => Promise<void>,
|
|
198
|
+
_fetch: unknown,
|
|
199
|
+
options?: { preflightOnly?: boolean },
|
|
167
200
|
) => {
|
|
201
|
+
if (options?.preflightOnly) return { status: "persisted" };
|
|
168
202
|
await commit(selection, { type: "move" }, { label: "Move", softReload: true });
|
|
169
|
-
return
|
|
203
|
+
return { status: "persisted" };
|
|
170
204
|
},
|
|
171
205
|
);
|
|
172
206
|
const commitMutation = vi.fn(
|
|
@@ -175,26 +209,10 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
175
209
|
return Promise.resolve();
|
|
176
210
|
},
|
|
177
211
|
);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
selectedGsapAnimations: [],
|
|
183
|
-
gsapCommitMutation: commitMutation,
|
|
184
|
-
previewIframeRef: { current: null },
|
|
185
|
-
showToast: vi.fn(),
|
|
186
|
-
bumpGsapCache: vi.fn(),
|
|
187
|
-
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
|
|
188
|
-
trackGsapInteractionFailure: vi.fn(),
|
|
189
|
-
handleDomBoxSizeCommit: vi.fn(),
|
|
190
|
-
addGsapAnimation: vi.fn(),
|
|
191
|
-
convertToKeyframes: vi.fn(),
|
|
192
|
-
setArcPath: vi.fn(),
|
|
193
|
-
updateArcSegment: vi.fn(),
|
|
194
|
-
}).handleGsapAwareGroupPathOffsetCommit;
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
const root = mountReactHarness(<Harness />);
|
|
212
|
+
const { groupCommit, root } = mountGroupHandler({
|
|
213
|
+
gsapCommitMutation: commitMutation,
|
|
214
|
+
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
|
|
215
|
+
});
|
|
198
216
|
const updates = [
|
|
199
217
|
{
|
|
200
218
|
selection: { element: document.createElement("div"), id: "a", selector: "#a" },
|
|
@@ -215,6 +233,74 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
215
233
|
act(() => root.unmount());
|
|
216
234
|
});
|
|
217
235
|
|
|
236
|
+
it("preflights every group member before the first mutation", async () => {
|
|
237
|
+
const commitMutation = vi.fn().mockResolvedValue(undefined);
|
|
238
|
+
const makeFetchFallback = vi.fn(() => vi.fn().mockResolvedValue([]));
|
|
239
|
+
mocks.drag.mockImplementation(
|
|
240
|
+
async (_selection, _next, _animations, _iframe, _commit, _fetch, options) => {
|
|
241
|
+
if (options?.preflightOnly) {
|
|
242
|
+
return _selection.id === "blocked"
|
|
243
|
+
? { status: "blocked", reason: "source-uneditable" }
|
|
244
|
+
: { status: "persisted" };
|
|
245
|
+
}
|
|
246
|
+
await _commit(_selection, { type: "move" }, { label: "Move" });
|
|
247
|
+
return { status: "persisted" };
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
const { groupCommit, root } = mountGroupHandler({
|
|
251
|
+
gsapCommitMutation: commitMutation,
|
|
252
|
+
makeFetchFallback,
|
|
253
|
+
});
|
|
254
|
+
const updates = [
|
|
255
|
+
{
|
|
256
|
+
selection: { element: document.createElement("div"), id: "ok", selector: "#ok" },
|
|
257
|
+
next: { x: 10, y: 10 },
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
selection: {
|
|
261
|
+
element: document.createElement("div"),
|
|
262
|
+
id: "blocked",
|
|
263
|
+
selector: "#blocked",
|
|
264
|
+
},
|
|
265
|
+
next: { x: 10, y: 10 },
|
|
266
|
+
},
|
|
267
|
+
] as unknown as DomEditGroupPathOffsetCommit[];
|
|
268
|
+
|
|
269
|
+
await expect(groupCommit(updates)).rejects.toMatchObject({
|
|
270
|
+
name: "GsapEditBlockedError",
|
|
271
|
+
reason: "source-uneditable",
|
|
272
|
+
});
|
|
273
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
274
|
+
expect(mocks.drag).toHaveBeenCalledTimes(2);
|
|
275
|
+
expect(makeFetchFallback).toHaveBeenNthCalledWith(1, updates[0]!.selection, {
|
|
276
|
+
failOnFetchError: true,
|
|
277
|
+
});
|
|
278
|
+
expect(makeFetchFallback).toHaveBeenNthCalledWith(2, updates[1]!.selection, {
|
|
279
|
+
failOnFetchError: true,
|
|
280
|
+
});
|
|
281
|
+
act(() => root.unmount());
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("fails a group preflight closed when ownership cannot be fetched", async () => {
|
|
285
|
+
const fetchError = new Error("parse endpoint unavailable");
|
|
286
|
+
const commitMutation = vi.fn().mockResolvedValue(undefined);
|
|
287
|
+
const { groupCommit, root } = mountGroupHandler({
|
|
288
|
+
gsapCommitMutation: commitMutation,
|
|
289
|
+
makeFetchFallback: () => vi.fn().mockRejectedValue(fetchError),
|
|
290
|
+
});
|
|
291
|
+
const updates = [
|
|
292
|
+
{
|
|
293
|
+
selection: { element: document.createElement("div"), id: "a", selector: "#a" },
|
|
294
|
+
next: { x: 10, y: 10 },
|
|
295
|
+
},
|
|
296
|
+
] as unknown as DomEditGroupPathOffsetCommit[];
|
|
297
|
+
|
|
298
|
+
await expect(groupCommit(updates)).rejects.toBe(fetchError);
|
|
299
|
+
expect(mocks.drag).not.toHaveBeenCalled();
|
|
300
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
301
|
+
act(() => root.unmount());
|
|
302
|
+
});
|
|
303
|
+
|
|
218
304
|
it("restores once when resize persistence fails", async () => {
|
|
219
305
|
const error = new Error("resize failed");
|
|
220
306
|
const restore = vi.fn();
|
|
@@ -228,7 +314,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
228
314
|
});
|
|
229
315
|
|
|
230
316
|
it("does not apply the anchor twice when scale route already settles the drop point", async () => {
|
|
231
|
-
mocks.resize.mockResolvedValue(
|
|
317
|
+
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
232
318
|
const scale = { propertyGroup: "scale" } as GsapAnimation;
|
|
233
319
|
const h = mountResizeHandler([scale]);
|
|
234
320
|
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
|
|
@@ -30,6 +30,8 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
|
30
30
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
31
31
|
import { runGestureTransaction } from "./gestureTransaction";
|
|
32
32
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
33
|
+
import { assertGsapEditPersisted } from "./gsapEditOutcome";
|
|
34
|
+
import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
|
|
33
35
|
|
|
34
36
|
// Distinct coalesceKey per group drag so consecutive group drags don't fold
|
|
35
37
|
// into one another's undo entry (module-local counter, not Date.now()).
|
|
@@ -42,7 +44,10 @@ export interface UseGsapAwareEditingParams {
|
|
|
42
44
|
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
43
45
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
44
46
|
bumpGsapCache: () => void;
|
|
45
|
-
makeFetchFallback: (
|
|
47
|
+
makeFetchFallback: (
|
|
48
|
+
selection: DomEditSelection,
|
|
49
|
+
options?: GsapAnimationFetchOptions,
|
|
50
|
+
) => () => Promise<GsapAnimation[]>;
|
|
46
51
|
trackGsapInteractionFailure: (
|
|
47
52
|
error: unknown,
|
|
48
53
|
selection: DomEditSelection,
|
|
@@ -112,7 +117,7 @@ export function useGsapAwareEditing({
|
|
|
112
117
|
) => {
|
|
113
118
|
if (gsapCommitMutation) {
|
|
114
119
|
try {
|
|
115
|
-
await tryGsapDragIntercept(
|
|
120
|
+
const outcome = await tryGsapDragIntercept(
|
|
116
121
|
selection,
|
|
117
122
|
next,
|
|
118
123
|
selectedGsapAnimations,
|
|
@@ -121,6 +126,7 @@ export function useGsapAwareEditing({
|
|
|
121
126
|
makeFetchFallback(selection),
|
|
122
127
|
modifiers,
|
|
123
128
|
);
|
|
129
|
+
assertGsapEditPersisted(outcome);
|
|
124
130
|
} catch (error) {
|
|
125
131
|
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
|
|
126
132
|
throw error;
|
|
@@ -155,16 +161,42 @@ export function useGsapAwareEditing({
|
|
|
155
161
|
coalesceKey,
|
|
156
162
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
157
163
|
});
|
|
164
|
+
const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
|
|
165
|
+
// Editability is user-atomic: prove every member can be written before
|
|
166
|
+
// the first source mutation. Network failures after this point retain the
|
|
167
|
+
// existing multi-request semantics, but a blocked member can never leave
|
|
168
|
+
// earlier siblings partially moved.
|
|
169
|
+
for (const { selection } of updates) {
|
|
170
|
+
try {
|
|
171
|
+
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
|
|
172
|
+
preflightAnimations.set(selection, animations);
|
|
173
|
+
const outcome = await tryGsapDragIntercept(
|
|
174
|
+
selection,
|
|
175
|
+
{ x: 0, y: 0 },
|
|
176
|
+
animations,
|
|
177
|
+
previewIframeRef.current,
|
|
178
|
+
coalescedCommit,
|
|
179
|
+
undefined,
|
|
180
|
+
{ preflightOnly: true },
|
|
181
|
+
);
|
|
182
|
+
assertGsapEditPersisted(outcome);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
158
188
|
for (const { selection, next } of updates) {
|
|
159
189
|
try {
|
|
160
|
-
await tryGsapDragIntercept(
|
|
190
|
+
const outcome = await tryGsapDragIntercept(
|
|
161
191
|
selection,
|
|
162
192
|
next,
|
|
163
|
-
[],
|
|
193
|
+
preflightAnimations.get(selection) ?? [],
|
|
164
194
|
previewIframeRef.current,
|
|
165
195
|
coalescedCommit,
|
|
166
196
|
makeFetchFallback(selection),
|
|
197
|
+
{ preflightPassed: true },
|
|
167
198
|
);
|
|
199
|
+
assertGsapEditPersisted(outcome);
|
|
168
200
|
} catch (error) {
|
|
169
201
|
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
|
|
170
202
|
throw error;
|
|
@@ -217,7 +249,7 @@ export function useGsapAwareEditing({
|
|
|
217
249
|
if (gsapCommitMutation) {
|
|
218
250
|
const commitMutation = commit(gsapCommitMutation);
|
|
219
251
|
try {
|
|
220
|
-
const
|
|
252
|
+
const outcome = await tryGsapResizeIntercept(
|
|
221
253
|
selection,
|
|
222
254
|
next,
|
|
223
255
|
selectedGsapAnimations,
|
|
@@ -225,26 +257,26 @@ export function useGsapAwareEditing({
|
|
|
225
257
|
commitMutation,
|
|
226
258
|
makeFetchFallback(selection),
|
|
227
259
|
);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
|
|
246
|
-
return;
|
|
260
|
+
assertGsapEditPersisted(outcome);
|
|
261
|
+
logResize("intercept-handled", {
|
|
262
|
+
scaleRoute,
|
|
263
|
+
willForwardOffset: !!(offset && !scaleRoute),
|
|
264
|
+
});
|
|
265
|
+
// Scale-route resize persists its residual position internally.
|
|
266
|
+
// Width/height persists the already-settled anchor through drag.
|
|
267
|
+
if (offset && !scaleRoute) {
|
|
268
|
+
const dragOutcome = await tryGsapDragIntercept(
|
|
269
|
+
selection,
|
|
270
|
+
offset,
|
|
271
|
+
selectedGsapAnimations,
|
|
272
|
+
previewIframeRef.current,
|
|
273
|
+
commitMutation,
|
|
274
|
+
makeFetchFallback(selection),
|
|
275
|
+
);
|
|
276
|
+
assertGsapEditPersisted(dragOutcome);
|
|
247
277
|
}
|
|
278
|
+
logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
|
|
279
|
+
return;
|
|
248
280
|
} catch (error) {
|
|
249
281
|
trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
|
|
250
282
|
throw error;
|
|
@@ -278,8 +310,9 @@ export function useGsapAwareEditing({
|
|
|
278
310
|
try {
|
|
279
311
|
// Single source of truth for rotation too: tryGsapRotationIntercept handles
|
|
280
312
|
// tweened elements (keyframes) and static ones (a tl.set), so there's no
|
|
281
|
-
// CSS-var fallback.
|
|
282
|
-
|
|
313
|
+
// CSS-var fallback. Selectorless/computed source rejects so the gesture
|
|
314
|
+
// transaction can restore its draft instead of reporting a false success.
|
|
315
|
+
const outcome = await tryGsapRotationIntercept(
|
|
283
316
|
selection,
|
|
284
317
|
next.angle,
|
|
285
318
|
selectedGsapAnimations,
|
|
@@ -287,6 +320,7 @@ export function useGsapAwareEditing({
|
|
|
287
320
|
gsapCommitMutation,
|
|
288
321
|
makeFetchFallback(selection),
|
|
289
322
|
);
|
|
323
|
+
assertGsapEditPersisted(outcome);
|
|
290
324
|
} catch (error) {
|
|
291
325
|
trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
|
|
292
326
|
throw error;
|
|
@@ -304,7 +338,10 @@ export function useGsapAwareEditing({
|
|
|
304
338
|
|
|
305
339
|
// ── Animated property commit ──
|
|
306
340
|
|
|
307
|
-
const {
|
|
341
|
+
const {
|
|
342
|
+
commitAnimatedProperty: commitAnimatedPropertyRaw,
|
|
343
|
+
commitAnimatedProperties: commitAnimatedPropertiesRaw,
|
|
344
|
+
} = useAnimatedPropertyCommit({
|
|
308
345
|
selectedGsapAnimations,
|
|
309
346
|
gsapCommitMutation,
|
|
310
347
|
addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
|
|
@@ -313,6 +350,30 @@ export function useGsapAwareEditing({
|
|
|
313
350
|
bumpGsapCache,
|
|
314
351
|
});
|
|
315
352
|
|
|
353
|
+
const commitAnimatedProperties = useCallback(
|
|
354
|
+
async (selection: DomEditSelection, properties: Record<string, number | string>) => {
|
|
355
|
+
try {
|
|
356
|
+
await commitAnimatedPropertiesRaw(selection, properties);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
[commitAnimatedPropertiesRaw, trackGsapInteractionFailure],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const commitAnimatedProperty = useCallback(
|
|
366
|
+
async (selection: DomEditSelection, property: string, value: number | string) => {
|
|
367
|
+
try {
|
|
368
|
+
await commitAnimatedPropertyRaw(selection, property, value);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
[commitAnimatedPropertyRaw, trackGsapInteractionFailure],
|
|
375
|
+
);
|
|
376
|
+
|
|
316
377
|
// ── Arc path wrappers ──
|
|
317
378
|
|
|
318
379
|
const handleSetArcPath = useCallback(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
|
+
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
|
|
12
|
+
vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
|
|
13
|
+
|
|
14
|
+
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
|
|
15
|
+
|
|
16
|
+
describe("useGsapInteractionFailureTelemetry", () => {
|
|
17
|
+
it("surfaces the blocked reason instead of a generic save failure", () => {
|
|
18
|
+
const showToast = vi.fn();
|
|
19
|
+
const selection = {
|
|
20
|
+
id: "clip",
|
|
21
|
+
selector: "#clip",
|
|
22
|
+
element: document.createElement("div"),
|
|
23
|
+
} as unknown as DomEditSelection;
|
|
24
|
+
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
|
|
25
|
+
function Harness() {
|
|
26
|
+
report = useGsapInteractionFailureTelemetry("index.html", showToast);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const root = mountReactHarness(<Harness />);
|
|
30
|
+
|
|
31
|
+
act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
|
|
32
|
+
|
|
33
|
+
expect(showToast).toHaveBeenCalledWith(
|
|
34
|
+
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
|
|
35
|
+
"error",
|
|
36
|
+
);
|
|
37
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
|
|
38
|
+
expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
|
|
39
|
+
);
|
|
40
|
+
act(() => root.unmount());
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
3
|
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
4
|
+
import { isGsapEditBlockedError } from "./gsapEditOutcome";
|
|
4
5
|
|
|
5
6
|
export function useGsapInteractionFailureTelemetry(
|
|
6
7
|
activeCompPath: string | null,
|
|
@@ -18,7 +19,10 @@ export function useGsapInteractionFailureTelemetry(
|
|
|
18
19
|
targetSelector: selection.selector,
|
|
19
20
|
targetSourceFile: selection.sourceFile,
|
|
20
21
|
});
|
|
21
|
-
showToast(
|
|
22
|
+
showToast(
|
|
23
|
+
isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",
|
|
24
|
+
"error",
|
|
25
|
+
);
|
|
22
26
|
},
|
|
23
27
|
[activeCompPath, showToast],
|
|
24
28
|
);
|