@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
|
@@ -43,54 +43,60 @@ beforeAll(async () => {
|
|
|
43
43
|
|
|
44
44
|
afterAll(() => browser?.close());
|
|
45
45
|
|
|
46
|
+
// The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
|
|
46
47
|
it.each([
|
|
47
48
|
{ gsap: "hold", placement: "px", rotation: 0 },
|
|
48
49
|
{ gsap: "tween", placement: "px", rotation: 0 },
|
|
49
50
|
{ gsap: "tween", placement: "xpercent", rotation: 30 },
|
|
50
|
-
])(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
51
|
+
])(
|
|
52
|
+
"an undone crop leaves a $gsap $placement r$rotation layer where it was",
|
|
53
|
+
async (spec) => {
|
|
54
|
+
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
|
|
55
|
+
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
56
|
+
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
57
|
+
const previous = restored.replace(
|
|
58
|
+
`<div id="target"`,
|
|
59
|
+
`<div id="target" style="clip-path: ${CROP}"`,
|
|
60
|
+
);
|
|
61
|
+
const page = await browser.newPage();
|
|
62
|
+
await page.setViewport({ width: 1920, height: 1080 });
|
|
63
|
+
await page.setRequestInterception(true);
|
|
64
|
+
// Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
|
|
65
|
+
page.on("request", (request) =>
|
|
66
|
+
request.url().endsWith("/gsap.min.js")
|
|
67
|
+
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
68
|
+
: request.abort("blockedbyclient"),
|
|
69
|
+
);
|
|
70
|
+
await page.setContent(restored, { waitUntil: "load" });
|
|
71
|
+
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
72
|
+
await page.waitForFunction(() => "__player" in window);
|
|
73
|
+
await page.evaluate(undoBundle);
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
75
|
+
const box = () =>
|
|
76
|
+
page.evaluate(() => {
|
|
77
|
+
const { left, top, width, height } = document
|
|
78
|
+
.getElementById("target")!
|
|
79
|
+
.getBoundingClientRect();
|
|
80
|
+
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
81
|
+
});
|
|
82
|
+
await page.evaluate(() =>
|
|
83
|
+
(window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
|
|
84
|
+
);
|
|
85
|
+
const before = await box();
|
|
86
|
+
const outcome = await page.evaluate(
|
|
87
|
+
(files, crop) => {
|
|
88
|
+
document.getElementById("target")!.style.setProperty("clip-path", crop);
|
|
89
|
+
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
|
|
90
|
+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
91
|
+
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
|
|
92
|
+
},
|
|
93
|
+
{ "index.html": { previous, restored } },
|
|
94
|
+
CROP,
|
|
95
|
+
);
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
+
expect(outcome).toBe("soft");
|
|
98
|
+
expect(await box()).toEqual(before);
|
|
99
|
+
await page.close();
|
|
100
|
+
},
|
|
101
|
+
30_000,
|
|
102
|
+
);
|
|
@@ -398,20 +398,17 @@ export function applyUndoRestoreToPreview(
|
|
|
398
398
|
const active = files[activeDocPath];
|
|
399
399
|
const restoredScript = active ? extractGsapScriptText(active.restored) : null;
|
|
400
400
|
const previousScript = active ? extractGsapScriptText(active.previous) : null;
|
|
401
|
-
const reparse = plan.scripted
|
|
402
|
-
|
|
403
|
-
: [];
|
|
404
|
-
if (reparse.length && !restoredScript) {
|
|
401
|
+
const reparse = plan.scripted && plan.targets.some(({ live }) => "_gsap" in live);
|
|
402
|
+
if (reparse && !restoredScript) {
|
|
405
403
|
reloadPreview();
|
|
406
404
|
return "full";
|
|
407
405
|
}
|
|
408
|
-
if (restoredScript && (restoredScript !== previousScript || reparse
|
|
406
|
+
if (restoredScript && (restoredScript !== previousScript || reparse)) {
|
|
409
407
|
syncStaleEditMarks(doc, active);
|
|
410
408
|
const result = applySoftReload(iframe, restoredScript, {
|
|
411
409
|
onAsyncFailure: reloadPreview,
|
|
412
410
|
currentTimeOverride: currentTime,
|
|
413
411
|
authoredHtml: active.restored,
|
|
414
|
-
reparse,
|
|
415
412
|
nestedFiles,
|
|
416
413
|
});
|
|
417
414
|
if (result === "cannot-soft-reload") {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CommitMutationOptions, MutationResult } from "../hooks/gsapScriptCommitTypes";
|
|
2
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
3
|
+
|
|
4
|
+
export type KeyframeUsageAction = "add" | "convert" | "remove_all" | "reset";
|
|
5
|
+
|
|
6
|
+
export function keyframeUsageActions(
|
|
7
|
+
mutations: ReadonlyArray<Record<string, unknown>>,
|
|
8
|
+
semanticActions?: ReadonlyArray<KeyframeUsageAction | undefined>,
|
|
9
|
+
): Set<KeyframeUsageAction> {
|
|
10
|
+
const actions = new Set<KeyframeUsageAction>();
|
|
11
|
+
for (const [index, mutation] of mutations.entries()) {
|
|
12
|
+
const semanticAction = semanticActions?.[index];
|
|
13
|
+
if (semanticAction) {
|
|
14
|
+
actions.add(semanticAction);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
switch (mutation.type) {
|
|
18
|
+
case "add-keyframe":
|
|
19
|
+
case "add-with-keyframes":
|
|
20
|
+
actions.add("add");
|
|
21
|
+
break;
|
|
22
|
+
case "convert-to-keyframes":
|
|
23
|
+
actions.add("convert");
|
|
24
|
+
break;
|
|
25
|
+
case "remove-all-keyframes":
|
|
26
|
+
actions.add("remove_all");
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return actions;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function trackKeyframeUsage(action: KeyframeUsageAction, property?: string): void {
|
|
34
|
+
trackStudioEvent("keyframe", property === undefined ? { action } : { action, property });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function primaryKeyframeAction(
|
|
38
|
+
actions: ReadonlySet<KeyframeUsageAction>,
|
|
39
|
+
): KeyframeUsageAction | undefined {
|
|
40
|
+
return (["add", "convert", "remove_all", "reset"] as const).find((action) => actions.has(action));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function changedMutationIndices(
|
|
44
|
+
result: MutationResult,
|
|
45
|
+
count: number,
|
|
46
|
+
options?: ReadonlyArray<Pick<CommitMutationOptions, "keyframeTelemetry">>,
|
|
47
|
+
): number[] {
|
|
48
|
+
if (!result.ok || result.changed !== true) return [];
|
|
49
|
+
const changes = count === 1 ? [true] : result.mutationChanges;
|
|
50
|
+
if (
|
|
51
|
+
!Array.isArray(changes) ||
|
|
52
|
+
changes.length !== count ||
|
|
53
|
+
changes.some((value) => typeof value !== "boolean")
|
|
54
|
+
)
|
|
55
|
+
return [];
|
|
56
|
+
return changes.flatMap((changed, index) =>
|
|
57
|
+
changed && options?.[index]?.keyframeTelemetry !== false ? [index] : [],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function trackKeyframeCommit(
|
|
62
|
+
mutations: ReadonlyArray<Record<string, unknown>>,
|
|
63
|
+
result: MutationResult,
|
|
64
|
+
options: CommitMutationOptions,
|
|
65
|
+
memberOptions?: ReadonlyArray<CommitMutationOptions>,
|
|
66
|
+
): void {
|
|
67
|
+
if (!result.ok || result.changed !== true || options.keyframeTelemetry === false) return;
|
|
68
|
+
const indices = changedMutationIndices(result, mutations.length, memberOptions);
|
|
69
|
+
const actions = keyframeUsageActions(
|
|
70
|
+
indices.map((index) => mutations[index]!),
|
|
71
|
+
indices.map(
|
|
72
|
+
(index) =>
|
|
73
|
+
memberOptions?.[index]?.keyframeAction ??
|
|
74
|
+
(mutations.length === 1 ? options.keyframeAction : undefined),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
const action = primaryKeyframeAction(actions);
|
|
78
|
+
if (action) trackKeyframeUsage(action, action === "add" ? options.keyframeProperty : undefined);
|
|
79
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { trackPreviewEditResult } from "./previewFeatureUsage";
|
|
3
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
4
|
+
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
5
|
+
beforeEach(() => vi.clearAllMocks());
|
|
6
|
+
describe("preview write usage", () => {
|
|
7
|
+
it.each([
|
|
8
|
+
{ ok: true, changed: true },
|
|
9
|
+
{
|
|
10
|
+
ok: true,
|
|
11
|
+
persistence: { changed: true, sourceFile: "private.html", content: "private text" },
|
|
12
|
+
},
|
|
13
|
+
])("sends only gesture metadata after a changed result", (result) => {
|
|
14
|
+
trackPreviewEditResult("resize", "drag", result);
|
|
15
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
16
|
+
feature: "resize",
|
|
17
|
+
surface: "preview",
|
|
18
|
+
method: "drag",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
it.each([
|
|
22
|
+
undefined,
|
|
23
|
+
null,
|
|
24
|
+
{},
|
|
25
|
+
{ ok: false, changed: true },
|
|
26
|
+
{ changed: false },
|
|
27
|
+
{ persistence: { changed: false } },
|
|
28
|
+
])("does not count unavailable, failed or unchanged results (%j)", (result) => {
|
|
29
|
+
trackPreviewEditResult("move", "drag", result);
|
|
30
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
2
|
+
|
|
3
|
+
export type PreviewFeature =
|
|
4
|
+
| "move"
|
|
5
|
+
| "resize"
|
|
6
|
+
| "rotate"
|
|
7
|
+
| "crop"
|
|
8
|
+
| "nudge"
|
|
9
|
+
| "multi_select"
|
|
10
|
+
| "z_order"
|
|
11
|
+
| "text_edit"
|
|
12
|
+
| "motion_path"
|
|
13
|
+
| "gesture_recording"
|
|
14
|
+
| "snapping"
|
|
15
|
+
| "grid"
|
|
16
|
+
| "grid_spacing"
|
|
17
|
+
| "snap_to_grid"
|
|
18
|
+
| "ruler"
|
|
19
|
+
| "safe_margins";
|
|
20
|
+
|
|
21
|
+
export type PreviewMethod = "drag" | "button" | "keyboard" | "field";
|
|
22
|
+
|
|
23
|
+
export interface GeometryCommitResult {
|
|
24
|
+
ok: true;
|
|
25
|
+
changed: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function trackPreviewFeatureUsed(feature: PreviewFeature, method: PreviewMethod): void {
|
|
29
|
+
trackStudioEvent("feature_used", { feature, surface: "preview", method });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function trackPreviewEditResult(
|
|
33
|
+
feature: PreviewFeature,
|
|
34
|
+
method: PreviewMethod,
|
|
35
|
+
result: unknown,
|
|
36
|
+
): void {
|
|
37
|
+
if (typeof result !== "object" || result === null) return;
|
|
38
|
+
if (Reflect.get(result, "ok") === false) return;
|
|
39
|
+
const persistence = Reflect.get(result, "persistence");
|
|
40
|
+
const changed =
|
|
41
|
+
Reflect.get(result, "changed") === true ||
|
|
42
|
+
(typeof persistence === "object" &&
|
|
43
|
+
persistence !== null &&
|
|
44
|
+
Reflect.get(persistence, "changed") === true);
|
|
45
|
+
if (changed) trackPreviewFeatureUsed(feature, method);
|
|
46
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyPreviewVariablesToUrl } from "../hooks/previewVariablesStore";
|
|
2
2
|
import { buildProjectApiPath, parseProjectIdFromHash } from "./projectRouting";
|
|
3
|
+
import { studioApiFetch } from "./studioApiFetch";
|
|
3
4
|
|
|
4
5
|
let prefetched: string | null = null;
|
|
5
6
|
|
|
@@ -10,5 +11,5 @@ export function prefetchPreviewForHash(hash: string): void {
|
|
|
10
11
|
prefetched = projectId;
|
|
11
12
|
const url = new URL(buildProjectApiPath(projectId, "/preview"), window.location.origin);
|
|
12
13
|
applyPreviewVariablesToUrl(url);
|
|
13
|
-
|
|
14
|
+
studioApiFetch(url).catch(() => undefined);
|
|
14
15
|
}
|
|
@@ -6,6 +6,7 @@ import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
|
6
6
|
import { buildProjectApiPath } from "./projectRouting";
|
|
7
7
|
import { markStudioWriteToken } from "./studioFileVersion";
|
|
8
8
|
import { resolveElementTrack } from "./studioHelpers";
|
|
9
|
+
import { studioApiFetch } from "./studioApiFetch";
|
|
9
10
|
|
|
10
11
|
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
11
12
|
|
|
@@ -101,7 +102,7 @@ export function buildAtomicCutIntents(
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
async function readFileVersion(projectId: string, path: string): Promise<string> {
|
|
104
|
-
const response = await
|
|
105
|
+
const response = await studioApiFetch(
|
|
105
106
|
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
|
|
106
107
|
);
|
|
107
108
|
if (!response.ok) throw new Error(`Failed to read ${path} before cut (${response.status})`);
|
|
@@ -124,14 +125,17 @@ async function requestAtomicCut(
|
|
|
124
125
|
}
|
|
125
126
|
const transactionToken = `cut:${crypto.randomUUID()}`;
|
|
126
127
|
markStudioWriteToken(transactionToken);
|
|
127
|
-
const response = await
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
const response = await studioApiFetch(
|
|
129
|
+
buildProjectApiPath(projectId, "/file-mutations/split-batch"),
|
|
130
|
+
{
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
"X-Hyperframes-Write-Token": transactionToken,
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({ files, transactionToken }),
|
|
132
137
|
},
|
|
133
|
-
|
|
134
|
-
});
|
|
138
|
+
);
|
|
135
139
|
const body = (await response.json().catch(() => null)) as
|
|
136
140
|
| (Partial<CutBatchResponse> & { error?: string; outcome?: string })
|
|
137
141
|
| null;
|
|
@@ -48,10 +48,8 @@ export function collectResetTargets(
|
|
|
48
48
|
win: { __timelines?: Record<string, unknown> },
|
|
49
49
|
doc: Document,
|
|
50
50
|
targetKeys: string[],
|
|
51
|
-
reparse: Element[] = [],
|
|
52
51
|
): Map<Element, Set<string>> {
|
|
53
|
-
|
|
54
|
-
const targets = new Map<Element, Set<string>>(reparse.map((el) => [el, new Set<string>()]));
|
|
52
|
+
const targets = new Map<Element, Set<string>>();
|
|
55
53
|
const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
|
|
56
54
|
const others = new Set<unknown>(
|
|
57
55
|
Object.entries(timelines).flatMap(([key, tl]) => (targetKeys.includes(key) ? [] : [tl])),
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
async function loadWithEnv(env: Record<string, string>) {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
|
|
6
|
+
return import("./studioApiFetch");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("studioApiFetch", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
vi.unstubAllEnvs();
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("sends Studio's requests without credentials, keeping the caller's options", async () => {
|
|
17
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
18
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
19
|
+
const { studioApiFetch } = await loadWithEnv({});
|
|
20
|
+
const signal = new AbortController().signal;
|
|
21
|
+
|
|
22
|
+
await studioApiFetch("/api/projects/demo/history/step", { method: "POST", signal });
|
|
23
|
+
|
|
24
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/history/step", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
signal,
|
|
27
|
+
credentials: "omit",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
["the CLI or Studio dev server", "http://localhost:5190/"],
|
|
33
|
+
["Desktop's own server", "http://127.0.0.1:43117/"],
|
|
34
|
+
["IPv6 loopback", "http://[::1]:8080/"],
|
|
35
|
+
])("leaves cookies out when Studio is served from %s", async (_, page) => {
|
|
36
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
37
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
38
|
+
vi.stubGlobal("location", new URL(page));
|
|
39
|
+
const { studioApiFetch } = await loadWithEnv({});
|
|
40
|
+
|
|
41
|
+
await studioApiFetch("/api/projects/demo/files/index.html");
|
|
42
|
+
|
|
43
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
|
|
44
|
+
credentials: "omit",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps same-origin cookies off loopback, where a proxy or tunnel may authenticate with them", async () => {
|
|
49
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
50
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
51
|
+
vi.stubGlobal("location", new URL("https://studio.example.com/editor"));
|
|
52
|
+
const { studioApiFetch } = await loadWithEnv({});
|
|
53
|
+
|
|
54
|
+
await studioApiFetch("/api/projects/demo/files/index.html");
|
|
55
|
+
|
|
56
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
|
|
57
|
+
credentials: "same-origin",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("sends same-origin credentials on loopback for a host that opted out", async () => {
|
|
62
|
+
vi.stubGlobal("location", new URL("http://localhost:5190/"));
|
|
63
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
64
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
65
|
+
const { studioApiFetch } = await loadWithEnv({
|
|
66
|
+
VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS: "true",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await studioApiFetch("/api/projects/demo/files/index.html");
|
|
70
|
+
|
|
71
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
|
|
72
|
+
credentials: "same-origin",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { STUDIO_API_SAME_ORIGIN_CREDENTIALS } from "../components/editor/manualEditingAvailability";
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
4
|
+
|
|
5
|
+
function studioApiCredentials(): RequestCredentials {
|
|
6
|
+
if (STUDIO_API_SAME_ORIGIN_CREDENTIALS) return "same-origin";
|
|
7
|
+
const host = globalThis.location?.hostname;
|
|
8
|
+
return !host || LOOPBACK_HOSTS.has(host) ? "omit" : "same-origin";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function studioApiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
12
|
+
return globalThis.fetch(input, { ...init, credentials: studioApiCredentials() });
|
|
13
|
+
}
|
|
@@ -2,6 +2,7 @@ import { buildProjectApiPath } from "./projectRouting";
|
|
|
2
2
|
import type { MutableRefObject } from "react";
|
|
3
3
|
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
4
4
|
import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
|
|
5
|
+
import { studioApiFetch } from "./studioApiFetch";
|
|
5
6
|
|
|
6
7
|
export interface RecordEditInput {
|
|
7
8
|
label: string;
|
|
@@ -49,7 +50,9 @@ interface SaveProjectFilesWithHistoryInput {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export async function readProjectFileContent(pid: string, path: string): Promise<string> {
|
|
52
|
-
const response = await
|
|
53
|
+
const response = await studioApiFetch(
|
|
54
|
+
buildProjectApiPath(pid, `/files/${encodeURIComponent(path)}`),
|
|
55
|
+
);
|
|
53
56
|
if (!response.ok) {
|
|
54
57
|
throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
|
|
55
58
|
}
|
|
@@ -4,6 +4,7 @@ import type { TimelineElement } from "../player/store/playerStore";
|
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
5
|
import type { TimelineAssetKind } from "./timelineAssetDrop";
|
|
6
6
|
import { roundToCenti } from "./rounding";
|
|
7
|
+
import { studioApiFetch } from "./studioApiFetch";
|
|
7
8
|
|
|
8
9
|
export interface EditingFile {
|
|
9
10
|
path: string;
|
|
@@ -91,6 +92,11 @@ export function isImageBackgroundValue(value: string): boolean {
|
|
|
91
92
|
return /^url\(/i.test(value.trim());
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
export function cssPropertyName(property: string): string {
|
|
96
|
+
if (property.startsWith("--")) return property;
|
|
97
|
+
return property.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`).replace(/^ms-/, "-ms-");
|
|
98
|
+
}
|
|
99
|
+
|
|
94
100
|
export function isManualGeometryStyleProperty(property: string): boolean {
|
|
95
101
|
return property === "left" || property === "top" || property === "width" || property === "height";
|
|
96
102
|
}
|
|
@@ -401,7 +407,7 @@ export async function resolveAssetHasAudio(
|
|
|
401
407
|
assetPath: string,
|
|
402
408
|
): Promise<boolean | null> {
|
|
403
409
|
try {
|
|
404
|
-
const response = await
|
|
410
|
+
const response = await studioApiFetch(mediaMetadataUrl(projectId, assetPath));
|
|
405
411
|
if (!response.ok) return null;
|
|
406
412
|
const data: unknown = await response.json();
|
|
407
413
|
const metadata = isPlainRecord(data) ? data.metadata : undefined;
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
beginStudioPendingEdit,
|
|
6
6
|
flushStudioPendingEdits,
|
|
7
7
|
hasStudioPendingEdits,
|
|
8
|
-
|
|
8
|
+
paintBackNewestStudioPendingEdit,
|
|
9
9
|
trackStudioPendingEdit,
|
|
10
10
|
trackedStudioEdit,
|
|
11
11
|
} from "./studioPendingEdits";
|
|
@@ -227,12 +227,12 @@ describe("a pending edit undo can paint back", () => {
|
|
|
227
227
|
shown.length = 0;
|
|
228
228
|
const first = edit("first");
|
|
229
229
|
const second = edit("second");
|
|
230
|
-
const again =
|
|
230
|
+
const again = paintBackNewestStudioPendingEdit();
|
|
231
231
|
expect(shown).toEqual(["second undone"]);
|
|
232
232
|
expect(second.reverted()).toBe(true);
|
|
233
233
|
expect(first.reverted()).toBe(false);
|
|
234
|
-
expect(
|
|
235
|
-
again
|
|
234
|
+
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
235
|
+
again!.showAgain();
|
|
236
236
|
expect(shown).toEqual(["second undone", "second again"]);
|
|
237
237
|
first.settle();
|
|
238
238
|
second.settle();
|
|
@@ -243,7 +243,7 @@ describe("a pending edit undo can paint back", () => {
|
|
|
243
243
|
const move = edit("move");
|
|
244
244
|
let saved!: () => void;
|
|
245
245
|
trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve)));
|
|
246
|
-
expect(
|
|
246
|
+
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
247
247
|
expect(shown).toEqual([]);
|
|
248
248
|
saved();
|
|
249
249
|
move.settle();
|
|
@@ -258,7 +258,7 @@ describe("a pending edit undo can paint back", () => {
|
|
|
258
258
|
trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve))),
|
|
259
259
|
);
|
|
260
260
|
move.settle(save);
|
|
261
|
-
|
|
261
|
+
paintBackNewestStudioPendingEdit();
|
|
262
262
|
expect(shown).toEqual(["move undone"]);
|
|
263
263
|
saved();
|
|
264
264
|
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
@@ -295,3 +295,20 @@ describe("the package's public revert", () => {
|
|
|
295
295
|
}
|
|
296
296
|
});
|
|
297
297
|
});
|
|
298
|
+
|
|
299
|
+
describe("a drain of only the current edits", () => {
|
|
300
|
+
it("ends once the edits pending at its start land, without waiting for one started after", async () => {
|
|
301
|
+
let landFirst!: () => void;
|
|
302
|
+
let landLater!: () => void;
|
|
303
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (landFirst = resolve)));
|
|
304
|
+
let drained = false;
|
|
305
|
+
const drain = flushStudioPendingEdits({ onlyCurrent: true }).then(() => (drained = true));
|
|
306
|
+
await Promise.resolve();
|
|
307
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (landLater = resolve)));
|
|
308
|
+
landFirst();
|
|
309
|
+
await drain;
|
|
310
|
+
expect(drained).toBe(true);
|
|
311
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
312
|
+
landLater();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -11,7 +11,13 @@ export type StudioPendingEditsDrainResult = StudioSaveDrainResult;
|
|
|
11
11
|
|
|
12
12
|
export type StudioEditRevert = () => () => void;
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface PendingEdit {
|
|
15
|
+
revert: StudioEditRevert | null;
|
|
16
|
+
landed: () => Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pendingEdits = new Map<Promise<unknown>, PendingEdit>();
|
|
20
|
+
const NOT_SAVED = () => Promise.resolve(false);
|
|
15
21
|
let adopting = false;
|
|
16
22
|
|
|
17
23
|
function waitForPostBlurEffects(): Promise<void> {
|
|
@@ -67,7 +73,7 @@ export function trackStudioPendingEdit(
|
|
|
67
73
|
if (!result) return undefined;
|
|
68
74
|
const promise = Promise.resolve(result);
|
|
69
75
|
if (adopting) return promise;
|
|
70
|
-
pendingEdits.set(promise, { revert: null });
|
|
76
|
+
pendingEdits.set(promise, { revert: null, landed: NOT_SAVED });
|
|
71
77
|
promise.then(
|
|
72
78
|
() => pendingEdits.delete(promise),
|
|
73
79
|
() => pendingEdits.delete(promise),
|
|
@@ -90,6 +96,8 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
|
|
|
90
96
|
const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
|
|
91
97
|
const entry = pendingEdits.get(promise)!;
|
|
92
98
|
entry.revert = revert;
|
|
99
|
+
let landed = Promise.resolve(false);
|
|
100
|
+
entry.landed = () => landed;
|
|
93
101
|
return {
|
|
94
102
|
settle,
|
|
95
103
|
reverted: () => entry.revert === null && revert !== null,
|
|
@@ -97,7 +105,12 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
|
|
|
97
105
|
adopt<T>(start: () => T): T {
|
|
98
106
|
adopting = true;
|
|
99
107
|
try {
|
|
100
|
-
|
|
108
|
+
const committed = start();
|
|
109
|
+
landed = Promise.resolve(committed).then(
|
|
110
|
+
() => true,
|
|
111
|
+
() => false,
|
|
112
|
+
);
|
|
113
|
+
return committed;
|
|
101
114
|
} catch (error) {
|
|
102
115
|
settle();
|
|
103
116
|
throw error;
|
|
@@ -108,15 +121,24 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
|
|
|
108
121
|
};
|
|
109
122
|
}
|
|
110
123
|
|
|
111
|
-
export function
|
|
124
|
+
export function paintBackNewestStudioPendingEdit(): {
|
|
125
|
+
showAgain: () => void;
|
|
126
|
+
landed: () => Promise<boolean>;
|
|
127
|
+
} | null {
|
|
112
128
|
const newest = [...pendingEdits.values()].at(-1);
|
|
113
129
|
const revert = newest?.revert;
|
|
114
130
|
if (!newest || !revert) return null;
|
|
115
131
|
newest.revert = null;
|
|
116
|
-
return revert();
|
|
132
|
+
return { showAgain: revert(), landed: newest.landed };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function revertNewestStudioPendingEdit(): (() => void) | null {
|
|
136
|
+
return paintBackNewestStudioPendingEdit()?.showAgain ?? null;
|
|
117
137
|
}
|
|
118
138
|
|
|
119
|
-
export async function flushStudioPendingEdits(
|
|
139
|
+
export async function flushStudioPendingEdits({
|
|
140
|
+
onlyCurrent = false,
|
|
141
|
+
} = {}): Promise<StudioPendingEditsDrainResult> {
|
|
120
142
|
const active = focusedField();
|
|
121
143
|
if (active) {
|
|
122
144
|
active.blur();
|
|
@@ -128,10 +150,12 @@ export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrain
|
|
|
128
150
|
window.dispatchEvent(
|
|
129
151
|
new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
|
|
130
152
|
);
|
|
153
|
+
const current = onlyCurrent ? new Set(pendingEdits.keys()) : null;
|
|
154
|
+
const waiting = () => [...pendingEdits.keys()].filter((edit) => !current || current.has(edit));
|
|
131
155
|
let conflict: StudioFileConflictError | undefined;
|
|
132
156
|
let firstFailure: PromiseRejectedResult | undefined;
|
|
133
|
-
while (detail.promises.length > 0 ||
|
|
134
|
-
const promises = [...detail.promises, ...
|
|
157
|
+
while (detail.promises.length > 0 || waiting().length > 0) {
|
|
158
|
+
const promises = [...detail.promises, ...waiting()];
|
|
135
159
|
detail.promises = [];
|
|
136
160
|
const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
|
|
137
161
|
conflict ??= batchFailures.conflict;
|