@hyperframes/studio 0.8.114 → 0.8.115
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-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
- package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
- package/dist/chunk-TXZMMOMA.js.map +1 -0
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
- package/dist/index.d.ts +43 -19
- package/dist/index.html +2 -2
- package/dist/index.js +2939 -2596
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -2
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.ts +4 -3
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +39 -15
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.ts +2 -20
- package/src/hooks/gsapDragPositionCommit.ts +18 -3
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapResizeIntercept.test.ts +33 -0
- package/src/hooks/gsapResizeIntercept.ts +1 -0
- package/src/hooks/gsapScriptCommitTypes.ts +5 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.ts +6 -5
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
- package/src/hooks/useDomEditTextCommits.ts +5 -1
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +7 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEnableKeyframes.test.ts +2 -2
- package/src/hooks/useEnableKeyframes.ts +5 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
- package/src/hooks/useGsapAwareEditing.ts +96 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +21 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +49 -4
- package/src/webmcp/StudioAgentTools.tsx +2 -2
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
|
@@ -314,7 +314,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
|
|
|
314
314
|
],
|
|
315
315
|
ease: "none",
|
|
316
316
|
},
|
|
317
|
-
{ label: "Add keyframe", softReload: true },
|
|
317
|
+
{ label: "Add keyframe", softReload: true, keyframeAction: "add" },
|
|
318
318
|
);
|
|
319
319
|
});
|
|
320
320
|
|
|
@@ -336,7 +336,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
|
|
|
336
336
|
duration: 10,
|
|
337
337
|
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
|
|
338
338
|
}),
|
|
339
|
-
{ label: "Add keyframe", softReload: true },
|
|
339
|
+
{ label: "Add keyframe", softReload: true, keyframeAction: "add" },
|
|
340
340
|
);
|
|
341
341
|
});
|
|
342
342
|
});
|
|
@@ -159,7 +159,7 @@ async function replaceSetWithSingleKeyframe(
|
|
|
159
159
|
keyframes: [{ percentage: 0, properties: position }],
|
|
160
160
|
ease: setAnim.ease,
|
|
161
161
|
},
|
|
162
|
-
{ label: "Enable keyframes", softReload: true },
|
|
162
|
+
{ label: "Enable keyframes", keyframeAction: "add", softReload: true },
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
@@ -270,6 +270,7 @@ async function extendKeyframedTweenToPlayhead(
|
|
|
270
270
|
},
|
|
271
271
|
{
|
|
272
272
|
label: "Add keyframe",
|
|
273
|
+
keyframeAction: "add",
|
|
273
274
|
softReload: true,
|
|
274
275
|
...commitOverrides,
|
|
275
276
|
},
|
|
@@ -377,7 +378,7 @@ export async function promoteSetToKeyframes(
|
|
|
377
378
|
],
|
|
378
379
|
ease: setAnim.ease,
|
|
379
380
|
},
|
|
380
|
-
{ label: "Add keyframe", softReload: true },
|
|
381
|
+
{ label: "Add keyframe", keyframeAction: "add", softReload: true },
|
|
381
382
|
);
|
|
382
383
|
}
|
|
383
384
|
|
|
@@ -454,7 +455,7 @@ export async function applyArcKeyframeAtPlayhead(
|
|
|
454
455
|
}),
|
|
455
456
|
ease: "none",
|
|
456
457
|
},
|
|
457
|
-
{ label: "Add keyframe", softReload: true },
|
|
458
|
+
{ label: "Add keyframe", keyframeAction: "add", softReload: true },
|
|
458
459
|
);
|
|
459
460
|
}
|
|
460
461
|
|
|
@@ -503,6 +504,7 @@ export function useEnableKeyframes(
|
|
|
503
504
|
enableKeyframesTransactionCounter += 1;
|
|
504
505
|
const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
|
|
505
506
|
const convertCommitOverrides: Partial<CommitMutationOptions> = {
|
|
507
|
+
keyframeTelemetry: false,
|
|
506
508
|
skipReload: true,
|
|
507
509
|
coalesceKey,
|
|
508
510
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
@@ -143,6 +143,85 @@ describe("external file change coordinator", () => {
|
|
|
143
143
|
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it("keeps a waiting head edit's thumbnail refresh when a later write replaces it", async () => {
|
|
147
|
+
let release = () => {};
|
|
148
|
+
const inFlight = new Promise<void>((resolve) => (release = resolve));
|
|
149
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
150
|
+
let drains = 0;
|
|
151
|
+
await mountCoordinator({
|
|
152
|
+
onAcceptedPersistedFileChange,
|
|
153
|
+
drainPendingChanges: async () => {
|
|
154
|
+
if (drains++ === 0) await inFlight;
|
|
155
|
+
return { status: "clean" as const };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
await act(async () => handler?.({ path: "other.html", content: "a", version: "v1" }));
|
|
159
|
+
await act(async () => handler?.({ path: "index.html", content: "b", version: "v2" }));
|
|
160
|
+
await act(async () =>
|
|
161
|
+
handler?.({
|
|
162
|
+
path: "index.html",
|
|
163
|
+
content: "c",
|
|
164
|
+
version: "v3",
|
|
165
|
+
affectedCompositions: ["index.html"],
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
await act(async () => release());
|
|
169
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("keeps a head edit's thumbnail refresh through a conflict that a later write replaces", async () => {
|
|
173
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
174
|
+
const conflict = new StudioFileConflictError({
|
|
175
|
+
filePath: "index.html",
|
|
176
|
+
currentVersion: "v1",
|
|
177
|
+
currentContent: "theirs",
|
|
178
|
+
attemptedContent: "mine",
|
|
179
|
+
});
|
|
180
|
+
const { captured } = await mountCoordinator({
|
|
181
|
+
onAcceptedPersistedFileChange,
|
|
182
|
+
drainPendingChanges: async () => ({ status: "conflict" as const, error: conflict }),
|
|
183
|
+
});
|
|
184
|
+
await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
|
|
185
|
+
await act(async () =>
|
|
186
|
+
handler?.({
|
|
187
|
+
path: "index.html",
|
|
188
|
+
content: "body",
|
|
189
|
+
version: "v2",
|
|
190
|
+
affectedCompositions: ["index.html"],
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
await act(async () => captured.handle?.useExternalFile());
|
|
194
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("keeps a held head edit's thumbnail refresh when a later write saves cleanly", async () => {
|
|
198
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
199
|
+
const conflict = new StudioFileConflictError({
|
|
200
|
+
filePath: "index.html",
|
|
201
|
+
currentVersion: "v1",
|
|
202
|
+
currentContent: "theirs",
|
|
203
|
+
attemptedContent: "mine",
|
|
204
|
+
});
|
|
205
|
+
let drains = 0;
|
|
206
|
+
await mountCoordinator({
|
|
207
|
+
onAcceptedPersistedFileChange,
|
|
208
|
+
drainPendingChanges: async () =>
|
|
209
|
+
drains++ === 0
|
|
210
|
+
? { status: "conflict" as const, error: conflict }
|
|
211
|
+
: { status: "clean" as const },
|
|
212
|
+
});
|
|
213
|
+
await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
|
|
214
|
+
await act(async () =>
|
|
215
|
+
handler?.({
|
|
216
|
+
path: "index.html",
|
|
217
|
+
content: "body",
|
|
218
|
+
version: "v2",
|
|
219
|
+
affectedCompositions: ["index.html"],
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
|
|
223
|
+
});
|
|
224
|
+
|
|
146
225
|
it("does not refresh the tree for a suppressed self-write echo", async () => {
|
|
147
226
|
const refreshFileTree = vi.fn();
|
|
148
227
|
await mountCoordinator({ refreshFileTree });
|
|
@@ -333,6 +412,29 @@ describe("external file change coordinator", () => {
|
|
|
333
412
|
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
334
413
|
});
|
|
335
414
|
|
|
415
|
+
it("keeps the failed file's payload when a snapshot delete fails for another file", async () => {
|
|
416
|
+
const { captured } = await mountCoordinator({
|
|
417
|
+
drainPendingChanges: vi
|
|
418
|
+
.fn()
|
|
419
|
+
.mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
|
|
420
|
+
.mockResolvedValueOnce({ status: "clean" as const }),
|
|
421
|
+
getPendingCandidate: () => ({ path: "scene.html", content: "studio" }),
|
|
422
|
+
deleteConflictSnapshot: vi.fn(async () => {
|
|
423
|
+
throw new Error("delete failed");
|
|
424
|
+
}),
|
|
425
|
+
});
|
|
426
|
+
await act(async () =>
|
|
427
|
+
handler?.({ path: "scene.html", content: "scene-external", version: "s1" }),
|
|
428
|
+
);
|
|
429
|
+
await act(async () =>
|
|
430
|
+
handler?.({ path: "index.html", content: "index-external", version: "i1" }),
|
|
431
|
+
);
|
|
432
|
+
expect(captured.handle?.blocked).toMatchObject({
|
|
433
|
+
status: "failed",
|
|
434
|
+
payload: { path: "scene.html", content: "scene-external", version: "s1" },
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
336
438
|
it("restores and overwrites from a durable failed draft", async () => {
|
|
337
439
|
const overwriteConflict = vi.fn(async () => undefined);
|
|
338
440
|
const onAcceptedPersistedFileChange = vi.fn();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
2
2
|
import {
|
|
3
|
+
mergeFileChangeAffectedCompositions,
|
|
3
4
|
readFileChangeAffectedCompositions,
|
|
4
5
|
readFileChangeAffectsPreview,
|
|
5
6
|
readFileChangeField,
|
|
@@ -163,7 +164,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
163
164
|
onAcceptedPersistedFileChange,
|
|
164
165
|
refreshFileTree,
|
|
165
166
|
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
|
|
166
|
-
const [blocked,
|
|
167
|
+
const [blocked, setBlockedState] = useState<ExternalFileChangeBlockedState | null>(null);
|
|
167
168
|
const generationRef = useRef(0);
|
|
168
169
|
const mountedRef = useRef(true);
|
|
169
170
|
const lastEventIdentityRef = useRef<string | null>(null);
|
|
@@ -172,6 +173,14 @@ export function useExternalFileChangeCoordinator({
|
|
|
172
173
|
const drainingRef = useRef(false);
|
|
173
174
|
const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
|
|
174
175
|
blockedRef.current = blocked;
|
|
176
|
+
// A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed.
|
|
177
|
+
const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => {
|
|
178
|
+
setBlockedState((held) =>
|
|
179
|
+
next && held
|
|
180
|
+
? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) }
|
|
181
|
+
: next,
|
|
182
|
+
);
|
|
183
|
+
}, []);
|
|
175
184
|
|
|
176
185
|
useEffect(() => {
|
|
177
186
|
mountedRef.current = true;
|
|
@@ -185,7 +194,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
185
194
|
generationRef.current += 1;
|
|
186
195
|
setBlocked(null);
|
|
187
196
|
lastEventIdentityRef.current = null;
|
|
188
|
-
}, [projectId, activeCompPath]);
|
|
197
|
+
}, [projectId, activeCompPath, setBlocked]);
|
|
189
198
|
|
|
190
199
|
useEffect(() => {
|
|
191
200
|
if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
|
|
@@ -229,7 +238,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
229
238
|
return () => {
|
|
230
239
|
cancelled = true;
|
|
231
240
|
};
|
|
232
|
-
}, [loadConflictSnapshot, projectId, recoveryFilePath]);
|
|
241
|
+
}, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]);
|
|
233
242
|
|
|
234
243
|
const reloadAcceptedGeneration = useCallback(
|
|
235
244
|
(path: string, affectsPreview = true) => {
|
|
@@ -275,14 +284,22 @@ export function useExternalFileChangeCoordinator({
|
|
|
275
284
|
await deleteConflictSnapshot(projectId!, path);
|
|
276
285
|
} catch (error) {
|
|
277
286
|
if (mountedRef.current && generation === generationRef.current) {
|
|
278
|
-
setBlocked({
|
|
287
|
+
setBlocked({
|
|
288
|
+
...previousBlocked,
|
|
289
|
+
generation,
|
|
290
|
+
error,
|
|
291
|
+
payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload),
|
|
292
|
+
});
|
|
279
293
|
}
|
|
280
294
|
return;
|
|
281
295
|
}
|
|
282
296
|
}
|
|
283
297
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
284
298
|
setBlocked(null);
|
|
285
|
-
|
|
299
|
+
const owed = previousBlocked
|
|
300
|
+
? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload)
|
|
301
|
+
: payload;
|
|
302
|
+
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
|
|
286
303
|
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
|
|
287
304
|
return;
|
|
288
305
|
}
|
|
@@ -344,6 +361,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
344
361
|
},
|
|
345
362
|
[
|
|
346
363
|
drainPendingChanges,
|
|
364
|
+
setBlocked,
|
|
347
365
|
projectId,
|
|
348
366
|
deleteConflictSnapshot,
|
|
349
367
|
getPendingCandidate,
|
|
@@ -419,7 +437,12 @@ export function useExternalFileChangeCoordinator({
|
|
|
419
437
|
waiting != null &&
|
|
420
438
|
readFileChangeAffectsPreview(waiting) &&
|
|
421
439
|
!readFileChangeAffectsPreview(payload);
|
|
422
|
-
if (!waitingChangeOutranksThis)
|
|
440
|
+
if (!waitingChangeOutranksThis) {
|
|
441
|
+
pendingPayloadRef.current = {
|
|
442
|
+
payload:
|
|
443
|
+
waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
423
446
|
void startDrainLoop();
|
|
424
447
|
},
|
|
425
448
|
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
|
|
@@ -469,6 +492,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
469
492
|
},
|
|
470
493
|
[
|
|
471
494
|
deleteConflictSnapshot,
|
|
495
|
+
setBlocked,
|
|
472
496
|
discardPendingChanges,
|
|
473
497
|
onUseExternalFile,
|
|
474
498
|
onAcceptedPersistedFileChange,
|
|
@@ -527,6 +551,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
527
551
|
reloadAcceptedGeneration(conflict.filePath);
|
|
528
552
|
}, [
|
|
529
553
|
deleteConflictSnapshot,
|
|
554
|
+
setBlocked,
|
|
530
555
|
discardPendingChanges,
|
|
531
556
|
overwriteConflict,
|
|
532
557
|
projectId,
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { act } from "react";
|
|
2
|
+
import { act, useLayoutEffect } from "react";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
|
+
import { trackKeyframeCommit } from "../utils/keyframeUsage";
|
|
4
7
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
8
|
import { usePlayerStore } from "../player";
|
|
6
9
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
10
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
11
|
+
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
8
12
|
import { useGestureCommit } from "./useGestureCommit";
|
|
9
13
|
|
|
14
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
15
|
+
|
|
10
16
|
const gestureRecording = vi.hoisted(() => ({
|
|
11
17
|
startRecording: vi.fn(),
|
|
12
18
|
stopRecording: vi.fn(() => [
|
|
@@ -84,44 +90,88 @@ function makeSelection(element: HTMLElement): DomEditSelection {
|
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
function mountRecording(
|
|
94
|
+
writer: (
|
|
95
|
+
mutation: Record<string, unknown>,
|
|
96
|
+
options: CommitMutationOptions,
|
|
97
|
+
selection?: DomEditSelection,
|
|
98
|
+
) => Promise<void>,
|
|
99
|
+
animations: GsapAnimation[] = [],
|
|
100
|
+
readOnlyPreview = false,
|
|
101
|
+
) {
|
|
102
|
+
const iframe = document.createElement("iframe");
|
|
103
|
+
document.body.append(iframe);
|
|
104
|
+
const element = document.createElement("div");
|
|
105
|
+
element.id = "card";
|
|
106
|
+
const session = {
|
|
107
|
+
current: {
|
|
108
|
+
domEditSelection: makeSelection(element),
|
|
109
|
+
selectedGsapAnimations: animations,
|
|
110
|
+
commitMutation: writer,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
let hook: ReturnType<typeof useGestureCommit> | null = null;
|
|
114
|
+
function Probe() {
|
|
115
|
+
const editing = useGsapAwareEditing({
|
|
116
|
+
domEditSelection: session.current.domEditSelection,
|
|
117
|
+
selectedGsapAnimations: session.current.selectedGsapAnimations,
|
|
118
|
+
gsapCommitMutation: (selection, mutation, options) => writer(mutation, options, selection),
|
|
119
|
+
previewIframeRef: { current: iframe },
|
|
120
|
+
showToast: vi.fn(),
|
|
121
|
+
bumpGsapCache: vi.fn(),
|
|
122
|
+
makeFetchFallback: () => vi.fn(),
|
|
123
|
+
trackGsapInteractionFailure: vi.fn(),
|
|
124
|
+
stageElementPositionOffset: vi.fn(),
|
|
125
|
+
handleDomBoxSizeCommit: vi.fn(),
|
|
126
|
+
handleDomRotationCommit: vi.fn(),
|
|
127
|
+
commitPositionPatchToHtml: vi.fn(),
|
|
128
|
+
addGsapAnimation: vi.fn(),
|
|
129
|
+
convertToKeyframes: vi.fn(),
|
|
130
|
+
setArcPath: vi.fn(),
|
|
131
|
+
updateArcSegment: vi.fn(),
|
|
132
|
+
});
|
|
133
|
+
const currentHook = useGestureCommit({
|
|
134
|
+
domEditSessionRef: session,
|
|
135
|
+
previewIframeRef: { current: iframe },
|
|
136
|
+
showToast: vi.fn(),
|
|
137
|
+
isGestureRecordingRef: { current: false },
|
|
138
|
+
readOnlyPreview,
|
|
139
|
+
});
|
|
140
|
+
useLayoutEffect(() => {
|
|
141
|
+
session.current.commitMutation = editing.commitMutation;
|
|
142
|
+
hook = currentHook;
|
|
143
|
+
});
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const root = mountReactHarness(<Probe />);
|
|
147
|
+
cleanup = () => act(() => root.unmount());
|
|
148
|
+
return Object.assign(
|
|
149
|
+
() => {
|
|
150
|
+
if (!hook) throw new Error("hook did not initialize");
|
|
151
|
+
return hook;
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
select: (selection: DomEditSelection, nextAnimations: GsapAnimation[]) => {
|
|
155
|
+
session.current.domEditSelection = selection;
|
|
156
|
+
session.current.selectedGsapAnimations = nextAnimations;
|
|
157
|
+
act(() => root.render(<Probe />));
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
87
163
|
describe("useGestureCommit", () => {
|
|
88
164
|
it("coalesces property-group commits and reloads only the terminal group", async () => {
|
|
89
|
-
const
|
|
90
|
-
document.body.append(iframe);
|
|
91
|
-
const element = document.createElement("div");
|
|
92
|
-
element.id = "card";
|
|
93
|
-
const commitMutation = vi.fn<
|
|
165
|
+
const writer = vi.fn<
|
|
94
166
|
(mutation: Record<string, unknown>, options: CommitMutationOptions) => Promise<void>
|
|
95
167
|
>(async () => {});
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
selectedGsapAnimations: [],
|
|
100
|
-
commitMutation,
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
|
|
104
|
-
function Probe() {
|
|
105
|
-
captured.hook = useGestureCommit({
|
|
106
|
-
domEditSessionRef: sessionRef,
|
|
107
|
-
previewIframeRef: { current: iframe },
|
|
108
|
-
showToast: vi.fn(),
|
|
109
|
-
isGestureRecordingRef: { current: false },
|
|
110
|
-
readOnlyPreview: false,
|
|
111
|
-
});
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
const root = mountReactHarness(<Probe />);
|
|
115
|
-
cleanup = () => act(() => root.unmount());
|
|
116
|
-
if (!captured.hook) throw new Error("hook did not initialize");
|
|
117
|
-
|
|
118
|
-
act(() => captured.hook?.handleToggleRecording());
|
|
119
|
-
act(() => captured.hook?.handleToggleRecording());
|
|
168
|
+
const hook = mountRecording(writer);
|
|
169
|
+
act(() => hook().handleToggleRecording());
|
|
170
|
+
act(() => hook().handleToggleRecording());
|
|
120
171
|
await act(async () => {
|
|
121
|
-
await vi.waitFor(() => expect(
|
|
172
|
+
await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(2));
|
|
122
173
|
});
|
|
123
|
-
|
|
124
|
-
const options = commitMutation.mock.calls.map((call) => call[1]);
|
|
174
|
+
const options = writer.mock.calls.map((call) => call[1]);
|
|
125
175
|
expect(new Set(options.map((entry) => entry.coalesceKey)).size).toBe(1);
|
|
126
176
|
expect(options[0]).toEqual(expect.objectContaining({ coalesceMs: Infinity, skipReload: true }));
|
|
127
177
|
expect(options[0]).not.toHaveProperty("softReload");
|
|
@@ -130,30 +180,150 @@ describe("useGestureCommit", () => {
|
|
|
130
180
|
});
|
|
131
181
|
|
|
132
182
|
it("does not start a recording while the preview is read-only", () => {
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
183
|
+
const writer = vi.fn(async () => {});
|
|
184
|
+
const hook = mountRecording(writer, [], true);
|
|
185
|
+
act(() => hook().handleToggleRecording());
|
|
186
|
+
expect(gestureRecording.startRecording).not.toHaveBeenCalled();
|
|
187
|
+
expect(writer).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it.each([
|
|
192
|
+
["new mixed-property recording", undefined, true, true, 1, "button"],
|
|
193
|
+
["keyboard recording", undefined, true, true, 1, "keyboard"],
|
|
194
|
+
["static hold replacement", "set", true, true, 1, "button"],
|
|
195
|
+
["overlapping tween replacement", "to", true, true, 1, "button"],
|
|
196
|
+
["unchanged recording", undefined, true, false, 0, "button"],
|
|
197
|
+
["refused recording", undefined, false, true, 0, "button"],
|
|
198
|
+
] as const)(
|
|
199
|
+
"counts writer receipts once for %s",
|
|
200
|
+
async (_name, method, ok, changed, count, inputMethod) => {
|
|
201
|
+
const animations = method
|
|
202
|
+
? [
|
|
203
|
+
{
|
|
204
|
+
id: "card-position",
|
|
205
|
+
targetSelector: "#card",
|
|
206
|
+
propertyGroup: "position",
|
|
207
|
+
method,
|
|
208
|
+
properties: { x: 0, y: 0 },
|
|
209
|
+
resolvedStart: 0,
|
|
210
|
+
position: 0,
|
|
211
|
+
duration: method === "set" ? 0 : 2,
|
|
212
|
+
keyframes: {
|
|
213
|
+
keyframes: [
|
|
214
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
215
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
} as unknown as GsapAnimation,
|
|
219
|
+
]
|
|
220
|
+
: [];
|
|
221
|
+
const writer = vi.fn(
|
|
222
|
+
async (mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
223
|
+
const result = { ok, changed };
|
|
224
|
+
trackKeyframeCommit([mutation], result, options);
|
|
225
|
+
options.onResult?.(result);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
const hook = mountRecording(writer, animations);
|
|
229
|
+
act(() => hook().handleToggleRecording(inputMethod));
|
|
230
|
+
await act(async () => {
|
|
231
|
+
hook().handleToggleRecording();
|
|
232
|
+
await vi.waitFor(() => expect(writer).toHaveBeenCalled());
|
|
233
|
+
});
|
|
234
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(count * 2);
|
|
235
|
+
if (count) {
|
|
236
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
|
|
237
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
|
|
238
|
+
feature: "gesture_recording",
|
|
239
|
+
surface: "preview",
|
|
240
|
+
method: inputMethod,
|
|
149
241
|
});
|
|
150
|
-
return null;
|
|
151
242
|
}
|
|
152
|
-
|
|
153
|
-
|
|
243
|
+
},
|
|
244
|
+
);
|
|
154
245
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
246
|
+
it("keeps a pending recording isolated until its writer settles", async () => {
|
|
247
|
+
let release!: () => void;
|
|
248
|
+
const pending = new Promise<void>((resolve) => {
|
|
249
|
+
release = resolve;
|
|
250
|
+
});
|
|
251
|
+
const writer = vi.fn(
|
|
252
|
+
async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
253
|
+
await pending;
|
|
254
|
+
options.onResult?.({ ok: true, changed: true });
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
const hook = mountRecording(writer);
|
|
258
|
+
act(() => hook().handleToggleRecording("keyboard"));
|
|
259
|
+
act(() => hook().handleToggleRecording());
|
|
260
|
+
await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(1));
|
|
261
|
+
act(() => hook().handleToggleRecording("button"));
|
|
262
|
+
expect(gestureRecording.startRecording).toHaveBeenCalledTimes(1);
|
|
263
|
+
await act(async () => {
|
|
264
|
+
release();
|
|
265
|
+
await vi.waitFor(() =>
|
|
266
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
|
|
267
|
+
feature: "gesture_recording",
|
|
268
|
+
surface: "preview",
|
|
269
|
+
method: "keyboard",
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
expect(gestureRecording.clearSamples).toHaveBeenCalledTimes(1);
|
|
274
|
+
act(() => hook().handleToggleRecording("button"));
|
|
275
|
+
expect(gestureRecording.startRecording).toHaveBeenCalledTimes(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("writes one recording to its original selection after selecting another source", async () => {
|
|
279
|
+
const original = {
|
|
280
|
+
id: "original-position",
|
|
281
|
+
targetSelector: "#card",
|
|
282
|
+
propertyGroup: "position",
|
|
283
|
+
method: "set",
|
|
284
|
+
properties: { x: 0, y: 0 },
|
|
285
|
+
resolvedStart: 0,
|
|
286
|
+
position: 0,
|
|
287
|
+
duration: 0,
|
|
288
|
+
} as GsapAnimation;
|
|
289
|
+
const writer = vi.fn(
|
|
290
|
+
async (
|
|
291
|
+
_mutation: Record<string, unknown>,
|
|
292
|
+
options: CommitMutationOptions,
|
|
293
|
+
_selection?: DomEditSelection,
|
|
294
|
+
) => {
|
|
295
|
+
options.onResult?.({ ok: true, changed: true });
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
const hook = mountRecording(writer, [original]);
|
|
299
|
+
act(() => hook().handleToggleRecording("keyboard"));
|
|
300
|
+
const other = document.createElement("div");
|
|
301
|
+
other.id = "other";
|
|
302
|
+
hook.select(
|
|
303
|
+
{ ...makeSelection(other), sourceFile: "other.html", compositionPath: "other.html" },
|
|
304
|
+
[],
|
|
305
|
+
);
|
|
306
|
+
await act(async () => {
|
|
307
|
+
hook().handleToggleRecording();
|
|
308
|
+
await vi.waitFor(() => expect(trackStudioEvent).toHaveBeenCalledTimes(2));
|
|
309
|
+
});
|
|
310
|
+
expect(writer).toHaveBeenCalledExactlyOnceWith(
|
|
311
|
+
expect.objectContaining({
|
|
312
|
+
type: "replace-with-keyframes",
|
|
313
|
+
animationId: "original-position",
|
|
314
|
+
targetSelector: "#card",
|
|
315
|
+
}),
|
|
316
|
+
expect.objectContaining({ keyframeAction: "add" }),
|
|
317
|
+
expect.objectContaining({
|
|
318
|
+
id: "card",
|
|
319
|
+
sourceFile: "index.html",
|
|
320
|
+
compositionPath: "index.html",
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
|
|
324
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
|
|
325
|
+
feature: "gesture_recording",
|
|
326
|
+
surface: "preview",
|
|
327
|
+
method: "keyboard",
|
|
158
328
|
});
|
|
159
329
|
});
|