@hyperframes/studio 0.8.114 → 0.8.116
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-mGYvPVpw.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
- package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
- package/dist/chunk-3F47EFCT.js.map +1 -0
- 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/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
- package/dist/index.d.ts +146 -40
- package/dist/index.html +2 -2
- package/dist/index.js +4021 -3591
- 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/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- 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/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -6
- 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/motionPathSelection.test.ts +6 -31
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +41 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- 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/useColorGradingController.ts +2 -1
- 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/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +5 -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/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +4 -125
- package/src/hooks/gsapDragPositionCommit.ts +36 -384
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +96 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -168
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +13 -0
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +8 -6
- package/src/hooks/newTweenTarget.test.ts +41 -76
- 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/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
- package/src/hooks/useDomEditTextCommits.ts +13 -3
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +8 -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/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +34 -3
- package/src/hooks/useEnableKeyframes.ts +7 -4
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.ts +3 -2
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +101 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
- package/src/hooks/useGsapScriptCommits.ts +15 -10
- 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/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +277 -1
- package/src/hooks/usePersistentEditHistory.ts +47 -8
- 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/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +22 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- 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/CompositionThumbnail.tsx +2 -1
- 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/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/clipPeakMap.ts +2 -1
- 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/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- 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/sceneSwap.ts +2 -1
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.ts +2 -3
- package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
- package/src/utils/gsapUndoRestore.ts +3 -6
- 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/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +1 -3
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +4 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +51 -5
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +4 -3
- 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-OL7GLSS4.js.map} +0 -0
|
@@ -88,6 +88,234 @@ it("an edit Studio saved is undone and redone by the project's history, with the
|
|
|
88
88
|
expect(file()).toBe("B");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
it("undoes the edit claimed since the key, not a later edit the server took in first", async () => {
|
|
92
|
+
const { dir, hook, file, save, readFile } = await studio();
|
|
93
|
+
const atKey = hook().claims();
|
|
94
|
+
save("B");
|
|
95
|
+
await act(() =>
|
|
96
|
+
hook().recordEdit({
|
|
97
|
+
label: "Moved Title",
|
|
98
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
writeFileSync(join(dir, "card.html"), "Y");
|
|
102
|
+
const later = hook().recordEdit({
|
|
103
|
+
label: "Added Card",
|
|
104
|
+
files: { "card.html": { before: "", after: "Y" } },
|
|
105
|
+
});
|
|
106
|
+
const afterLater = async <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
107
|
+
await later;
|
|
108
|
+
return task();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const undone = await act(() =>
|
|
112
|
+
hook().undo({ readFile, serialize: afterLater, claimedAfter: atKey }),
|
|
113
|
+
);
|
|
114
|
+
expect(undone).toMatchObject({ ok: true, label: "Undid: Moved Title" });
|
|
115
|
+
expect(file()).toBe("A");
|
|
116
|
+
expect(readFileSync(join(dir, "card.html"), "utf8")).toBe("Y");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("a second undo pressed while the same edit saves undoes the edit before it", async () => {
|
|
120
|
+
const { hook, file, save, readFile } = await studio();
|
|
121
|
+
save("B");
|
|
122
|
+
await act(() =>
|
|
123
|
+
hook().recordEdit({
|
|
124
|
+
label: "Moved Title",
|
|
125
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
129
|
+
const atKey = hook().claims();
|
|
130
|
+
save("C");
|
|
131
|
+
await act(() =>
|
|
132
|
+
hook().recordEdit({
|
|
133
|
+
label: "Moved Card",
|
|
134
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
139
|
+
const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
140
|
+
const run = queue.then(task);
|
|
141
|
+
queue = run.catch(() => undefined);
|
|
142
|
+
return run;
|
|
143
|
+
};
|
|
144
|
+
const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
|
|
145
|
+
|
|
146
|
+
const [first, second] = await act(() => Promise.all([press(), press()]));
|
|
147
|
+
|
|
148
|
+
expect([first, second]).toMatchObject([
|
|
149
|
+
{ ok: true, label: "Undid: Moved Card" },
|
|
150
|
+
{ ok: true, label: "Undid: Moved Title" },
|
|
151
|
+
]);
|
|
152
|
+
expect(file()).toBe("A");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps the claim when its undo fails, so the next press targets that edit again", async () => {
|
|
156
|
+
const { hook, save, readFile } = await studio();
|
|
157
|
+
save("B");
|
|
158
|
+
await act(() =>
|
|
159
|
+
hook().recordEdit({
|
|
160
|
+
label: "Moved Title",
|
|
161
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
165
|
+
const atKey = hook().claims();
|
|
166
|
+
save("C");
|
|
167
|
+
await act(() =>
|
|
168
|
+
hook().recordEdit({
|
|
169
|
+
label: "Moved Card",
|
|
170
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
const posted: string[] = [];
|
|
174
|
+
const fetch = globalThis.fetch;
|
|
175
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
|
|
176
|
+
if (init?.method === "POST") posted.push(url.slice(url.lastIndexOf("/") + 1));
|
|
177
|
+
return fetch(url, init);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
save("edited elsewhere");
|
|
181
|
+
const failed = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
182
|
+
save("C");
|
|
183
|
+
const retried = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
184
|
+
|
|
185
|
+
expect(failed.ok).toBe(false);
|
|
186
|
+
expect(retried).toMatchObject({ ok: true, label: "Undid: Moved Card" });
|
|
187
|
+
expect(posted.filter((p) => p === "undo" || p === "step")).toEqual(["undo", "undo"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("does not undo again an edit another press already undid", async () => {
|
|
191
|
+
const { hook, file, save, readFile } = await studio();
|
|
192
|
+
save("B");
|
|
193
|
+
await act(() =>
|
|
194
|
+
hook().recordEdit({
|
|
195
|
+
label: "Moved Title",
|
|
196
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
200
|
+
const atKey = hook().claims();
|
|
201
|
+
save("C");
|
|
202
|
+
await act(() =>
|
|
203
|
+
hook().recordEdit({
|
|
204
|
+
label: "Moved Card",
|
|
205
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
let calls = 0;
|
|
209
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
210
|
+
const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
211
|
+
calls += 1;
|
|
212
|
+
if (calls === 1) return Promise.reject(new Error("save failed"));
|
|
213
|
+
const run = queue.then(task);
|
|
214
|
+
queue = run.catch(() => undefined);
|
|
215
|
+
return run;
|
|
216
|
+
};
|
|
217
|
+
const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
|
|
218
|
+
|
|
219
|
+
const [first, second] = await act(() => Promise.allSettled([press(), press()]));
|
|
220
|
+
const third = await act(() => press());
|
|
221
|
+
|
|
222
|
+
expect(first.status).toBe("rejected");
|
|
223
|
+
expect(second).toMatchObject({
|
|
224
|
+
status: "fulfilled",
|
|
225
|
+
value: { ok: true, label: "Undid: Moved Card" },
|
|
226
|
+
});
|
|
227
|
+
expect(third).toMatchObject({ ok: true, label: "Undid: Moved Title" });
|
|
228
|
+
expect(file()).toBe("A");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not undo again an edit a later press undid first", async () => {
|
|
232
|
+
const { hook, file, save, readFile } = await studio();
|
|
233
|
+
save("B");
|
|
234
|
+
await act(() =>
|
|
235
|
+
hook().recordEdit({
|
|
236
|
+
label: "Moved Title",
|
|
237
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
241
|
+
const atKey = hook().claims();
|
|
242
|
+
save("C");
|
|
243
|
+
await act(() =>
|
|
244
|
+
hook().recordEdit({
|
|
245
|
+
label: "Moved Card",
|
|
246
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
let fail!: (error: Error) => void;
|
|
250
|
+
const held = <T>(_paths: readonly string[], _task: () => Promise<T>) =>
|
|
251
|
+
new Promise<T>((_resolve, reject) => {
|
|
252
|
+
fail = reject;
|
|
253
|
+
});
|
|
254
|
+
const failed = hook()
|
|
255
|
+
.undo({ readFile, claimedAfter: atKey, serialize: held })
|
|
256
|
+
.catch(() => undefined);
|
|
257
|
+
|
|
258
|
+
const second = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
259
|
+
fail(new Error("save failed"));
|
|
260
|
+
await failed;
|
|
261
|
+
const third = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
262
|
+
|
|
263
|
+
expect(second).toMatchObject({ ok: true, label: "Undid: Moved Card" });
|
|
264
|
+
expect(third).toMatchObject({ ok: true, label: "Undid: Moved Title" });
|
|
265
|
+
expect(file()).toBe("A");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("refuses a second press whose edit is undone once an edit was made after the press", async () => {
|
|
269
|
+
const { dir, hook, file, save, readFile } = await studio();
|
|
270
|
+
save("B");
|
|
271
|
+
await act(() =>
|
|
272
|
+
hook().recordEdit({
|
|
273
|
+
label: "Moved Title",
|
|
274
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
278
|
+
const atKey = hook().claims();
|
|
279
|
+
save("C");
|
|
280
|
+
await act(() =>
|
|
281
|
+
hook().recordEdit({
|
|
282
|
+
label: "Moved Card",
|
|
283
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
let open!: () => void;
|
|
287
|
+
const opened = new Promise<void>((resolve) => {
|
|
288
|
+
open = resolve;
|
|
289
|
+
});
|
|
290
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
291
|
+
let queued = 0;
|
|
292
|
+
const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
293
|
+
queued += 1;
|
|
294
|
+
const wait = queued === 2 ? opened : Promise.resolve();
|
|
295
|
+
const run = queue.then(() => wait).then(task);
|
|
296
|
+
queue = run.catch(() => undefined);
|
|
297
|
+
return run;
|
|
298
|
+
};
|
|
299
|
+
const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
|
|
300
|
+
const presses = Promise.all([press(), press()]);
|
|
301
|
+
await vi.waitFor(() => expect(file()).toBe("B"));
|
|
302
|
+
writeFileSync(join(dir, "card.html"), "Y");
|
|
303
|
+
await act(() =>
|
|
304
|
+
hook().recordEdit({
|
|
305
|
+
label: "Added Card",
|
|
306
|
+
files: { "card.html": { before: "", after: "Y" } },
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
open();
|
|
311
|
+
const [first, second] = await act(() => presses);
|
|
312
|
+
|
|
313
|
+
expect(first).toMatchObject({ ok: true, label: "Undid: Moved Card" });
|
|
314
|
+
expect(second).toMatchObject({ ok: false, reason: "content-mismatch" });
|
|
315
|
+
expect(readFileSync(join(dir, "card.html"), "utf8")).toBe("Y");
|
|
316
|
+
expect(file()).toBe("B");
|
|
317
|
+
});
|
|
318
|
+
|
|
91
319
|
it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
|
|
92
320
|
const { hook, file, save, readFile } = await studio();
|
|
93
321
|
const writes: Array<[before: string, after: string]> = [
|
|
@@ -115,6 +343,32 @@ it("a drag's edits under one key undo as one step, even before the drag goes idl
|
|
|
115
343
|
expect(file()).toBe("A");
|
|
116
344
|
});
|
|
117
345
|
|
|
346
|
+
it("an undo before the history view shows a drag's held claim still waits on the files it wrote", async () => {
|
|
347
|
+
const { hook, save, readFile } = await studio();
|
|
348
|
+
const server = globalThis.fetch;
|
|
349
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
350
|
+
init?.method === "POST"
|
|
351
|
+
? server(url, init)
|
|
352
|
+
: Promise.resolve(new Response(null, { status: 503 })),
|
|
353
|
+
);
|
|
354
|
+
save("B");
|
|
355
|
+
await act(() =>
|
|
356
|
+
hook().recordEdit({
|
|
357
|
+
label: "Dragged Title",
|
|
358
|
+
coalesceKey: "drag",
|
|
359
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
let waitedOn: readonly string[] = [];
|
|
363
|
+
const serialize = <T>(paths: readonly string[], task: () => Promise<T>) => {
|
|
364
|
+
waitedOn = paths;
|
|
365
|
+
return task();
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
await act(() => hook().undo({ readFile, serialize }));
|
|
369
|
+
expect(waitedOn).toEqual(["index.html"]);
|
|
370
|
+
});
|
|
371
|
+
|
|
118
372
|
it("an agent's edit made seconds before Studio's stays the agent's: Cmd+Z undoes only Studio's", async () => {
|
|
119
373
|
const { hook, file, save, readFile } = await studio();
|
|
120
374
|
save("B");
|
|
@@ -159,7 +413,11 @@ it("without a history on the server an edit still saves, and there is nothing to
|
|
|
159
413
|
});
|
|
160
414
|
|
|
161
415
|
/** Answers requests to `route` with `reply` instead of the engine, every other request as before; returns the undo. */
|
|
162
|
-
function answer(
|
|
416
|
+
function answer(
|
|
417
|
+
route: "/history" | "/history/step" | "/history/claim",
|
|
418
|
+
status: number,
|
|
419
|
+
reply: object | string,
|
|
420
|
+
) {
|
|
163
421
|
const real = globalThis.fetch;
|
|
164
422
|
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
165
423
|
url.endsWith(route)
|
|
@@ -230,6 +488,24 @@ it("a step whose reply cannot be read says so, instead of throwing", async () =>
|
|
|
230
488
|
});
|
|
231
489
|
});
|
|
232
490
|
|
|
491
|
+
it("an edit whose history reply cannot be read is still saved, and the reply is its own error", async () => {
|
|
492
|
+
const { hook, file, save } = await studio();
|
|
493
|
+
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
494
|
+
cleanup.push(() => logged.mockRestore());
|
|
495
|
+
answer("/history", 200, "<html>");
|
|
496
|
+
save("B");
|
|
497
|
+
await act(() =>
|
|
498
|
+
hook().recordEdit({
|
|
499
|
+
label: "Moved Title",
|
|
500
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
await vi.waitFor(() =>
|
|
504
|
+
expect(logged).toHaveBeenCalledWith("The history's reply was unreadable."),
|
|
505
|
+
);
|
|
506
|
+
expect(file()).toBe("B");
|
|
507
|
+
});
|
|
508
|
+
|
|
233
509
|
it("a step that cannot reach the server says so", async () => {
|
|
234
510
|
const { hook, readFile } = await studio();
|
|
235
511
|
const real = globalThis.fetch;
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server";
|
|
3
3
|
import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
4
4
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
5
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
5
6
|
|
|
6
7
|
interface RecordEditInput {
|
|
7
8
|
label: string;
|
|
@@ -13,6 +14,7 @@ interface RecordEditInput {
|
|
|
13
14
|
interface ApplyCallbacks {
|
|
14
15
|
readFile: (path: string) => Promise<string>;
|
|
15
16
|
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
17
|
+
claimedAfter?: number;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface UsePersistentEditHistoryOptions {
|
|
@@ -53,6 +55,8 @@ function createOwnHistory() {
|
|
|
53
55
|
const own = new Map<string, OwnFiles>();
|
|
54
56
|
let next: Record<"undo" | "redo", NextStep | null> | null = null;
|
|
55
57
|
let changes = 0;
|
|
58
|
+
let claimed = { count: 0, id: "" };
|
|
59
|
+
const undoneIds = new Set<string>();
|
|
56
60
|
const remember = (id: string, files: OwnFiles) => {
|
|
57
61
|
const known = own.get(id) ?? {};
|
|
58
62
|
for (const [path, { before, after }] of Object.entries(files)) {
|
|
@@ -73,7 +77,16 @@ function createOwnHistory() {
|
|
|
73
77
|
if (seen === changes) next = { undo: view.back, redo: view.forward };
|
|
74
78
|
},
|
|
75
79
|
changes: () => changes,
|
|
80
|
+
claimCount: () => claimed.count,
|
|
81
|
+
claimedAfter: (count: number) => (claimed.count > count && claimed.id ? claimed.id : null),
|
|
82
|
+
targetFor: (id: string | null, pressedAt: number) =>
|
|
83
|
+
!id || !undoneIds.has(id) ? id : claimed.count > pressedAt ? false : null,
|
|
84
|
+
noteClaim: (id: string) => {
|
|
85
|
+
claimed = { count: claimed.count + 1, id };
|
|
86
|
+
},
|
|
76
87
|
stepped: (entry: { id: string; undoes?: string }) => {
|
|
88
|
+
if (entry.undoes) undoneIds.add(entry.undoes);
|
|
89
|
+
if (undoneIds.size > OWN_ENTRIES_KEPT) undoneIds.delete(undoneIds.values().next().value!);
|
|
77
90
|
const undone = entry.undoes ? own.get(entry.undoes) : undefined;
|
|
78
91
|
if (!undone) return;
|
|
79
92
|
const swapped = Object.entries(undone).map(([path, f]) => [
|
|
@@ -102,6 +115,7 @@ function createOwnHistory() {
|
|
|
102
115
|
},
|
|
103
116
|
clear: () => {
|
|
104
117
|
own.clear();
|
|
118
|
+
undoneIds.clear();
|
|
105
119
|
next = null;
|
|
106
120
|
changes += 1;
|
|
107
121
|
},
|
|
@@ -113,12 +127,20 @@ function historyUrl(projectId: string, path = ""): string {
|
|
|
113
127
|
return `/api/projects/${encodeURIComponent(projectId)}/history${path}`;
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
async function cantUndo(paths: string[] | undefined): Promise<ApplyResult> {
|
|
131
|
+
return { ok: false, reason: "content-mismatch", paths: paths ?? [] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function unionPaths(...lists: Array<readonly string[] | undefined>): string[] {
|
|
135
|
+
return [...new Set(lists.flatMap((list) => list ?? []))];
|
|
136
|
+
}
|
|
137
|
+
|
|
116
138
|
async function post(
|
|
117
139
|
url: string,
|
|
118
140
|
body: object,
|
|
119
141
|
headers: Record<string, string> = {},
|
|
120
142
|
): Promise<{ ok: true; body: unknown } | { ok: false; status: number; error: string }> {
|
|
121
|
-
const response = await
|
|
143
|
+
const response = await studioApiFetch(url, {
|
|
122
144
|
method: "POST",
|
|
123
145
|
headers: { "Content-Type": "application/json", ...headers },
|
|
124
146
|
body: JSON.stringify(body),
|
|
@@ -194,9 +216,12 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
194
216
|
const refresh = useCallback(async () => {
|
|
195
217
|
if (!projectId) return;
|
|
196
218
|
const seen = own.changes();
|
|
197
|
-
const response = await
|
|
198
|
-
const next = response?.ok
|
|
219
|
+
const response = await studioApiFetch(historyUrl(projectId)).catch(() => null);
|
|
220
|
+
const next = response?.ok
|
|
221
|
+
? ((await response.json().catch(() => null)) as HistoryView | null)
|
|
222
|
+
: EMPTY;
|
|
199
223
|
if (projectIdRef.current !== projectId) return;
|
|
224
|
+
if (!next) return console.error("The history's reply was unreadable.");
|
|
200
225
|
setView(next);
|
|
201
226
|
own.offered(seen, next);
|
|
202
227
|
}, [projectId, own]);
|
|
@@ -221,7 +246,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
221
246
|
...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }),
|
|
222
247
|
});
|
|
223
248
|
const claimed = claimHeld(reply, label);
|
|
224
|
-
if (claimed)
|
|
249
|
+
if (claimed) {
|
|
250
|
+
own.remember(claimed, files);
|
|
251
|
+
own.noteClaim(claimed);
|
|
252
|
+
}
|
|
225
253
|
heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
|
|
226
254
|
await refresh();
|
|
227
255
|
},
|
|
@@ -231,14 +259,20 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
231
259
|
const step = useCallback(
|
|
232
260
|
async (direction: "undo" | "redo", callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
233
261
|
if (!projectId) return { ok: false, reason: "empty" };
|
|
262
|
+
const candidate =
|
|
263
|
+
direction === "undo" && callbacks.claimedAfter !== undefined
|
|
264
|
+
? own.claimedAfter(callbacks.claimedAfter)
|
|
265
|
+
: null;
|
|
234
266
|
const next = direction === "undo" ? view.back : view.forward;
|
|
235
|
-
const
|
|
267
|
+
const stepPaths = candidate ? Object.keys(own.afterOf(candidate)) : next?.paths;
|
|
268
|
+
const paths = unionPaths(stepPaths, heldClaimRef.current?.paths);
|
|
269
|
+
const pressedAt = own.claimCount();
|
|
236
270
|
own.overtake();
|
|
237
|
-
const
|
|
271
|
+
const attempt = async (target: string | null): Promise<ApplyResult> => {
|
|
238
272
|
const previous = await readAll(paths, callbacks.readFile);
|
|
239
273
|
const posted = await post(
|
|
240
|
-
historyUrl(projectId, "/step"),
|
|
241
|
-
{ direction: direction === "undo" ? "back" : "forward" },
|
|
274
|
+
historyUrl(projectId, target ? "/undo" : "/step"),
|
|
275
|
+
target ? { entryId: target } : { direction: direction === "undo" ? "back" : "forward" },
|
|
242
276
|
studioWriteHeaders(),
|
|
243
277
|
);
|
|
244
278
|
heldClaimRef.current = null;
|
|
@@ -266,6 +300,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
266
300
|
),
|
|
267
301
|
};
|
|
268
302
|
};
|
|
303
|
+
const run = () => {
|
|
304
|
+
const target = own.targetFor(candidate, pressedAt);
|
|
305
|
+
return target === false ? cantUndo(stepPaths) : attempt(target);
|
|
306
|
+
};
|
|
269
307
|
return callbacks.serialize ? callbacks.serialize(paths, run) : run();
|
|
270
308
|
},
|
|
271
309
|
[projectId, view, refresh, own],
|
|
@@ -301,6 +339,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
301
339
|
undo,
|
|
302
340
|
redo,
|
|
303
341
|
predict: own.predict,
|
|
342
|
+
claims: own.claimCount,
|
|
304
343
|
noteOutsideChange,
|
|
305
344
|
};
|
|
306
345
|
}
|
|
@@ -8,8 +8,8 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
8
8
|
|
|
9
9
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
10
|
|
|
11
|
-
function HookHost() {
|
|
12
|
-
usePopulateKeyframeCacheForFile("demo",
|
|
11
|
+
function HookHost({ sourceFile = "index.html", version = 1 }) {
|
|
12
|
+
usePopulateKeyframeCacheForFile("demo", sourceFile, version);
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -67,4 +67,78 @@ describe("usePopulateKeyframeCacheForFile", () => {
|
|
|
67
67
|
expect(urls.some((u) => u.endsWith("/index.html"))).toBe(true);
|
|
68
68
|
expect(urls.some((u) => u.includes("keyframe-lab.html"))).toBe(true);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it("keeps every covered file when the selection switches between them, and refetches all on new data", async () => {
|
|
72
|
+
// Every click on a clip from another file flips `sourceFile`; a flip must not
|
|
73
|
+
// re-read every composition file, or a multi-select pays files x clicks fetches.
|
|
74
|
+
const urls: string[] = [];
|
|
75
|
+
vi.stubGlobal(
|
|
76
|
+
"fetch",
|
|
77
|
+
vi.fn((input: RequestInfo | URL) => {
|
|
78
|
+
urls.push(String(input));
|
|
79
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve({ animations: [] }) });
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
const render = async (props: { sourceFile: string; version: number }) => {
|
|
83
|
+
await act(async () => {
|
|
84
|
+
if (!root) {
|
|
85
|
+
container = document.createElement("div");
|
|
86
|
+
document.body.appendChild(container);
|
|
87
|
+
root = createRoot(container);
|
|
88
|
+
}
|
|
89
|
+
root.render(<HookHost {...props} />);
|
|
90
|
+
});
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await render({ sourceFile: "index.html", version: 1 });
|
|
97
|
+
const loaded = urls.length;
|
|
98
|
+
await render({ sourceFile: "compositions/keyframe-lab.html", version: 1 });
|
|
99
|
+
expect(urls.length).toBe(loaded);
|
|
100
|
+
await render({ sourceFile: "index.html", version: 1 });
|
|
101
|
+
expect(urls.length).toBe(loaded);
|
|
102
|
+
|
|
103
|
+
await render({ sourceFile: "index.html", version: 2 });
|
|
104
|
+
expect(urls.length).toBe(loaded * 2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("asks again for a covered file whose first read failed", async () => {
|
|
108
|
+
const urls: string[] = [];
|
|
109
|
+
vi.stubGlobal(
|
|
110
|
+
"fetch",
|
|
111
|
+
vi.fn((input: RequestInfo | URL) => {
|
|
112
|
+
const url = String(input);
|
|
113
|
+
urls.push(url);
|
|
114
|
+
const failed =
|
|
115
|
+
url.includes("keyframe-lab.html") && urls.filter((u) => u === url).length === 1;
|
|
116
|
+
return Promise.resolve({
|
|
117
|
+
ok: !failed,
|
|
118
|
+
json: () => Promise.resolve({ animations: [] }),
|
|
119
|
+
});
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const render = async (sourceFile: string) => {
|
|
123
|
+
await act(async () => {
|
|
124
|
+
if (!root) {
|
|
125
|
+
container = document.createElement("div");
|
|
126
|
+
document.body.appendChild(container);
|
|
127
|
+
root = createRoot(container);
|
|
128
|
+
}
|
|
129
|
+
root.render(<HookHost sourceFile={sourceFile} />);
|
|
130
|
+
});
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await Promise.resolve();
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
await render("index.html");
|
|
137
|
+
const labReads = () => urls.filter((u) => u.includes("keyframe-lab.html")).length;
|
|
138
|
+
expect(labReads()).toBe(1);
|
|
139
|
+
await render("compositions/keyframe-lab.html");
|
|
140
|
+
expect(labReads()).toBe(2);
|
|
141
|
+
await render("index.html");
|
|
142
|
+
expect(labReads()).toBe(2);
|
|
143
|
+
});
|
|
70
144
|
});
|
|
@@ -299,3 +299,24 @@ describe("a refused move's toast", () => {
|
|
|
299
299
|
]);
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
|
+
|
|
303
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
304
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
305
|
+
it("counts additive preview selection only after resolving a hit", async () => {
|
|
306
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
307
|
+
const selection = makeSelection("Card", document.createElement("div"));
|
|
308
|
+
const applyDomSelection = vi.fn();
|
|
309
|
+
const { canvas, cleanup } = renderHarness({
|
|
310
|
+
previewIframe: createPreviewIframe(vi.fn()),
|
|
311
|
+
resolveDomSelectionFromPreviewPoint: vi.fn(async () => selection),
|
|
312
|
+
applyDomSelection,
|
|
313
|
+
});
|
|
314
|
+
await dispatchMouseDown(canvas, { shiftKey: true });
|
|
315
|
+
expect(applyDomSelection).toHaveBeenCalledWith(selection, { additive: true });
|
|
316
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
317
|
+
feature: "multi_select",
|
|
318
|
+
surface: "preview",
|
|
319
|
+
method: "button",
|
|
320
|
+
});
|
|
321
|
+
cleanup();
|
|
322
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPreviewFeatureUsed } from "../utils/previewFeatureUsage";
|
|
1
2
|
import { useCallback, useRef } from "react";
|
|
2
3
|
import { liveTime, usePlayerStore } from "../player";
|
|
3
4
|
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
|
|
@@ -169,6 +170,7 @@ export function usePreviewInteraction({
|
|
|
169
170
|
e.preventDefault();
|
|
170
171
|
e.stopPropagation();
|
|
171
172
|
applyDomSelection(nextSelection, { additive: true });
|
|
173
|
+
trackPreviewFeatureUsed("multi_select", "button");
|
|
172
174
|
return;
|
|
173
175
|
}
|
|
174
176
|
|
|
@@ -79,12 +79,13 @@ describe("undo that re-runs the top-level script over an element of a nested com
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
async function undo(fileOk: boolean) {
|
|
82
|
+
async function undo(fileOk: boolean, projectId: string | null = "p1") {
|
|
83
83
|
const { iframe, nwid } = nestedPreview();
|
|
84
84
|
const reloadPreview = vi.fn();
|
|
85
85
|
const fetch = vi.fn(async () => ({ ok: fileOk, json: async () => ({ content: SUB }) }));
|
|
86
86
|
vi.stubGlobal("fetch", fetch);
|
|
87
|
-
usePlayerStore.getState().beginTimelineSession(
|
|
87
|
+
if (projectId) usePlayerStore.getState().beginTimelineSession(projectId);
|
|
88
|
+
else usePlayerStore.setState({ timelineProjectId: null });
|
|
88
89
|
let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
|
|
89
90
|
null;
|
|
90
91
|
function Harness() {
|
|
@@ -109,7 +110,9 @@ describe("undo that re-runs the top-level script over an element of a nested com
|
|
|
109
110
|
it("restores the element from its own composition file", async () => {
|
|
110
111
|
const { nwid, reloadPreview, fetch } = await undo(true);
|
|
111
112
|
|
|
112
|
-
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html")
|
|
113
|
+
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"), {
|
|
114
|
+
credentials: "omit",
|
|
115
|
+
});
|
|
113
116
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
114
117
|
expect(nwid.getAttribute("style")).toBe("left: 40px;");
|
|
115
118
|
});
|
|
@@ -120,4 +123,12 @@ describe("undo that re-runs the top-level script over an element of a nested com
|
|
|
120
123
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
121
124
|
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
|
|
122
125
|
});
|
|
126
|
+
|
|
127
|
+
it("reloads the preview in full when no project is open to read that file from", async () => {
|
|
128
|
+
const { nwid, reloadPreview, fetch } = await undo(true, null);
|
|
129
|
+
|
|
130
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
131
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
|
|
133
|
+
});
|
|
123
134
|
});
|
|
@@ -163,8 +163,9 @@ export function usePreviewPersistence({
|
|
|
163
163
|
}, [drainPendingDomEditSaves]);
|
|
164
164
|
|
|
165
165
|
const settlePendingEdits = useCallback(async (): Promise<void> => {
|
|
166
|
-
|
|
167
|
-
await
|
|
166
|
+
const queued = domEditSaveQueueRef.current?.waitForIdle();
|
|
167
|
+
await flushStudioPendingEdits({ onlyCurrent: true });
|
|
168
|
+
await queued;
|
|
168
169
|
}, []);
|
|
169
170
|
|
|
170
171
|
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
|
@@ -209,14 +210,15 @@ export function usePreviewPersistence({
|
|
|
209
210
|
// multi-file, sub-comp, or a permanent soft-reload failure.
|
|
210
211
|
const projectId = usePlayerStore.getState().timelineProjectId;
|
|
211
212
|
const nestedFiles = await settleNestedReads(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
213
|
+
readUndoNestedFiles(
|
|
214
|
+
previewIframeRef.current,
|
|
215
|
+
activeCompPathRef.current,
|
|
216
|
+
restore.files,
|
|
217
|
+
(path) =>
|
|
218
|
+
projectId
|
|
219
|
+
? readProjectFileContent(projectId, path)
|
|
220
|
+
: Promise.reject(new Error("No project is open to read nested files from.")),
|
|
221
|
+
),
|
|
220
222
|
);
|
|
221
223
|
const strategy = applyUndoRestoreToPreview(
|
|
222
224
|
previewIframeRef.current,
|
|
@@ -258,7 +260,7 @@ export function usePreviewPersistence({
|
|
|
258
260
|
|
|
259
261
|
// ── Migrate legacy studio-motion.json ──
|
|
260
262
|
// Projects that used the old JSON-file approach may still have a populated
|
|
261
|
-
//
|
|
263
|
+
// studio-motion.json in the project's .hyperframes folder. The studio no longer reads it, but
|
|
262
264
|
// the legacy render-script injection in `preview.ts` / `vite.studioMotion.ts`
|
|
263
265
|
// could still fire alongside the new seek-reapply runtime. Empty the file so
|
|
264
266
|
// the legacy codepath no-ops.
|