@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
|
@@ -77,6 +77,21 @@ function timelineElement(input: {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** Mount a harness component under act() and return its unmount hook. */
|
|
81
|
+
function mountHarness(node: React.ReactElement): { unmount: () => void } {
|
|
82
|
+
const host = document.createElement("div");
|
|
83
|
+
document.body.append(host);
|
|
84
|
+
const root = createRoot(host);
|
|
85
|
+
act(() => {
|
|
86
|
+
root.render(node);
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
unmount: () => {
|
|
90
|
+
act(() => root.unmount());
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
function renderTimelineEditingHook(input: {
|
|
81
96
|
timelineElements: TimelineElement[];
|
|
82
97
|
iframe: HTMLIFrameElement;
|
|
@@ -92,17 +107,20 @@ function renderTimelineEditingHook(input: {
|
|
|
92
107
|
reloadPreview?: () => void;
|
|
93
108
|
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
|
|
94
109
|
forceReloadSdkSession?: () => void;
|
|
110
|
+
showToast?: (message: string, kind?: string) => void;
|
|
95
111
|
}): {
|
|
96
112
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
97
113
|
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
|
|
98
114
|
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
|
|
99
115
|
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
|
|
116
|
+
del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
|
|
100
117
|
unmount: () => void;
|
|
101
118
|
} {
|
|
102
119
|
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
|
103
120
|
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
|
|
104
121
|
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
|
|
105
122
|
let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
|
|
123
|
+
let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
|
|
106
124
|
|
|
107
125
|
function Harness() {
|
|
108
126
|
const commitRef = useRef(input.onZIndexCommit);
|
|
@@ -111,7 +129,7 @@ function renderTimelineEditingHook(input: {
|
|
|
111
129
|
projectId: input.projectId ?? null,
|
|
112
130
|
activeCompPath: "index.html",
|
|
113
131
|
timelineElements: input.timelineElements,
|
|
114
|
-
showToast: vi.fn(),
|
|
132
|
+
showToast: input.showToast ?? vi.fn(),
|
|
115
133
|
writeProjectFile: input.writeProjectFile ?? vi.fn(),
|
|
116
134
|
recordEdit: input.recordEdit ?? vi.fn(),
|
|
117
135
|
domEditSaveTimestampRef: { current: 0 },
|
|
@@ -127,29 +145,17 @@ function renderTimelineEditingHook(input: {
|
|
|
127
145
|
resize = hook.handleTimelineElementResize;
|
|
128
146
|
groupMove = hook.handleTimelineGroupMove;
|
|
129
147
|
groupResize = hook.handleTimelineGroupResize;
|
|
148
|
+
del = hook.handleTimelineElementDelete;
|
|
130
149
|
return null;
|
|
131
150
|
}
|
|
132
151
|
|
|
133
|
-
const
|
|
134
|
-
document.body.append(host);
|
|
135
|
-
const root = createRoot(host);
|
|
136
|
-
act(() => {
|
|
137
|
-
root.render(<Harness />);
|
|
138
|
-
});
|
|
139
|
-
|
|
152
|
+
const { unmount } = mountHarness(<Harness />);
|
|
140
153
|
if (!move) throw new Error("Expected hook to expose move handler");
|
|
141
154
|
if (!resize) throw new Error("Expected hook to expose resize handler");
|
|
142
155
|
if (!groupMove) throw new Error("Expected hook to expose group move handler");
|
|
143
156
|
if (!groupResize) throw new Error("Expected hook to expose group resize handler");
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
resize,
|
|
147
|
-
groupMove,
|
|
148
|
-
groupResize,
|
|
149
|
-
unmount: () => {
|
|
150
|
-
act(() => root.unmount());
|
|
151
|
-
},
|
|
152
|
-
};
|
|
157
|
+
if (!del) throw new Error("Expected hook to expose delete handler");
|
|
158
|
+
return { move, resize, groupMove, groupResize, del, unmount };
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
type TimelineRecordEdit = NonNullable<
|
|
@@ -159,7 +165,7 @@ type TimelineRecordEdit = NonNullable<
|
|
|
159
165
|
function renderTimelineEditingHookWithLifecycle(input: {
|
|
160
166
|
timelineElements: TimelineElement[];
|
|
161
167
|
iframe: HTMLIFrameElement;
|
|
162
|
-
|
|
168
|
+
commitDomEditPatchBatches: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
|
|
163
169
|
}): {
|
|
164
170
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
165
171
|
unmount: () => void;
|
|
@@ -176,7 +182,7 @@ function renderTimelineEditingHookWithLifecycle(input: {
|
|
|
176
182
|
projectIdRef: { current: "p1" },
|
|
177
183
|
reloadPreview: vi.fn(),
|
|
178
184
|
clearDomSelection: vi.fn(),
|
|
179
|
-
|
|
185
|
+
commitDomEditPatchBatches: input.commitDomEditPatchBatches,
|
|
180
186
|
});
|
|
181
187
|
const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
|
|
182
188
|
commitRef.current = lifecycle.handleDomZIndexReorderCommit;
|
|
@@ -198,20 +204,9 @@ function renderTimelineEditingHookWithLifecycle(input: {
|
|
|
198
204
|
return null;
|
|
199
205
|
}
|
|
200
206
|
|
|
201
|
-
const
|
|
202
|
-
document.body.append(host);
|
|
203
|
-
const root = createRoot(host);
|
|
204
|
-
act(() => {
|
|
205
|
-
root.render(<Harness />);
|
|
206
|
-
});
|
|
207
|
-
|
|
207
|
+
const { unmount } = mountHarness(<Harness />);
|
|
208
208
|
if (!move) throw new Error("Expected hook to expose move handler");
|
|
209
|
-
return {
|
|
210
|
-
move,
|
|
211
|
-
unmount: () => {
|
|
212
|
-
act(() => root.unmount());
|
|
213
|
-
},
|
|
214
|
-
};
|
|
209
|
+
return { move, unmount };
|
|
215
210
|
}
|
|
216
211
|
|
|
217
212
|
function jsonResponse(body: unknown): Response {
|
|
@@ -233,137 +228,118 @@ async function flushAsyncWork(): Promise<void> {
|
|
|
233
228
|
}
|
|
234
229
|
}
|
|
235
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Stub global fetch for project "p1": serves file contents (a single source
|
|
233
|
+
* string, or a path → content map) and answers the GSAP-mutation endpoint
|
|
234
|
+
* with `gsapBody`. Returns the mock for call inspection.
|
|
235
|
+
*/
|
|
236
|
+
function stubProjectFetch(
|
|
237
|
+
files: string | Record<string, string>,
|
|
238
|
+
gsapBody: unknown = { ok: true },
|
|
239
|
+
) {
|
|
240
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
241
|
+
const url = requestUrl(input);
|
|
242
|
+
if (url.includes("/api/projects/p1/files/")) {
|
|
243
|
+
if (typeof files === "string") return jsonResponse({ content: files });
|
|
244
|
+
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
|
245
|
+
return jsonResponse({ content: files[path] });
|
|
246
|
+
}
|
|
247
|
+
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse(gsapBody);
|
|
248
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
249
|
+
});
|
|
250
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
251
|
+
return fetchMock;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ROOT_DURATION_FALLBACK_SOURCE = [
|
|
255
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
256
|
+
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
|
257
|
+
`</div>`,
|
|
258
|
+
].join("\n");
|
|
259
|
+
|
|
260
|
+
/** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */
|
|
261
|
+
async function setupRootDurationFallback() {
|
|
262
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
263
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
264
|
+
const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE);
|
|
265
|
+
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
|
266
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
267
|
+
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
|
268
|
+
const forceReloadSdkSession = vi.fn();
|
|
269
|
+
const reloadPreview = vi.fn();
|
|
270
|
+
const iframeWindow = iframe.contentWindow;
|
|
271
|
+
if (!iframeWindow) throw new Error("Expected iframe window");
|
|
272
|
+
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
|
273
|
+
stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { ok: true, mutated: false });
|
|
274
|
+
usePlayerStore.getState().setDuration(4);
|
|
275
|
+
const hook = renderTimelineEditingHook({
|
|
276
|
+
timelineElements: [clip],
|
|
277
|
+
iframe,
|
|
278
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
279
|
+
projectId: "p1",
|
|
280
|
+
writeProjectFile,
|
|
281
|
+
recordEdit,
|
|
282
|
+
sdkSession,
|
|
283
|
+
forceReloadSdkSession,
|
|
284
|
+
reloadPreview,
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
hook,
|
|
288
|
+
clip,
|
|
289
|
+
setTimingSpy,
|
|
290
|
+
writeProjectFile,
|
|
291
|
+
forceReloadSdkSession,
|
|
292
|
+
reloadPreview,
|
|
293
|
+
postMessageSpy,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */
|
|
298
|
+
function expectRootDurationExtendedViaFallback(
|
|
299
|
+
ctx: Awaited<ReturnType<typeof setupRootDurationFallback>>,
|
|
300
|
+
): void {
|
|
301
|
+
expect(ctx.setTimingSpy).not.toHaveBeenCalled();
|
|
302
|
+
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain(
|
|
303
|
+
'data-composition-id="main" data-duration="5"',
|
|
304
|
+
);
|
|
305
|
+
expect(usePlayerStore.getState().duration).toBe(5);
|
|
306
|
+
expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
307
|
+
// The GSAP endpoint returned no rewritten scriptText, so the timing sync
|
|
308
|
+
// escalates from the flash-free soft reload to ONE full reload. The root
|
|
309
|
+
// duration travels via the persisted content-driven `data-duration` (above),
|
|
310
|
+
// not a `set-root-duration` postMessage.
|
|
311
|
+
expect(ctx.reloadPreview).toHaveBeenCalledTimes(1);
|
|
312
|
+
expect(ctx.postMessageSpy).not.toHaveBeenCalledWith(
|
|
313
|
+
expect.objectContaining({ action: "set-root-duration" }),
|
|
314
|
+
"*",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
236
318
|
describe("useTimelineEditing timeline z-index reorder", () => {
|
|
237
319
|
it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => {
|
|
238
|
-
const
|
|
239
|
-
`<div data-composition-id="main" data-duration="4">`,
|
|
240
|
-
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
|
241
|
-
`</div>`,
|
|
242
|
-
].join("\n");
|
|
243
|
-
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
244
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
245
|
-
const sdkSession = await openComposition(source);
|
|
246
|
-
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
|
247
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
248
|
-
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
|
249
|
-
const forceReloadSdkSession = vi.fn();
|
|
250
|
-
const reloadPreview = vi.fn();
|
|
251
|
-
const iframeWindow = iframe.contentWindow;
|
|
252
|
-
if (!iframeWindow) throw new Error("Expected iframe window");
|
|
253
|
-
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
|
254
|
-
vi.stubGlobal(
|
|
255
|
-
"fetch",
|
|
256
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
257
|
-
const url = requestUrl(input);
|
|
258
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
259
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
260
|
-
return jsonResponse({ ok: true, mutated: false });
|
|
261
|
-
}
|
|
262
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
263
|
-
}),
|
|
264
|
-
);
|
|
265
|
-
usePlayerStore.getState().setDuration(4);
|
|
266
|
-
const { move, unmount } = renderTimelineEditingHook({
|
|
267
|
-
timelineElements: [clip],
|
|
268
|
-
iframe,
|
|
269
|
-
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
270
|
-
projectId: "p1",
|
|
271
|
-
writeProjectFile,
|
|
272
|
-
recordEdit,
|
|
273
|
-
sdkSession,
|
|
274
|
-
forceReloadSdkSession,
|
|
275
|
-
reloadPreview,
|
|
276
|
-
});
|
|
320
|
+
const ctx = await setupRootDurationFallback();
|
|
277
321
|
|
|
278
322
|
await act(async () => {
|
|
279
|
-
await move(clip, { start: 3, track: clip.track });
|
|
323
|
+
await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track });
|
|
280
324
|
});
|
|
281
325
|
|
|
282
|
-
expect(
|
|
283
|
-
|
|
284
|
-
'data-composition-id="main" data-duration="5"',
|
|
285
|
-
);
|
|
286
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
|
|
287
|
-
expect(usePlayerStore.getState().duration).toBe(5);
|
|
288
|
-
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
289
|
-
expect(reloadPreview).not.toHaveBeenCalled();
|
|
290
|
-
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
291
|
-
{
|
|
292
|
-
source: "hf-parent",
|
|
293
|
-
type: "control",
|
|
294
|
-
action: "set-root-duration",
|
|
295
|
-
durationSeconds: 5,
|
|
296
|
-
},
|
|
297
|
-
"*",
|
|
298
|
-
);
|
|
326
|
+
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
|
|
327
|
+
expectRootDurationExtendedViaFallback(ctx);
|
|
299
328
|
|
|
300
|
-
unmount();
|
|
329
|
+
ctx.hook.unmount();
|
|
301
330
|
});
|
|
302
331
|
|
|
303
332
|
it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
|
|
304
|
-
const
|
|
305
|
-
`<div data-composition-id="main" data-duration="4">`,
|
|
306
|
-
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
|
307
|
-
`</div>`,
|
|
308
|
-
].join("\n");
|
|
309
|
-
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
310
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
311
|
-
const sdkSession = await openComposition(source);
|
|
312
|
-
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
|
313
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
314
|
-
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
|
315
|
-
const forceReloadSdkSession = vi.fn();
|
|
316
|
-
const reloadPreview = vi.fn();
|
|
317
|
-
const iframeWindow = iframe.contentWindow;
|
|
318
|
-
if (!iframeWindow) throw new Error("Expected iframe window");
|
|
319
|
-
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
|
320
|
-
vi.stubGlobal(
|
|
321
|
-
"fetch",
|
|
322
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
323
|
-
const url = requestUrl(input);
|
|
324
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
325
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
326
|
-
return jsonResponse({ ok: true, mutated: false });
|
|
327
|
-
}
|
|
328
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
329
|
-
}),
|
|
330
|
-
);
|
|
331
|
-
usePlayerStore.getState().setDuration(4);
|
|
332
|
-
const { resize, unmount } = renderTimelineEditingHook({
|
|
333
|
-
timelineElements: [clip],
|
|
334
|
-
iframe,
|
|
335
|
-
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
336
|
-
projectId: "p1",
|
|
337
|
-
writeProjectFile,
|
|
338
|
-
recordEdit,
|
|
339
|
-
sdkSession,
|
|
340
|
-
forceReloadSdkSession,
|
|
341
|
-
reloadPreview,
|
|
342
|
-
});
|
|
333
|
+
const ctx = await setupRootDurationFallback();
|
|
343
334
|
|
|
344
335
|
await act(async () => {
|
|
345
|
-
await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
|
|
336
|
+
await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined });
|
|
346
337
|
});
|
|
347
338
|
|
|
348
|
-
expect(
|
|
349
|
-
|
|
350
|
-
'data-composition-id="main" data-duration="5"',
|
|
351
|
-
);
|
|
352
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
|
|
353
|
-
expect(usePlayerStore.getState().duration).toBe(5);
|
|
354
|
-
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
355
|
-
expect(reloadPreview).not.toHaveBeenCalled();
|
|
356
|
-
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
357
|
-
{
|
|
358
|
-
source: "hf-parent",
|
|
359
|
-
type: "control",
|
|
360
|
-
action: "set-root-duration",
|
|
361
|
-
durationSeconds: 5,
|
|
362
|
-
},
|
|
363
|
-
"*",
|
|
364
|
-
);
|
|
339
|
+
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
|
|
340
|
+
expectRootDurationExtendedViaFallback(ctx);
|
|
365
341
|
|
|
366
|
-
unmount();
|
|
342
|
+
ctx.hook.unmount();
|
|
367
343
|
});
|
|
368
344
|
|
|
369
345
|
it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
|
|
@@ -481,11 +457,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
481
457
|
]);
|
|
482
458
|
const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
|
|
483
459
|
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
|
|
484
|
-
const
|
|
460
|
+
const commitDomEditPatchBatches = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
485
461
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
486
462
|
timelineElements: [front, back],
|
|
487
463
|
iframe,
|
|
488
|
-
|
|
464
|
+
commitDomEditPatchBatches,
|
|
489
465
|
});
|
|
490
466
|
|
|
491
467
|
await act(async () => {
|
|
@@ -501,8 +477,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
501
477
|
await flushAsyncWork();
|
|
502
478
|
});
|
|
503
479
|
|
|
504
|
-
expect(
|
|
505
|
-
|
|
480
|
+
expect(commitDomEditPatchBatches).toHaveBeenCalled();
|
|
481
|
+
const batch = commitDomEditPatchBatches.mock.calls[0]![0] as Array<{
|
|
482
|
+
patches: Array<{ operations: unknown[] }>;
|
|
483
|
+
}>;
|
|
484
|
+
expect(batch[0]?.patches[0]?.operations).toEqual([
|
|
506
485
|
{ type: "inline-style", property: "z-index", value: "2" },
|
|
507
486
|
{ type: "inline-style", property: "position", value: "relative" },
|
|
508
487
|
]);
|
|
@@ -522,14 +501,13 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
522
501
|
{ ...back, hasExplicitZIndex: false },
|
|
523
502
|
]);
|
|
524
503
|
const saveError = new Error("save failed");
|
|
525
|
-
const
|
|
504
|
+
const commitDomEditPatchBatches = vi
|
|
526
505
|
.fn<(...args: unknown[]) => Promise<void>>()
|
|
527
|
-
.mockResolvedValueOnce(undefined)
|
|
528
506
|
.mockRejectedValueOnce(saveError);
|
|
529
507
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
530
508
|
timelineElements: [front, back],
|
|
531
509
|
iframe,
|
|
532
|
-
|
|
510
|
+
commitDomEditPatchBatches,
|
|
533
511
|
});
|
|
534
512
|
const doc = iframe.contentDocument;
|
|
535
513
|
if (!doc) throw new Error("Expected iframe document");
|
|
@@ -576,29 +554,24 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
576
554
|
unmount();
|
|
577
555
|
});
|
|
578
556
|
|
|
579
|
-
it("waits for
|
|
557
|
+
it("waits for the lifecycle z-index batch before resolving a reorder", async () => {
|
|
580
558
|
const iframe = createPreviewIframe([
|
|
581
559
|
{ id: "front", track: 0, style: "position: relative; z-index: 1" },
|
|
582
560
|
{ id: "back", track: 1, style: "position: relative; z-index: 0" },
|
|
583
561
|
]);
|
|
584
562
|
const front = timelineElement({ id: "front", track: 0, zIndex: 1 });
|
|
585
563
|
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
|
|
586
|
-
let
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
releaseFirst = resolve;
|
|
590
|
-
});
|
|
591
|
-
const secondSave = new Promise<void>((resolve) => {
|
|
592
|
-
releaseSecond = resolve;
|
|
564
|
+
let releaseBatch!: () => void;
|
|
565
|
+
const batchSave = new Promise<void>((resolve) => {
|
|
566
|
+
releaseBatch = resolve;
|
|
593
567
|
});
|
|
594
|
-
const
|
|
568
|
+
const commitDomEditPatchBatches = vi
|
|
595
569
|
.fn<(...args: unknown[]) => Promise<void>>()
|
|
596
|
-
.mockReturnValueOnce(
|
|
597
|
-
.mockReturnValueOnce(secondSave);
|
|
570
|
+
.mockReturnValueOnce(batchSave);
|
|
598
571
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
599
572
|
timelineElements: [front, back],
|
|
600
573
|
iframe,
|
|
601
|
-
|
|
574
|
+
commitDomEditPatchBatches,
|
|
602
575
|
});
|
|
603
576
|
let settled = false;
|
|
604
577
|
|
|
@@ -621,17 +594,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
621
594
|
await flushAsyncWork();
|
|
622
595
|
});
|
|
623
596
|
|
|
624
|
-
expect(
|
|
597
|
+
expect(commitDomEditPatchBatches).toHaveBeenCalledTimes(1);
|
|
625
598
|
expect(settled).toBe(false);
|
|
626
599
|
|
|
627
600
|
await act(async () => {
|
|
628
|
-
|
|
629
|
-
await flushAsyncWork();
|
|
630
|
-
});
|
|
631
|
-
expect(settled).toBe(false);
|
|
632
|
-
|
|
633
|
-
await act(async () => {
|
|
634
|
-
releaseSecond();
|
|
601
|
+
releaseBatch();
|
|
635
602
|
await movePromise;
|
|
636
603
|
await flushAsyncWork();
|
|
637
604
|
});
|
|
@@ -647,24 +614,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
647
614
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
648
615
|
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
649
616
|
const reloadPreview = vi.fn();
|
|
650
|
-
const fetchMock =
|
|
651
|
-
async (
|
|
652
|
-
input: Parameters<typeof fetch>[0],
|
|
653
|
-
_init?: Parameters<typeof fetch>[1],
|
|
654
|
-
): Promise<Response> => {
|
|
655
|
-
const url = requestUrl(input);
|
|
656
|
-
if (url.includes("/api/projects/p1/files/")) {
|
|
657
|
-
return jsonResponse({
|
|
658
|
-
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
662
|
-
return jsonResponse({ ok: true });
|
|
663
|
-
}
|
|
664
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
665
|
-
},
|
|
666
|
-
);
|
|
667
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
617
|
+
const fetchMock = stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
|
668
618
|
const { move, unmount } = renderTimelineEditingHook({
|
|
669
619
|
timelineElements: [clip],
|
|
670
620
|
iframe,
|
|
@@ -694,6 +644,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
694
644
|
unmount();
|
|
695
645
|
});
|
|
696
646
|
|
|
647
|
+
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
|
648
|
+
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
|
649
|
+
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
|
650
|
+
// wrote NOTHING — the lane snapped back on the next reload.
|
|
651
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
652
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
653
|
+
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
|
654
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
655
|
+
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
|
656
|
+
const { move, unmount } = renderTimelineEditingHook({
|
|
657
|
+
timelineElements: [clip],
|
|
658
|
+
iframe,
|
|
659
|
+
onZIndexCommit: commit,
|
|
660
|
+
projectId: "p1",
|
|
661
|
+
writeProjectFile,
|
|
662
|
+
recordEdit: vi.fn(async () => {}),
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
await act(async () => {
|
|
666
|
+
// Vertical-only: same start, new track (already authored-space on this path).
|
|
667
|
+
await move(clip, { start: clip.start, track: 2 });
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const doc = iframe.contentDocument;
|
|
671
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
672
|
+
// Live DOM patched so a pre-reload re-discovery doesn't snap the lane back...
|
|
673
|
+
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("2");
|
|
674
|
+
// ...and the file write carries the new data-track-index with start intact.
|
|
675
|
+
expect(writeProjectFile).toHaveBeenCalled();
|
|
676
|
+
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="2"');
|
|
677
|
+
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
|
|
678
|
+
|
|
679
|
+
unmount();
|
|
680
|
+
});
|
|
681
|
+
|
|
697
682
|
it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
|
|
698
683
|
const iframe = createPreviewIframe([
|
|
699
684
|
{ id: "clip", track: 0, style: "position: relative; z-index: 0" },
|
|
@@ -706,17 +691,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
706
691
|
});
|
|
707
692
|
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
|
|
708
693
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
709
|
-
|
|
710
|
-
const url = requestUrl(input);
|
|
711
|
-
if (url.includes("/api/projects/p1/files/")) {
|
|
712
|
-
return jsonResponse({
|
|
713
|
-
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
717
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
718
|
-
});
|
|
719
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
694
|
+
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
|
720
695
|
const { move, unmount } = renderTimelineEditingHook({
|
|
721
696
|
timelineElements: [clip],
|
|
722
697
|
iframe,
|
|
@@ -775,15 +750,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
775
750
|
];
|
|
776
751
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
777
752
|
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
778
|
-
|
|
779
|
-
"fetch",
|
|
780
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
781
|
-
const url = requestUrl(input);
|
|
782
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
783
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
784
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
785
|
-
}),
|
|
786
|
-
);
|
|
753
|
+
stubProjectFetch(source);
|
|
787
754
|
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
788
755
|
timelineElements: clips,
|
|
789
756
|
iframe,
|
|
@@ -832,18 +799,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
832
799
|
});
|
|
833
800
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
834
801
|
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
835
|
-
|
|
836
|
-
"fetch",
|
|
837
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
838
|
-
const url = requestUrl(input);
|
|
839
|
-
if (url.includes("/api/projects/p1/files/")) {
|
|
840
|
-
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
|
841
|
-
return jsonResponse({ content: files[path] });
|
|
842
|
-
}
|
|
843
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
844
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
845
|
-
}),
|
|
846
|
-
);
|
|
802
|
+
stubProjectFetch(files);
|
|
847
803
|
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
848
804
|
timelineElements: [a, b],
|
|
849
805
|
iframe,
|
|
@@ -884,15 +840,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
884
840
|
releaseCommit = resolve;
|
|
885
841
|
});
|
|
886
842
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
887
|
-
|
|
888
|
-
"fetch",
|
|
889
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
890
|
-
const url = requestUrl(input);
|
|
891
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
892
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
893
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
894
|
-
}),
|
|
895
|
-
);
|
|
843
|
+
stubProjectFetch(source);
|
|
896
844
|
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
897
845
|
timelineElements: [clip],
|
|
898
846
|
iframe,
|
|
@@ -922,13 +870,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
922
870
|
it("matches the single-clip move output when a group move contains one clip", async () => {
|
|
923
871
|
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
|
|
924
872
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
925
|
-
|
|
926
|
-
const url = requestUrl(input);
|
|
927
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
928
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
929
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
930
|
-
});
|
|
931
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
873
|
+
stubProjectFetch(source);
|
|
932
874
|
|
|
933
875
|
const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
934
876
|
const single = renderTimelineEditingHook({
|
|
@@ -961,3 +903,218 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
961
903
|
group.unmount();
|
|
962
904
|
});
|
|
963
905
|
});
|
|
906
|
+
|
|
907
|
+
describe("useTimelineEditing duration rollback on failed persist", () => {
|
|
908
|
+
const ROLLBACK_SOURCE = [
|
|
909
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
910
|
+
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
|
911
|
+
`</div>`,
|
|
912
|
+
].join("\n");
|
|
913
|
+
|
|
914
|
+
/** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */
|
|
915
|
+
function createRootedIframe(source: string = ROLLBACK_SOURCE): HTMLIFrameElement {
|
|
916
|
+
const iframe = document.createElement("iframe");
|
|
917
|
+
document.body.append(iframe);
|
|
918
|
+
const doc = iframe.contentDocument;
|
|
919
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
920
|
+
doc.body.innerHTML = source;
|
|
921
|
+
return iframe;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined {
|
|
925
|
+
return iframe.contentDocument
|
|
926
|
+
?.querySelector("[data-composition-id]")
|
|
927
|
+
?.getAttribute("data-duration");
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function setupFailedPersist() {
|
|
931
|
+
const iframe = createRootedIframe();
|
|
932
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
933
|
+
const writeError = new Error("write failed");
|
|
934
|
+
const writeProjectFile = vi
|
|
935
|
+
.fn<(...args: unknown[]) => Promise<void>>()
|
|
936
|
+
.mockRejectedValue(writeError);
|
|
937
|
+
stubProjectFetch(ROLLBACK_SOURCE);
|
|
938
|
+
usePlayerStore.getState().setDuration(4);
|
|
939
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
940
|
+
const hook = renderTimelineEditingHook({
|
|
941
|
+
timelineElements: [clip],
|
|
942
|
+
iframe,
|
|
943
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
944
|
+
projectId: "p1",
|
|
945
|
+
writeProjectFile,
|
|
946
|
+
recordEdit: vi.fn(async () => {}),
|
|
947
|
+
reloadPreview: vi.fn(),
|
|
948
|
+
});
|
|
949
|
+
return { iframe, clip, hook, writeError };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
it("rolls back the store duration and live root when a move persist fails", async () => {
|
|
953
|
+
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
|
954
|
+
|
|
955
|
+
let rejection: unknown;
|
|
956
|
+
await act(async () => {
|
|
957
|
+
// Move past the end: the optimistic sync grows the readout to 5s.
|
|
958
|
+
await hook.move(clip, { start: 3, track: clip.track }).catch((error) => {
|
|
959
|
+
rejection = error;
|
|
960
|
+
});
|
|
961
|
+
await flushAsyncWork();
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
expect(rejection).toBe(writeError);
|
|
965
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
966
|
+
expect(rootDurationAttr(iframe)).toBe("4");
|
|
967
|
+
|
|
968
|
+
hook.unmount();
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("rolls back the store duration and live root when a resize persist fails", async () => {
|
|
972
|
+
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
|
973
|
+
|
|
974
|
+
let rejection: unknown;
|
|
975
|
+
await act(async () => {
|
|
976
|
+
await hook
|
|
977
|
+
.resize(clip, { start: 0, duration: 6, playbackStart: undefined })
|
|
978
|
+
.catch((error) => {
|
|
979
|
+
rejection = error;
|
|
980
|
+
});
|
|
981
|
+
await flushAsyncWork();
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
expect(rejection).toBe(writeError);
|
|
985
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
986
|
+
expect(rootDurationAttr(iframe)).toBe("4");
|
|
987
|
+
|
|
988
|
+
hook.unmount();
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it("rolls back the store duration and live root when a group move persist fails", async () => {
|
|
992
|
+
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
|
993
|
+
|
|
994
|
+
let rejection: unknown;
|
|
995
|
+
await act(async () => {
|
|
996
|
+
await hook.groupMove([{ element: clip, start: 3.5 }]).catch((error) => {
|
|
997
|
+
rejection = error;
|
|
998
|
+
});
|
|
999
|
+
await flushAsyncWork();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
expect(rejection).toBe(writeError);
|
|
1003
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
1004
|
+
expect(rootDurationAttr(iframe)).toBe("4");
|
|
1005
|
+
|
|
1006
|
+
hook.unmount();
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it("rolls back the store duration and live root when a group resize persist fails", async () => {
|
|
1010
|
+
const { iframe, clip, hook, writeError } = setupFailedPersist();
|
|
1011
|
+
|
|
1012
|
+
let rejection: unknown;
|
|
1013
|
+
await act(async () => {
|
|
1014
|
+
await hook.groupResize([{ element: clip, start: 0, duration: 7 }]).catch((error) => {
|
|
1015
|
+
rejection = error;
|
|
1016
|
+
});
|
|
1017
|
+
await flushAsyncWork();
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
expect(rejection).toBe(writeError);
|
|
1021
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
1022
|
+
expect(rootDurationAttr(iframe)).toBe("4");
|
|
1023
|
+
|
|
1024
|
+
hook.unmount();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("rolls back the store duration and live root when a delete persist fails", async () => {
|
|
1028
|
+
// Two clips; deleting the furthest one shrinks the content-driven duration
|
|
1029
|
+
// optimistically (4s -> 2s), so a failed write must roll that shrink back.
|
|
1030
|
+
const DELETE_SOURCE = [
|
|
1031
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
1032
|
+
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
|
1033
|
+
` <div id="tail" data-start="2" data-duration="2" data-track-index="0"></div>`,
|
|
1034
|
+
`</div>`,
|
|
1035
|
+
].join("\n");
|
|
1036
|
+
const DELETE_REMOVED_SOURCE = [
|
|
1037
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
1038
|
+
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
|
1039
|
+
`</div>`,
|
|
1040
|
+
].join("\n");
|
|
1041
|
+
|
|
1042
|
+
const iframe = createRootedIframe(DELETE_SOURCE);
|
|
1043
|
+
const tail = timelineElement({ id: "tail", track: 0, zIndex: 0, start: 2, duration: 2 });
|
|
1044
|
+
const writeError = new Error("write failed");
|
|
1045
|
+
const writeProjectFile = vi
|
|
1046
|
+
.fn<(...args: unknown[]) => Promise<void>>()
|
|
1047
|
+
.mockRejectedValue(writeError);
|
|
1048
|
+
// The delete path reads the file, then asks the server-side remove-element
|
|
1049
|
+
// mutation for the post-removal source before persisting it.
|
|
1050
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
1051
|
+
const url = requestUrl(input);
|
|
1052
|
+
if (url.includes("/api/projects/p1/files/")) {
|
|
1053
|
+
return jsonResponse({ content: DELETE_SOURCE });
|
|
1054
|
+
}
|
|
1055
|
+
if (url.includes("/api/projects/p1/file-mutations/remove-element/")) {
|
|
1056
|
+
return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
|
|
1057
|
+
}
|
|
1058
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
1059
|
+
});
|
|
1060
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1061
|
+
usePlayerStore.getState().setDuration(4);
|
|
1062
|
+
const showToast = vi.fn();
|
|
1063
|
+
const hook = renderTimelineEditingHook({
|
|
1064
|
+
timelineElements: [tail],
|
|
1065
|
+
iframe,
|
|
1066
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1067
|
+
projectId: "p1",
|
|
1068
|
+
writeProjectFile,
|
|
1069
|
+
recordEdit: vi.fn(async () => {}),
|
|
1070
|
+
reloadPreview: vi.fn(),
|
|
1071
|
+
showToast,
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
await act(async () => {
|
|
1075
|
+
// Unlike move/resize, the delete handler swallows the persist failure
|
|
1076
|
+
// into a toast, so the promise resolves.
|
|
1077
|
+
await hook.del(tail);
|
|
1078
|
+
await flushAsyncWork();
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// The optimistic shrink reached the persist attempt (root patched to the
|
|
1082
|
+
// furthest remaining clip end, 2s)...
|
|
1083
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
1084
|
+
expect(String(writeProjectFile.mock.calls[0]![1])).toContain(
|
|
1085
|
+
'data-composition-id="main" data-duration="2"',
|
|
1086
|
+
);
|
|
1087
|
+
expect(showToast).toHaveBeenCalledWith("write failed");
|
|
1088
|
+
// ...and the failed write rolled the readout AND the live root back.
|
|
1089
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
1090
|
+
expect(rootDurationAttr(iframe)).toBe("4");
|
|
1091
|
+
|
|
1092
|
+
hook.unmount();
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it("keeps the grown duration when the persist succeeds", async () => {
|
|
1096
|
+
const { iframe, clip, hook } = setupFailedPersist();
|
|
1097
|
+
// Same harness, but with a write that succeeds this time.
|
|
1098
|
+
hook.unmount();
|
|
1099
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1100
|
+
const succeeding = renderTimelineEditingHook({
|
|
1101
|
+
timelineElements: [clip],
|
|
1102
|
+
iframe,
|
|
1103
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1104
|
+
projectId: "p1",
|
|
1105
|
+
writeProjectFile,
|
|
1106
|
+
recordEdit: vi.fn(async () => {}),
|
|
1107
|
+
reloadPreview: vi.fn(),
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
await act(async () => {
|
|
1111
|
+
await succeeding.move(clip, { start: 3, track: clip.track });
|
|
1112
|
+
await flushAsyncWork();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
expect(usePlayerStore.getState().duration).toBe(5);
|
|
1116
|
+
expect(rootDurationAttr(iframe)).toBe("5");
|
|
1117
|
+
|
|
1118
|
+
succeeding.unmount();
|
|
1119
|
+
});
|
|
1120
|
+
});
|