@hyperframes/studio 0.7.92 → 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-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.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-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- 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/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- 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 +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- 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/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/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/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- 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/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- 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/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/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -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/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
|
|
|
20
20
|
useEditorSave: () => ({
|
|
21
21
|
saveRafRef: { current: null },
|
|
22
22
|
handleContentChange: vi.fn(),
|
|
23
|
+
getPendingCandidate: vi.fn(() => null),
|
|
24
|
+
flushPendingSave: vi.fn(async () => ({ status: "clean" as const })),
|
|
25
|
+
discardPendingSave: vi.fn(),
|
|
23
26
|
}),
|
|
24
27
|
}));
|
|
25
28
|
|
|
26
29
|
import { useFileManager } from "./useFileManager";
|
|
30
|
+
import { resetStudioWriteTokens, studioFileContentVersion } from "../utils/studioFileVersion";
|
|
31
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
27
32
|
|
|
28
33
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
29
34
|
|
|
35
|
+
function useTestFileManager(projectId: string) {
|
|
36
|
+
return useFileManager({
|
|
37
|
+
projectId,
|
|
38
|
+
showToast: vi.fn(),
|
|
39
|
+
recordEdit: vi.fn(async () => {}),
|
|
40
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
41
|
+
setRefreshKey: vi.fn(),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function mountTestFileManager(projectId = "project-a") {
|
|
46
|
+
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
|
47
|
+
function Probe() {
|
|
48
|
+
captured.manager = useTestFileManager(projectId);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const root = createRoot(document.createElement("div"));
|
|
52
|
+
await act(async () => root.render(<Probe />));
|
|
53
|
+
const manager = captured.manager;
|
|
54
|
+
if (!manager) throw new Error("file manager did not render");
|
|
55
|
+
return { manager, root };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function mountOverwriteRequest(response: Response) {
|
|
59
|
+
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
|
60
|
+
if (init?.method !== "PUT") {
|
|
61
|
+
throw new Error("overwrite unexpectedly performed a preflight read");
|
|
62
|
+
}
|
|
63
|
+
return Promise.resolve(response);
|
|
64
|
+
});
|
|
65
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
66
|
+
return { ...(await mountTestFileManager()), fetchMock };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createOverwriteConflict(currentVersion: string | null, currentContent: string | null) {
|
|
70
|
+
return new StudioFileConflictError({
|
|
71
|
+
filePath: "index.html",
|
|
72
|
+
currentVersion,
|
|
73
|
+
currentContent,
|
|
74
|
+
attemptedContent: "STUDIO",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
describe("useFileManager project ownership", () => {
|
|
31
79
|
afterEach(() => {
|
|
80
|
+
resetStudioWriteTokens();
|
|
81
|
+
vi.useRealTimers();
|
|
32
82
|
vi.unstubAllGlobals();
|
|
33
83
|
});
|
|
34
84
|
|
|
@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
|
|
|
54
104
|
|
|
55
105
|
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
|
56
106
|
function Probe({ projectId }: { projectId: string }) {
|
|
57
|
-
captured.manager =
|
|
58
|
-
projectId,
|
|
59
|
-
showToast: vi.fn(),
|
|
60
|
-
recordEdit: vi.fn(async () => {}),
|
|
61
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
62
|
-
setRefreshKey: vi.fn(),
|
|
63
|
-
});
|
|
107
|
+
captured.manager = useTestFileManager(projectId);
|
|
64
108
|
return null;
|
|
65
109
|
}
|
|
66
110
|
|
|
@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
|
|
|
106
150
|
|
|
107
151
|
await act(async () => root.unmount());
|
|
108
152
|
});
|
|
153
|
+
|
|
154
|
+
it("uses a fresh write token when a lost response retries a committed save", async () => {
|
|
155
|
+
vi.useFakeTimers();
|
|
156
|
+
let putAttempt = 0;
|
|
157
|
+
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
|
158
|
+
if (!init?.method) {
|
|
159
|
+
return Promise.resolve({
|
|
160
|
+
ok: true,
|
|
161
|
+
json: async () => ({ content: "BEFORE", version: "v1" }),
|
|
162
|
+
} as Response);
|
|
163
|
+
}
|
|
164
|
+
putAttempt += 1;
|
|
165
|
+
if (putAttempt === 1) return Promise.reject(new TypeError("response lost"));
|
|
166
|
+
return Promise.resolve({ ok: true, json: async () => ({ version: "v2" }) } as Response);
|
|
167
|
+
});
|
|
168
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
169
|
+
|
|
170
|
+
const { manager, root } = await mountTestFileManager();
|
|
171
|
+
await manager.readProjectFile("index.html");
|
|
172
|
+
|
|
173
|
+
const write = manager.writeProjectFile("index.html", "AFTER");
|
|
174
|
+
await vi.runAllTimersAsync();
|
|
175
|
+
await write;
|
|
176
|
+
|
|
177
|
+
const writeTokens = fetchMock.mock.calls
|
|
178
|
+
.filter(([, init]) => init?.method === "PUT")
|
|
179
|
+
.map(([, init]) => new Headers(init?.headers).get("X-Hyperframes-Write-Token"));
|
|
180
|
+
expect(writeTokens).toHaveLength(2);
|
|
181
|
+
expect(writeTokens[0]).toBeTruthy();
|
|
182
|
+
expect(writeTokens[1]).not.toBe(writeTokens[0]);
|
|
183
|
+
|
|
184
|
+
await act(async () => root.unmount());
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("overwrites the exact external content version with an If-Match precondition", async () => {
|
|
188
|
+
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
|
189
|
+
ok: true,
|
|
190
|
+
json: async () => ({ version: "v3" }),
|
|
191
|
+
} as Response);
|
|
192
|
+
const conflict = createOverwriteConflict("v2", "EXTERNAL");
|
|
193
|
+
|
|
194
|
+
await manager.overwriteExternalConflict(conflict);
|
|
195
|
+
|
|
196
|
+
const [, init] = fetchMock.mock.calls[0] ?? [];
|
|
197
|
+
const headers = new Headers(init?.headers);
|
|
198
|
+
expect(init).toMatchObject({ method: "PUT", body: "STUDIO" });
|
|
199
|
+
expect(headers.get("If-Match")).toBe(await studioFileContentVersion("EXTERNAL"));
|
|
200
|
+
expect(headers.get("If-None-Match")).toBeNull();
|
|
201
|
+
await act(async () => root.unmount());
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("preserves a newer third-party edit when a content-less conflict version is stale", async () => {
|
|
205
|
+
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
|
206
|
+
ok: false,
|
|
207
|
+
status: 409,
|
|
208
|
+
json: async () => ({ currentVersion: "v3", currentContent: "THIRD PARTY" }),
|
|
209
|
+
} as Response);
|
|
210
|
+
const conflict = createOverwriteConflict("v2", null);
|
|
211
|
+
|
|
212
|
+
await expect(manager.overwriteExternalConflict(conflict)).rejects.toMatchObject({
|
|
213
|
+
name: "StudioFileConflictError",
|
|
214
|
+
currentVersion: "v3",
|
|
215
|
+
currentContent: "THIRD PARTY",
|
|
216
|
+
attemptedContent: "STUDIO",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const [, init] = fetchMock.mock.calls[0] ?? [];
|
|
220
|
+
const headers = new Headers(init?.headers);
|
|
221
|
+
expect(headers.get("If-Match")).toBe("v2");
|
|
222
|
+
expect(headers.get("If-None-Match")).toBeNull();
|
|
223
|
+
await act(async () => root.unmount());
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("uses create-only semantics when the conflicted file was deleted", async () => {
|
|
227
|
+
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
|
228
|
+
ok: true,
|
|
229
|
+
json: async () => ({ version: "v1" }),
|
|
230
|
+
} as Response);
|
|
231
|
+
const conflict = createOverwriteConflict(null, null);
|
|
232
|
+
|
|
233
|
+
await manager.overwriteExternalConflict(conflict);
|
|
234
|
+
|
|
235
|
+
const [, init] = fetchMock.mock.calls[0] ?? [];
|
|
236
|
+
const headers = new Headers(init?.headers);
|
|
237
|
+
expect(headers.get("If-Match")).toBeNull();
|
|
238
|
+
expect(headers.get("If-None-Match")).toBe("*");
|
|
239
|
+
await act(async () => root.unmount());
|
|
240
|
+
});
|
|
109
241
|
});
|
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
StudioFileConflictError,
|
|
11
11
|
StudioSaveNetworkError,
|
|
12
12
|
} from "../utils/studioSaveDiagnostics";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createStudioWriteToken,
|
|
15
|
+
markStudioWriteToken,
|
|
16
|
+
studioExpectedFileVersion,
|
|
17
|
+
} from "../utils/studioFileVersion";
|
|
14
18
|
import { useFileTree } from "./useFileTree";
|
|
15
19
|
import { useEditorSave } from "./useEditorSave";
|
|
16
20
|
|
|
@@ -117,8 +121,11 @@ export function useFileManager({
|
|
|
117
121
|
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
|
-
const writeToken = createStudioWriteToken();
|
|
121
124
|
await retryStudioSave(async () => {
|
|
125
|
+
// Each request gets its own receipt identity. If a committed request loses its response,
|
|
126
|
+
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
|
127
|
+
const writeToken = createStudioWriteToken();
|
|
128
|
+
markStudioWriteToken(writeToken);
|
|
122
129
|
let response: Response;
|
|
123
130
|
try {
|
|
124
131
|
response = await fetch(
|
|
@@ -191,7 +198,7 @@ export function useFileManager({
|
|
|
191
198
|
|
|
192
199
|
// ── Editor save (debounced content change) ──
|
|
193
200
|
|
|
194
|
-
const
|
|
201
|
+
const editorSave = useEditorSave({
|
|
195
202
|
editingPathRef,
|
|
196
203
|
projectIdRef,
|
|
197
204
|
readProjectFile,
|
|
@@ -201,6 +208,24 @@ export function useFileManager({
|
|
|
201
208
|
setRefreshKey,
|
|
202
209
|
showToast,
|
|
203
210
|
});
|
|
211
|
+
const { saveRafRef, handleContentChange } = editorSave;
|
|
212
|
+
|
|
213
|
+
const overwriteExternalConflict = useCallback(
|
|
214
|
+
async (conflict: StudioFileConflictError) => {
|
|
215
|
+
if (conflict.currentContent != null) {
|
|
216
|
+
await writeProjectFile(
|
|
217
|
+
conflict.filePath,
|
|
218
|
+
conflict.attemptedContent,
|
|
219
|
+
conflict.currentContent,
|
|
220
|
+
);
|
|
221
|
+
} else {
|
|
222
|
+
fileVersions.set(conflict.filePath, conflict.currentVersion);
|
|
223
|
+
await writeProjectFile(conflict.filePath, conflict.attemptedContent);
|
|
224
|
+
}
|
|
225
|
+
updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
|
|
226
|
+
},
|
|
227
|
+
[fileVersions, updateEditingFileContent, writeProjectFile],
|
|
228
|
+
);
|
|
204
229
|
|
|
205
230
|
// ── File select ──
|
|
206
231
|
|
|
@@ -491,11 +516,15 @@ export function useFileManager({
|
|
|
491
516
|
editingPathRef,
|
|
492
517
|
projectIdRef,
|
|
493
518
|
saveRafRef,
|
|
519
|
+
flushPendingSourceSave: editorSave.flushPendingSave,
|
|
520
|
+
discardPendingSourceSave: editorSave.discardPendingSave,
|
|
521
|
+
getPendingSourceCandidate: editorSave.getPendingCandidate,
|
|
494
522
|
importedFontAssetsRef,
|
|
495
523
|
|
|
496
524
|
// Core I/O
|
|
497
525
|
readProjectFile,
|
|
498
526
|
writeProjectFile,
|
|
527
|
+
overwriteExternalConflict,
|
|
499
528
|
readOptionalProjectFile,
|
|
500
529
|
observeProjectFileVersion,
|
|
501
530
|
updateEditingFileContent,
|
|
@@ -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 }));
|