@hyperframes/studio 0.7.55 → 0.7.57
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--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +126 -125
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +53 -2
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +260 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +56 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +23 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +148 -18
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +42 -2
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
7
|
+
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
8
|
+
|
|
9
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
10
|
+
|
|
11
|
+
const trackStudioEventMock = vi.mocked(trackStudioEvent);
|
|
12
|
+
|
|
13
|
+
function rect(x: number, y: number, width: number, height: number): DOMRect {
|
|
14
|
+
return { x, y, width, height } as DOMRect;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runTwoMutationTransaction(
|
|
18
|
+
underlying: CommitMutation,
|
|
19
|
+
firstSelection: DomEditSelection,
|
|
20
|
+
secondSelection = firstSelection,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
return runGestureTransaction({
|
|
23
|
+
element: firstSelection.element,
|
|
24
|
+
label: "Resize layer",
|
|
25
|
+
settle: vi.fn(),
|
|
26
|
+
persist: async (commit) => {
|
|
27
|
+
const commitMutation = commit(underlying);
|
|
28
|
+
await commitMutation(firstSelection, { type: "first" }, { label: "First" });
|
|
29
|
+
await commitMutation(secondSelection, { type: "last" }, { label: "Last", softReload: true });
|
|
30
|
+
},
|
|
31
|
+
restore: vi.fn(),
|
|
32
|
+
skipPixelAssert: true,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("runGestureTransaction", () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
trackStudioEventMock.mockReset();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("settles synchronously before persist reaches its first await", async () => {
|
|
42
|
+
const order: string[] = [];
|
|
43
|
+
let release!: () => void;
|
|
44
|
+
const gate = new Promise<void>((resolve) => {
|
|
45
|
+
release = resolve;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const transaction = runGestureTransaction({
|
|
49
|
+
element: document.createElement("div"),
|
|
50
|
+
label: "Resize layer",
|
|
51
|
+
settle: () => order.push("settle"),
|
|
52
|
+
persist: async () => {
|
|
53
|
+
order.push("persist");
|
|
54
|
+
await gate;
|
|
55
|
+
order.push("persisted");
|
|
56
|
+
},
|
|
57
|
+
restore: vi.fn(),
|
|
58
|
+
skipPixelAssert: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(order).toEqual(["settle", "persist"]);
|
|
62
|
+
release();
|
|
63
|
+
await transaction;
|
|
64
|
+
expect(order).toEqual(["settle", "persist", "persisted"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("injects one coalesce key and reloads only the final mutation", async () => {
|
|
68
|
+
const element = document.createElement("div");
|
|
69
|
+
const selection = { element, id: "clip" } as DomEditSelection;
|
|
70
|
+
const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
|
|
71
|
+
const batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
|
|
72
|
+
underlying.batch = batch;
|
|
73
|
+
const now = vi.spyOn(performance, "now").mockReturnValueOnce(100).mockReturnValueOnce(112.6);
|
|
74
|
+
|
|
75
|
+
await runTwoMutationTransaction(underlying, selection);
|
|
76
|
+
|
|
77
|
+
expect(underlying).not.toHaveBeenCalled();
|
|
78
|
+
expect(batch).toHaveBeenCalledTimes(1);
|
|
79
|
+
const [calls, mergedOptions] = batch.mock.calls[0]!;
|
|
80
|
+
const firstOptions = calls[0]!.options;
|
|
81
|
+
const lastOptions = calls[1]!.options;
|
|
82
|
+
expect(calls.map(({ mutation }) => mutation)).toEqual([{ type: "first" }, { type: "last" }]);
|
|
83
|
+
expect(firstOptions.coalesceKey).toBe(lastOptions.coalesceKey);
|
|
84
|
+
expect(firstOptions.coalesceKey).toMatch(/^tx:Resize layer:\d+$/);
|
|
85
|
+
expect(firstOptions.coalesceMs).toBe(Number.POSITIVE_INFINITY);
|
|
86
|
+
expect(lastOptions.coalesceMs).toBe(Number.POSITIVE_INFINITY);
|
|
87
|
+
expect(firstOptions).toMatchObject({ skipReload: true });
|
|
88
|
+
expect(firstOptions.softReload).toBeUndefined();
|
|
89
|
+
expect(lastOptions).toMatchObject({ softReload: true });
|
|
90
|
+
expect(lastOptions.skipReload).toBeUndefined();
|
|
91
|
+
// The transaction owns the undo label — the per-mutation "First"/"Last"
|
|
92
|
+
// labels are overridden so the coalesced entry reads "Resize layer".
|
|
93
|
+
expect(firstOptions.label).toBe("Resize layer");
|
|
94
|
+
expect(lastOptions.label).toBe("Resize layer");
|
|
95
|
+
expect(mergedOptions).toMatchObject({
|
|
96
|
+
label: "Resize layer",
|
|
97
|
+
coalesceKey: firstOptions.coalesceKey,
|
|
98
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
99
|
+
softReload: true,
|
|
100
|
+
});
|
|
101
|
+
expect(mergedOptions.skipReload).toBeUndefined();
|
|
102
|
+
expect(trackStudioEventMock).toHaveBeenCalledWith("commit_transaction", {
|
|
103
|
+
label: "Resize layer",
|
|
104
|
+
mutation_count: 2,
|
|
105
|
+
reload_count: 1,
|
|
106
|
+
duration_ms: 13,
|
|
107
|
+
pixel_asserted: false,
|
|
108
|
+
});
|
|
109
|
+
now.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reports one reload when a batch collapses two softReload commits", async () => {
|
|
113
|
+
const element = document.createElement("div");
|
|
114
|
+
const selection = { element, id: "clip", sourceFile: "index.html" } as DomEditSelection;
|
|
115
|
+
const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
|
|
116
|
+
underlying.batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
|
|
117
|
+
|
|
118
|
+
// Both a resize's size and offset persists request softReload; the batch is
|
|
119
|
+
// one write and one reload, so telemetry must report reload_count 1, not 2.
|
|
120
|
+
await runGestureTransaction({
|
|
121
|
+
element,
|
|
122
|
+
label: "Resize layer",
|
|
123
|
+
settle: vi.fn(),
|
|
124
|
+
persist: async (commit) => {
|
|
125
|
+
const commitMutation = commit(underlying);
|
|
126
|
+
await commitMutation(selection, { type: "size" }, { label: "Resize", softReload: true });
|
|
127
|
+
await commitMutation(selection, { type: "offset" }, { label: "Move", softReload: true });
|
|
128
|
+
},
|
|
129
|
+
restore: vi.fn(),
|
|
130
|
+
skipPixelAssert: true,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(underlying.batch).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(trackStudioEventMock).toHaveBeenCalledWith(
|
|
135
|
+
"commit_transaction",
|
|
136
|
+
expect.objectContaining({ mutation_count: 2, reload_count: 1 }),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("falls back to sequential dispatch when the commit has no batch capability", async () => {
|
|
141
|
+
const element = document.createElement("div");
|
|
142
|
+
const selection = { element, id: "clip" } as DomEditSelection;
|
|
143
|
+
const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
|
|
144
|
+
|
|
145
|
+
await runTwoMutationTransaction(underlying, selection);
|
|
146
|
+
|
|
147
|
+
expect(underlying).toHaveBeenCalledTimes(2);
|
|
148
|
+
expect(underlying.mock.calls[0]![2]).toMatchObject({ skipReload: true });
|
|
149
|
+
expect(underlying.mock.calls[1]![2]).toMatchObject({ softReload: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("keeps a single mutation on the original commit path", async () => {
|
|
153
|
+
const element = document.createElement("div");
|
|
154
|
+
const selection = { element, id: "clip" } as DomEditSelection;
|
|
155
|
+
const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
|
|
156
|
+
const batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
|
|
157
|
+
underlying.batch = batch;
|
|
158
|
+
|
|
159
|
+
await runGestureTransaction({
|
|
160
|
+
element,
|
|
161
|
+
label: "Move layer",
|
|
162
|
+
settle: vi.fn(),
|
|
163
|
+
persist: async (commit) => {
|
|
164
|
+
await commit(underlying)(
|
|
165
|
+
selection,
|
|
166
|
+
{ type: "update-property" },
|
|
167
|
+
{ label: "Move", softReload: true },
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
restore: vi.fn(),
|
|
171
|
+
skipPixelAssert: true,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(underlying).toHaveBeenCalledTimes(1);
|
|
175
|
+
expect(batch).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not batch mutations for different source files", async () => {
|
|
179
|
+
const element = document.createElement("div");
|
|
180
|
+
const firstSelection = { element, id: "a", sourceFile: "a.html" } as DomEditSelection;
|
|
181
|
+
const secondSelection = { element, id: "b", sourceFile: "b.html" } as DomEditSelection;
|
|
182
|
+
const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
|
|
183
|
+
const batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
|
|
184
|
+
underlying.batch = batch;
|
|
185
|
+
|
|
186
|
+
await runTwoMutationTransaction(underlying, firstSelection, secondSelection);
|
|
187
|
+
|
|
188
|
+
expect(batch).not.toHaveBeenCalled();
|
|
189
|
+
expect(underlying).toHaveBeenCalledTimes(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("identifies transaction-owned commit wrappers without marking their underlying commit", async () => {
|
|
193
|
+
const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
|
|
194
|
+
let wrapped: CommitMutation | null = null;
|
|
195
|
+
|
|
196
|
+
await runGestureTransaction({
|
|
197
|
+
element: document.createElement("div"),
|
|
198
|
+
label: "Move layer",
|
|
199
|
+
settle: vi.fn(),
|
|
200
|
+
persist: async (commit) => {
|
|
201
|
+
wrapped = commit(underlying);
|
|
202
|
+
},
|
|
203
|
+
restore: vi.fn(),
|
|
204
|
+
skipPixelAssert: true,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(isGestureTransactionCommit(underlying)).toBe(false);
|
|
208
|
+
expect(isGestureTransactionCommit(wrapped!)).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("restores exactly once and rethrows a persist failure", async () => {
|
|
212
|
+
const error = new Error("persist failed with secret selector #private-layer");
|
|
213
|
+
error.name = "PersistenceError";
|
|
214
|
+
const restore = vi.fn();
|
|
215
|
+
const element = document.createElement("div");
|
|
216
|
+
const selection = { element, id: "clip" } as DomEditSelection;
|
|
217
|
+
const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
|
|
218
|
+
|
|
219
|
+
await expect(
|
|
220
|
+
runGestureTransaction({
|
|
221
|
+
element,
|
|
222
|
+
label: "Resize layer",
|
|
223
|
+
settle: vi.fn(),
|
|
224
|
+
persist: async (commit) => {
|
|
225
|
+
await commit(underlying)(selection, { type: "position" }, { label: "Position" });
|
|
226
|
+
throw error;
|
|
227
|
+
},
|
|
228
|
+
restore,
|
|
229
|
+
skipPixelAssert: true,
|
|
230
|
+
}),
|
|
231
|
+
).rejects.toBe(error);
|
|
232
|
+
expect(restore).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(trackStudioEventMock).toHaveBeenCalledWith("commit_transaction_failed", {
|
|
234
|
+
label: "Resize layer",
|
|
235
|
+
mutation_count: 1,
|
|
236
|
+
error_name: "PersistenceError",
|
|
237
|
+
restore_ran: true,
|
|
238
|
+
});
|
|
239
|
+
const failureProperties = trackStudioEventMock.mock.calls.find(
|
|
240
|
+
([event]) => event === "commit_transaction_failed",
|
|
241
|
+
)?.[1];
|
|
242
|
+
expect(JSON.stringify(failureProperties)).not.toContain(error.message);
|
|
243
|
+
expect(JSON.stringify(failureProperties)).not.toContain("#private-layer");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("reports when persistence changes pixels", async () => {
|
|
247
|
+
const element = document.createElement("div");
|
|
248
|
+
const getRect = vi
|
|
249
|
+
.spyOn(element, "getBoundingClientRect")
|
|
250
|
+
.mockReturnValueOnce(rect(10.04, 20.05, 100.05, 80.05))
|
|
251
|
+
.mockReturnValueOnce(rect(11.19, 17.89, 100.29, 78.99));
|
|
252
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
253
|
+
const now = vi.spyOn(performance, "now").mockReturnValueOnce(50).mockReturnValueOnce(58.44);
|
|
254
|
+
|
|
255
|
+
await runGestureTransaction({
|
|
256
|
+
element,
|
|
257
|
+
label: "Resize layer",
|
|
258
|
+
settle: vi.fn(),
|
|
259
|
+
persist: async () => undefined,
|
|
260
|
+
restore: vi.fn(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(getRect).toHaveBeenCalledTimes(2);
|
|
264
|
+
expect(error).toHaveBeenCalledWith(
|
|
265
|
+
"[hf-commit] persist changed pixels",
|
|
266
|
+
expect.objectContaining({
|
|
267
|
+
label: "Resize layer",
|
|
268
|
+
delta: expect.objectContaining({ x: expect.any(Number) }),
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
expect(trackStudioEventMock).toHaveBeenCalledWith("commit_invariant_violation", {
|
|
272
|
+
label: "Resize layer",
|
|
273
|
+
delta_x: 1.2,
|
|
274
|
+
delta_y: -2.2,
|
|
275
|
+
delta_w: 0.2,
|
|
276
|
+
delta_h: -1.1,
|
|
277
|
+
mutation_count: 0,
|
|
278
|
+
reload_count: 0,
|
|
279
|
+
duration_ms: 8,
|
|
280
|
+
});
|
|
281
|
+
expect(trackStudioEventMock).toHaveBeenCalledWith(
|
|
282
|
+
"commit_transaction",
|
|
283
|
+
expect.objectContaining({ pixel_asserted: true }),
|
|
284
|
+
);
|
|
285
|
+
now.mockRestore();
|
|
286
|
+
error.mockRestore();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("skips the pixel assertion for live position tweens", async () => {
|
|
290
|
+
const element = document.createElement("div");
|
|
291
|
+
const getRect = vi.spyOn(element, "getBoundingClientRect");
|
|
292
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
293
|
+
|
|
294
|
+
await runGestureTransaction({
|
|
295
|
+
element,
|
|
296
|
+
label: "Resize layer",
|
|
297
|
+
settle: vi.fn(),
|
|
298
|
+
persist: async () => undefined,
|
|
299
|
+
restore: vi.fn(),
|
|
300
|
+
skipPixelAssert: true,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(getRect).not.toHaveBeenCalled();
|
|
304
|
+
expect(error).not.toHaveBeenCalledWith("[hf-commit] persist changed pixels", expect.anything());
|
|
305
|
+
expect(trackStudioEventMock).not.toHaveBeenCalledWith(
|
|
306
|
+
"commit_invariant_violation",
|
|
307
|
+
expect.anything(),
|
|
308
|
+
);
|
|
309
|
+
error.mockRestore();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommitMutation,
|
|
3
|
+
CommitMutationCall,
|
|
4
|
+
CommitMutationOptions,
|
|
5
|
+
} from "./gsapScriptCommitTypes";
|
|
6
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
|
+
|
|
8
|
+
type PixelRect = Pick<DOMRect, "x" | "y" | "width" | "height">;
|
|
9
|
+
|
|
10
|
+
export type TxCommit = (commitMutation: CommitMutation) => CommitMutation;
|
|
11
|
+
|
|
12
|
+
export interface GestureTransaction {
|
|
13
|
+
element: HTMLElement;
|
|
14
|
+
label: string;
|
|
15
|
+
settle(): void;
|
|
16
|
+
persist(commit: TxCommit): Promise<void>;
|
|
17
|
+
restore(): void;
|
|
18
|
+
skipPixelAssert?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let transactionCounter = 0;
|
|
22
|
+
const transactionCommits = new WeakSet<CommitMutation>();
|
|
23
|
+
|
|
24
|
+
/** Whether a commit function already belongs to an active gesture transaction. */
|
|
25
|
+
export function isGestureTransactionCommit(commitMutation: CommitMutation): boolean {
|
|
26
|
+
return transactionCommits.has(commitMutation);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readPixelRect(element: HTMLElement): PixelRect {
|
|
30
|
+
const { x, y, width, height } = element.getBoundingClientRect();
|
|
31
|
+
return { x, y, width, height };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function transactionOptions(
|
|
35
|
+
options: CommitMutationOptions,
|
|
36
|
+
coalesceKey: string,
|
|
37
|
+
label: string,
|
|
38
|
+
): CommitMutationOptions {
|
|
39
|
+
// The transaction owns the undo label: every wrapped mutation records under
|
|
40
|
+
// `label`, so the coalesced entry reads as the gesture (e.g. "Resize layer")
|
|
41
|
+
// rather than whichever sub-mutation happened to land last (the offset
|
|
42
|
+
// persist's "Move layer").
|
|
43
|
+
const { coalesceKey: _coalesceKey, skipReload: _skipReload, softReload, ...rest } = options;
|
|
44
|
+
return softReload
|
|
45
|
+
? { ...rest, label, softReload: true, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY }
|
|
46
|
+
: { ...rest, label, skipReload: true, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pixelDelta(before: PixelRect, after: PixelRect): PixelRect {
|
|
50
|
+
return {
|
|
51
|
+
x: after.x - before.x,
|
|
52
|
+
y: after.y - before.y,
|
|
53
|
+
width: after.width - before.width,
|
|
54
|
+
height: after.height - before.height,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function exceedsPixelTolerance(delta: PixelRect): boolean {
|
|
59
|
+
return Object.values(delta).some((value) => Math.abs(value) > 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function roundToOneDecimal(value: number): number {
|
|
63
|
+
return Math.round(value * 10) / 10;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type BufferedCommit = CommitMutationCall & { dispatch: CommitMutation };
|
|
67
|
+
|
|
68
|
+
function mergeTransactionOptions(calls: BufferedCommit[]): CommitMutationOptions {
|
|
69
|
+
const reloadCall = calls.find(({ options }) => options.softReload);
|
|
70
|
+
const source = reloadCall?.options ?? calls.at(-1)?.options;
|
|
71
|
+
if (!source) throw new Error("Cannot merge an empty gesture transaction");
|
|
72
|
+
const { skipReload: _skipReload, instantPatch: _instantPatch, ...options } = source;
|
|
73
|
+
return reloadCall ? { ...options, softReload: true } : { ...options, skipReload: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Dispatch a transaction's buffered commits and return the number of preview
|
|
78
|
+
* reloads that ACTUALLY happened — a batch collapses every buffered softReload
|
|
79
|
+
* into one reload, so reload telemetry reflects the real cost, not the count of
|
|
80
|
+
* softReload requests.
|
|
81
|
+
*/
|
|
82
|
+
function reloadsRequested(calls: BufferedCommit[]): number {
|
|
83
|
+
return calls.filter(({ options }) => options.softReload).length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function dispatchBufferedCommits(calls: BufferedCommit[]): Promise<number> {
|
|
87
|
+
const first = calls[0];
|
|
88
|
+
if (!first) return 0;
|
|
89
|
+
if (calls.length === 1) {
|
|
90
|
+
await first.dispatch(first.selection, first.mutation, first.options);
|
|
91
|
+
return reloadsRequested(calls);
|
|
92
|
+
}
|
|
93
|
+
const canBatch = calls.every(
|
|
94
|
+
({ dispatch, selection }) =>
|
|
95
|
+
dispatch === first.dispatch && selection.sourceFile === first.selection.sourceFile,
|
|
96
|
+
);
|
|
97
|
+
if (canBatch && first.dispatch.batch) {
|
|
98
|
+
await first.dispatch.batch(
|
|
99
|
+
calls.map(({ selection, mutation, options }) => ({ selection, mutation, options })),
|
|
100
|
+
mergeTransactionOptions(calls),
|
|
101
|
+
);
|
|
102
|
+
// One server write, one reload — the reload count collapses with the batch.
|
|
103
|
+
return reloadsRequested(calls) > 0 ? 1 : 0;
|
|
104
|
+
}
|
|
105
|
+
for (const { dispatch, selection, mutation, options } of calls) {
|
|
106
|
+
await dispatch(selection, mutation, options);
|
|
107
|
+
}
|
|
108
|
+
return reloadsRequested(calls);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Dev-only [hf-commit] lifecycle trace. The production observability lives in
|
|
113
|
+
* the trackStudioEvent commit_* events (always on); these console lines are a
|
|
114
|
+
* developer aid and stay out of end users' consoles.
|
|
115
|
+
*/
|
|
116
|
+
function traceCommit(stage: string, data: Record<string, unknown>): void {
|
|
117
|
+
if (import.meta.env.DEV) console.info(`[hf-commit] ${stage}`, data);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Owns the visual + persistence + history lifecycle for one gesture release.
|
|
122
|
+
* `settle` deliberately runs before the first promise is created or awaited.
|
|
123
|
+
*/
|
|
124
|
+
export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
|
|
125
|
+
const startedAt = performance.now();
|
|
126
|
+
const coalesceKey = `tx:${tx.label}:${++transactionCounter}`;
|
|
127
|
+
let mutationCount = 0;
|
|
128
|
+
let reloadCount = 0;
|
|
129
|
+
const bufferedCommits: BufferedCommit[] = [];
|
|
130
|
+
traceCommit("start", { label: tx.label, coalesceKey });
|
|
131
|
+
tx.settle();
|
|
132
|
+
traceCommit("settled", { label: tx.label, coalesceKey });
|
|
133
|
+
|
|
134
|
+
const before = !tx.skipPixelAssert ? readPixelRect(tx.element) : null;
|
|
135
|
+
const commit: TxCommit = (commitMutation) => {
|
|
136
|
+
const wrapped: CommitMutation = (selection, mutation, options) => {
|
|
137
|
+
mutationCount += 1;
|
|
138
|
+
bufferedCommits.push({
|
|
139
|
+
dispatch: commitMutation,
|
|
140
|
+
selection,
|
|
141
|
+
mutation,
|
|
142
|
+
options: transactionOptions(options, coalesceKey, tx.label),
|
|
143
|
+
});
|
|
144
|
+
return Promise.resolve();
|
|
145
|
+
};
|
|
146
|
+
transactionCommits.add(wrapped);
|
|
147
|
+
return wrapped;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return tx
|
|
151
|
+
.persist(commit)
|
|
152
|
+
.then(async () => {
|
|
153
|
+
reloadCount = await dispatchBufferedCommits(bufferedCommits);
|
|
154
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
155
|
+
traceCommit("persisted", { label: tx.label, coalesceKey });
|
|
156
|
+
if (before) {
|
|
157
|
+
const after = readPixelRect(tx.element);
|
|
158
|
+
const delta = pixelDelta(before, after);
|
|
159
|
+
if (exceedsPixelTolerance(delta)) {
|
|
160
|
+
if (import.meta.env.DEV) {
|
|
161
|
+
console.error("[hf-commit] persist changed pixels", {
|
|
162
|
+
label: tx.label,
|
|
163
|
+
before,
|
|
164
|
+
after,
|
|
165
|
+
delta,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
trackStudioEvent("commit_invariant_violation", {
|
|
169
|
+
label: tx.label,
|
|
170
|
+
delta_x: roundToOneDecimal(delta.x),
|
|
171
|
+
delta_y: roundToOneDecimal(delta.y),
|
|
172
|
+
delta_w: roundToOneDecimal(delta.width),
|
|
173
|
+
delta_h: roundToOneDecimal(delta.height),
|
|
174
|
+
mutation_count: mutationCount,
|
|
175
|
+
reload_count: reloadCount,
|
|
176
|
+
duration_ms: durationMs,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
trackStudioEvent("commit_transaction", {
|
|
181
|
+
label: tx.label,
|
|
182
|
+
mutation_count: mutationCount,
|
|
183
|
+
reload_count: reloadCount,
|
|
184
|
+
duration_ms: durationMs,
|
|
185
|
+
pixel_asserted: before !== null,
|
|
186
|
+
});
|
|
187
|
+
})
|
|
188
|
+
.catch((error: unknown) => {
|
|
189
|
+
tx.restore();
|
|
190
|
+
trackStudioEvent("commit_transaction_failed", {
|
|
191
|
+
label: tx.label,
|
|
192
|
+
mutation_count: mutationCount,
|
|
193
|
+
error_name: error instanceof Error ? error.name : "unknown",
|
|
194
|
+
restore_ran: true,
|
|
195
|
+
});
|
|
196
|
+
traceCommit("restore", { label: tx.label, coalesceKey });
|
|
197
|
+
throw error;
|
|
198
|
+
});
|
|
199
|
+
}
|