@hyperframes/studio 0.7.54 → 0.7.56
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/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.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 +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- 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 +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- 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 +158 -126
- 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 +43 -1
- 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 +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- 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/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/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/nle/AssetPreviewOverlay.tsx +147 -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 +240 -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.tsx +326 -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 +39 -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 +12 -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 +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -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/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -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 +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- 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 +303 -0
- 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/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- 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/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- 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/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 +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -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 +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- 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 +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- 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/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- 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 +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- 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/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- 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,943 @@
|
|
|
1
|
+
import { describe, expect, it, vi, type Mock } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
4
|
+
import {
|
|
5
|
+
commitDraggedClipMove,
|
|
6
|
+
type DragCommitDeps,
|
|
7
|
+
type TimelineMoveEdit,
|
|
8
|
+
} from "./timelineClipDragCommit";
|
|
9
|
+
import {
|
|
10
|
+
buildEditHistoryEntry,
|
|
11
|
+
createEmptyEditHistory,
|
|
12
|
+
pushEditHistoryEntry,
|
|
13
|
+
} from "../../utils/editHistory";
|
|
14
|
+
import { normalizeToZones } from "./timelineZones";
|
|
15
|
+
import type { StackingPatch } from "./timelineStackingSync";
|
|
16
|
+
|
|
17
|
+
function el(
|
|
18
|
+
id: string,
|
|
19
|
+
track: number,
|
|
20
|
+
start: number,
|
|
21
|
+
duration: number,
|
|
22
|
+
tag = "video",
|
|
23
|
+
): TimelineElement {
|
|
24
|
+
// domId gives the row a patchable target so getTimelineEditCapabilities().canMove
|
|
25
|
+
// is true (the capabilities gate in commitDraggedClipMove filters on it).
|
|
26
|
+
return { id, key: id, tag, start, duration, track, domId: id };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Flush the microtask chain: the z-sync now fires only after the move persist
|
|
30
|
+
* promise resolves (serialized), so tests asserting on it must await. */
|
|
31
|
+
async function flushMicrotasks(): Promise<void> {
|
|
32
|
+
await Promise.resolve();
|
|
33
|
+
await Promise.resolve();
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function drag(
|
|
38
|
+
element: TimelineElement,
|
|
39
|
+
opts: {
|
|
40
|
+
previewStart: number;
|
|
41
|
+
previewTrack: number;
|
|
42
|
+
desiredTrack?: number;
|
|
43
|
+
insertRow?: number | null;
|
|
44
|
+
},
|
|
45
|
+
): DraggedClipState {
|
|
46
|
+
return {
|
|
47
|
+
element,
|
|
48
|
+
originClientX: 0,
|
|
49
|
+
originClientY: 0,
|
|
50
|
+
originScrollLeft: 0,
|
|
51
|
+
originScrollTop: 0,
|
|
52
|
+
pointerClientX: 0,
|
|
53
|
+
pointerClientY: 0,
|
|
54
|
+
pointerOffsetX: 0,
|
|
55
|
+
pointerOffsetY: 0,
|
|
56
|
+
previewStart: opts.previewStart,
|
|
57
|
+
previewTrack: opts.previewTrack,
|
|
58
|
+
// Defaults to previewTrack (a lane change) when a test doesn't distinguish a
|
|
59
|
+
// horizontal collision bump — the commit's `desiredTrack ?? previewTrack`.
|
|
60
|
+
desiredTrack: opts.desiredTrack,
|
|
61
|
+
insertRow: opts.insertRow ?? null,
|
|
62
|
+
snapTime: null,
|
|
63
|
+
snapType: null,
|
|
64
|
+
started: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function editMap(edits: TimelineMoveEdit[]): Record<string, { start: number; track: number }> {
|
|
69
|
+
const out: Record<string, { start: number; track: number }> = {};
|
|
70
|
+
for (const e of edits)
|
|
71
|
+
out[e.element.key ?? e.element.id] = {
|
|
72
|
+
start: e.updates.start,
|
|
73
|
+
track: e.updates.track,
|
|
74
|
+
};
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run a drag commit with fresh spies for the three persist callbacks, returning
|
|
80
|
+
* them so a test can assert on whichever path it exercised. Any other dep
|
|
81
|
+
* (trackOrder, selectedKeys, readZIndex, onStackingPatches, …) is passed through.
|
|
82
|
+
*/
|
|
83
|
+
function runClipMove(
|
|
84
|
+
dragState: DraggedClipState,
|
|
85
|
+
deps: Omit<DragCommitDeps, "onMoveElement" | "onMoveElements" | "updateElement">,
|
|
86
|
+
): { updateElement: Mock; onMoveElement: Mock; onMoveElements: Mock } {
|
|
87
|
+
const updateElement = vi.fn();
|
|
88
|
+
const onMoveElement = vi.fn();
|
|
89
|
+
const onMoveElements = vi.fn();
|
|
90
|
+
commitDraggedClipMove(dragState, {
|
|
91
|
+
...deps,
|
|
92
|
+
updateElement,
|
|
93
|
+
onMoveElement,
|
|
94
|
+
onMoveElements,
|
|
95
|
+
});
|
|
96
|
+
return { updateElement, onMoveElement, onMoveElements };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A drag onto another lane where the two clips do NOT time-overlap must issue no
|
|
100
|
+
* stacking patch (the dragged clip is elements[0]). Awaits the serialized z-sync. */
|
|
101
|
+
async function expectNoStackingPatchOnNonOverlapLaneChange(
|
|
102
|
+
elements: TimelineElement[],
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const onStackingPatches = vi.fn();
|
|
105
|
+
runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
|
|
106
|
+
elements,
|
|
107
|
+
trackOrder: [0, 1],
|
|
108
|
+
readZIndex: () => 0,
|
|
109
|
+
onStackingPatches,
|
|
110
|
+
});
|
|
111
|
+
await flushMicrotasks();
|
|
112
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type MoveMap = Record<string, { start: number; track: number }>;
|
|
116
|
+
|
|
117
|
+
/** Assert a lane change persisted atomically (single onMoveElements, no single
|
|
118
|
+
* onMoveElement) and return the resulting id → {start, track} edit map. */
|
|
119
|
+
function expectAtomicMoveMap(spies: { onMoveElement: Mock; onMoveElements: Mock }): MoveMap {
|
|
120
|
+
expect(spies.onMoveElement).not.toHaveBeenCalled();
|
|
121
|
+
expect(spies.onMoveElements).toHaveBeenCalledTimes(1);
|
|
122
|
+
return editMap(spies.onMoveElements.mock.calls[0][0]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Two time-overlapping clips carrying authored z (a below at z=1, b on top at
|
|
126
|
+
// z=5) — the bed for the z-sync / lane-change tests.
|
|
127
|
+
const overlapping = (): TimelineElement[] => [
|
|
128
|
+
{
|
|
129
|
+
id: "a",
|
|
130
|
+
key: "a",
|
|
131
|
+
tag: "video",
|
|
132
|
+
start: 0,
|
|
133
|
+
duration: 10,
|
|
134
|
+
track: 1,
|
|
135
|
+
zIndex: 1,
|
|
136
|
+
domId: "a",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "b",
|
|
140
|
+
key: "b",
|
|
141
|
+
tag: "video",
|
|
142
|
+
start: 0,
|
|
143
|
+
duration: 10,
|
|
144
|
+
track: 0,
|
|
145
|
+
zIndex: 5,
|
|
146
|
+
domId: "b",
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
const zOf = (e: TimelineElement) => ({ a: 1, b: 5 })[e.key ?? e.id] ?? 0;
|
|
150
|
+
|
|
151
|
+
// Commit an "insert a above b" lane change: the drop lifts a's z, so both the
|
|
152
|
+
// move persist and the z-sync fire. Tests drive onMoveElements differently
|
|
153
|
+
// (immediate / pending / rejecting), so the persist deps are supplied per call.
|
|
154
|
+
function commitInsertAbove(
|
|
155
|
+
elements: TimelineElement[],
|
|
156
|
+
deps: Partial<DragCommitDeps> & Pick<DragCommitDeps, "onMoveElements" | "onStackingPatches">,
|
|
157
|
+
): void {
|
|
158
|
+
commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
|
|
159
|
+
elements,
|
|
160
|
+
trackOrder: [0, 1],
|
|
161
|
+
updateElement: vi.fn(),
|
|
162
|
+
onMoveElement: vi.fn(),
|
|
163
|
+
readZIndex: zOf,
|
|
164
|
+
...deps,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The edited clip `a` is lifted above b(5) → 6, issued as one stacking patch.
|
|
169
|
+
function expectZLiftedToSix(onStackingPatches: Mock): void {
|
|
170
|
+
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 6 }]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A marquee (a+b selected) time-move of the dragged clip; returns the persist
|
|
175
|
+
// spies plus the resulting id → {start, track} edit map.
|
|
176
|
+
function runMarqueeMove(
|
|
177
|
+
dragged: TimelineElement,
|
|
178
|
+
previewStart: number,
|
|
179
|
+
elements: TimelineElement[],
|
|
180
|
+
): {
|
|
181
|
+
updateElement: Mock;
|
|
182
|
+
onMoveElement: Mock;
|
|
183
|
+
onMoveElements: Mock;
|
|
184
|
+
map: MoveMap;
|
|
185
|
+
} {
|
|
186
|
+
const spies = runClipMove(drag(dragged, { previewStart, previewTrack: 0 }), {
|
|
187
|
+
elements,
|
|
188
|
+
trackOrder: [0, 1],
|
|
189
|
+
selectedKeys: new Set(["a", "b"]),
|
|
190
|
+
});
|
|
191
|
+
return { ...spies, map: editMap(spies.onMoveElements.mock.calls[0][0]) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
describe("commitDraggedClipMove", () => {
|
|
195
|
+
it("pure time-move (same lane) persists just the dragged clip (single, SDK-aware)", () => {
|
|
196
|
+
const elements = [el("v1", 1, 0, 5)];
|
|
197
|
+
// previewTrack === element.track → no topology change → single move.
|
|
198
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
199
|
+
drag(elements[0], { previewStart: 6, previewTrack: 1 }),
|
|
200
|
+
{ elements, trackOrder: [1] },
|
|
201
|
+
);
|
|
202
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
203
|
+
expect(onMoveElement).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 1 });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("a plain lane change persists ONLY the dragged clip (CapCut: never re-lanes others)", () => {
|
|
208
|
+
// Move 'a' from lane 0 down onto lane 1 (b's lane) at a non-overlapping time.
|
|
209
|
+
// The CapCut rule: editing one clip must never rewrite another. Only 'a' is
|
|
210
|
+
// persisted — with its new lane (previewTrack 1) — and 'b' is left untouched.
|
|
211
|
+
const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)];
|
|
212
|
+
const { onMoveElements } = runClipMove(
|
|
213
|
+
drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
|
|
214
|
+
{ elements, trackOrder: [0, 1] },
|
|
215
|
+
);
|
|
216
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
217
|
+
expect(onMoveElements.mock.calls[0][2]).toBe("lane-reorder");
|
|
218
|
+
expect(map.a).toEqual({ start: 20, track: 1 });
|
|
219
|
+
expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
|
|
223
|
+
const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
|
|
224
|
+
// Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
|
|
225
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
226
|
+
drag(elements[0], { previewStart: 7, previewTrack: 0 }),
|
|
227
|
+
{ elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "b"]) },
|
|
228
|
+
);
|
|
229
|
+
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
|
|
230
|
+
expect(map.a).toEqual({ start: 7, track: 0 });
|
|
231
|
+
expect(map.b).toEqual({ start: 15, track: 1 }); // same +5 delta, keeps its lane
|
|
232
|
+
expect(map.c).toBeUndefined(); // unselected clips untouched
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
|
|
236
|
+
const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
|
|
237
|
+
// Drag 'a' −5s: b would land at −3 → clamps to 0.
|
|
238
|
+
const { updateElement, map } = runMarqueeMove(elements[0], 1, elements);
|
|
239
|
+
expect(map.a).toEqual({ start: 1, track: 0 });
|
|
240
|
+
expect(map.b).toEqual({ start: 0, track: 1 });
|
|
241
|
+
expect(updateElement).toHaveBeenCalledWith("a", { start: 1, track: 0 });
|
|
242
|
+
expect(updateElement).toHaveBeenCalledWith("b", { start: 0, track: 1 });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("a multi-selection that does NOT include the dragged clip moves only the dragged clip", () => {
|
|
246
|
+
const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)];
|
|
247
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
248
|
+
drag(elements[0], { previewStart: 6, previewTrack: 0 }),
|
|
249
|
+
{ elements, trackOrder: [0, 1], selectedKeys: new Set(["b", "x"]) },
|
|
250
|
+
);
|
|
251
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
252
|
+
expect(onMoveElement).toHaveBeenCalledTimes(1);
|
|
253
|
+
expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 0 });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("multi-selection lane change: dragged clip changes track, selection shifts in time, others untouched", () => {
|
|
257
|
+
const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
|
|
258
|
+
// Drag 'a' +4s down onto lane 1 (non-overlapping with b) while {a, c} selected.
|
|
259
|
+
const { onMoveElements } = runClipMove(
|
|
260
|
+
drag(elements[0], { previewStart: 4, previewTrack: 1, desiredTrack: 1 }),
|
|
261
|
+
{ elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "c"]) },
|
|
262
|
+
);
|
|
263
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
264
|
+
expect(onMoveElements.mock.calls[0][2]).toBe("lane-reorder");
|
|
265
|
+
expect(map.a).toEqual({ start: 4, track: 1 }); // dragged: new time + new lane
|
|
266
|
+
expect(map.c).toEqual({ start: 24, track: 2 }); // passenger: same +4 delta, own lane
|
|
267
|
+
expect(map.b).toBeUndefined(); // unselected clip is NOT rewritten
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("inserting a new lane places the dragged clip at the aimed row and +1-shifts the clips below", () => {
|
|
271
|
+
// a,b,c all start=0 dur=5 → mutually overlapping. Insert drops c on a NEW lane
|
|
272
|
+
// at row 1 (between a and b). The CapCut renumber: c lands at row 1, a keeps
|
|
273
|
+
// row 0, and b (at/below the insert) shifts down by one to row 2. Lane order
|
|
274
|
+
// now follows track-index (a, c, b) — the ONLY sanctioned multi-clip write,
|
|
275
|
+
// index-only. Contiguous 0..2, one atomic persist.
|
|
276
|
+
const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
|
|
277
|
+
const { onMoveElements } = runClipMove(
|
|
278
|
+
drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 1 }),
|
|
279
|
+
{ elements, trackOrder: [0, 1, 2] },
|
|
280
|
+
);
|
|
281
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
282
|
+
expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
|
|
283
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
284
|
+
// Lanes are contiguous and distinct (no two overlapping clips share a lane).
|
|
285
|
+
expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
|
|
286
|
+
// The z-aware normalization may reverse the authored lane numbers, but the
|
|
287
|
+
// insert must still leave three distinct, contiguous visual lanes.
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("lane ↔ stacking sync", () => {
|
|
291
|
+
it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
|
|
292
|
+
// a & b overlap in time. Elements carry their authored z (as real discovery
|
|
293
|
+
// populates TimelineElement.zIndex from the DOM), so the per-clip pack lays
|
|
294
|
+
// them out by z: b (z=5) tops, a (z=1) below. The user drags a UP onto the
|
|
295
|
+
// TOP lane (row 0, above b) via an insert — expressing "a should stack above
|
|
296
|
+
// b". The z-sync must lift a above b (5) → 6 so the lane move is realised.
|
|
297
|
+
// (Was: equal-z candidate + drop onto b's track; that relied on the old
|
|
298
|
+
// key-order tie-break placing a on top, which contradicted canvas paint for
|
|
299
|
+
// equal z — the elements now carry z and the drop intent is an insert above.)
|
|
300
|
+
const elements: TimelineElement[] = [
|
|
301
|
+
{
|
|
302
|
+
id: "a",
|
|
303
|
+
key: "a",
|
|
304
|
+
tag: "video",
|
|
305
|
+
start: 0,
|
|
306
|
+
duration: 10,
|
|
307
|
+
track: 1,
|
|
308
|
+
zIndex: 1,
|
|
309
|
+
domId: "a",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
id: "b",
|
|
313
|
+
key: "b",
|
|
314
|
+
tag: "video",
|
|
315
|
+
start: 0,
|
|
316
|
+
duration: 10,
|
|
317
|
+
track: 0,
|
|
318
|
+
zIndex: 5,
|
|
319
|
+
domId: "b",
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
const z: Record<string, number> = { a: 1, b: 5 };
|
|
323
|
+
const onStackingPatches = vi.fn();
|
|
324
|
+
// Insert a new lane at row 0 (above the top lane) with a → a lands above b.
|
|
325
|
+
runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
|
|
326
|
+
elements,
|
|
327
|
+
trackOrder: [0, 1],
|
|
328
|
+
readZIndex: (e) => z[e.key ?? e.id] ?? 0,
|
|
329
|
+
onStackingPatches,
|
|
330
|
+
});
|
|
331
|
+
// z-sync is serialized after the move persist → deferred to a microtask.
|
|
332
|
+
await flushMicrotasks();
|
|
333
|
+
// Only `a` (the edited clip) is patched, lifted above b(5) → 6.
|
|
334
|
+
expectZLiftedToSix(onStackingPatches);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("partial z-sync deps (no readZIndex) → move persists but no stacking call", async () => {
|
|
338
|
+
const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)];
|
|
339
|
+
// onStackingPatches present but readZIndex absent → syncStackingForEdit needs
|
|
340
|
+
// BOTH, so it must issue zero patches even though this IS a lane change.
|
|
341
|
+
const onStackingPatches = vi.fn();
|
|
342
|
+
const { onMoveElements } = runClipMove(
|
|
343
|
+
drag(elements[0], { previewStart: 0, previewTrack: 0 }),
|
|
344
|
+
{ elements, trackOrder: [0, 1], onStackingPatches },
|
|
345
|
+
);
|
|
346
|
+
await flushMicrotasks();
|
|
347
|
+
// The move still persists atomically; no z patch is issued, no stacking call.
|
|
348
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
349
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("no time overlap → no stacking patch even on a lane change", async () => {
|
|
353
|
+
await expectNoStackingPatchOnNonOverlapLaneChange([el("a", 1, 0, 5), el("b", 0, 10, 5)]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("pure time-move (no lane change) never triggers a stacking patch", () => {
|
|
357
|
+
const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)];
|
|
358
|
+
const onStackingPatches = vi.fn();
|
|
359
|
+
// same track → not a topology change → z-sync branch not reached.
|
|
360
|
+
runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), {
|
|
361
|
+
elements,
|
|
362
|
+
trackOrder: [0],
|
|
363
|
+
readZIndex: () => 0,
|
|
364
|
+
onStackingPatches,
|
|
365
|
+
});
|
|
366
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("drop-intent lane realization — the dragged clip DISPLAYS on the aimed lane", () => {
|
|
371
|
+
// CapCut-stable lanes follow the track-index, so a gap INSERT lands the dragged
|
|
372
|
+
// clip exactly where aimed: it takes the new lane at the insert row and the
|
|
373
|
+
// clips at/below shift down by one (the sanctioned renumber). z never enters
|
|
374
|
+
// lane assignment, so a low-z clip aimed at the top gets the top lane regardless
|
|
375
|
+
// of z. These are full preview→commit→normalize round trips: apply the persisted
|
|
376
|
+
// track edits (and any z patches), then re-normalize — the lanes come purely
|
|
377
|
+
// from the track-index renumber, never from z.
|
|
378
|
+
const vz = (
|
|
379
|
+
id: string,
|
|
380
|
+
track: number,
|
|
381
|
+
start: number,
|
|
382
|
+
duration: number,
|
|
383
|
+
zIndex: number,
|
|
384
|
+
): TimelineElement => ({
|
|
385
|
+
id,
|
|
386
|
+
key: id,
|
|
387
|
+
tag: "video",
|
|
388
|
+
start,
|
|
389
|
+
duration,
|
|
390
|
+
track,
|
|
391
|
+
zIndex,
|
|
392
|
+
domId: id,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
async function roundTripLane(
|
|
396
|
+
elements: TimelineElement[],
|
|
397
|
+
dragState: DraggedClipState,
|
|
398
|
+
trackOrder: number[],
|
|
399
|
+
): Promise<Record<string, number>> {
|
|
400
|
+
let edits: TimelineMoveEdit[] = [];
|
|
401
|
+
let patches: StackingPatch[] = [];
|
|
402
|
+
const z = new Map(elements.map((e) => [e.key ?? e.id, (e.zIndex as number) ?? 0]));
|
|
403
|
+
commitDraggedClipMove(dragState, {
|
|
404
|
+
elements,
|
|
405
|
+
trackOrder,
|
|
406
|
+
updateElement: vi.fn(),
|
|
407
|
+
onMoveElement: vi.fn(),
|
|
408
|
+
onMoveElements: (e: TimelineMoveEdit[]) => {
|
|
409
|
+
edits = e;
|
|
410
|
+
},
|
|
411
|
+
readZIndex: (el) => z.get(el.key ?? el.id) ?? 0,
|
|
412
|
+
onStackingPatches: (p: StackingPatch[]) => {
|
|
413
|
+
patches = p;
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
await flushMicrotasks();
|
|
417
|
+
const te = new Map(edits.map((e) => [e.element.key ?? e.element.id, e.updates]));
|
|
418
|
+
const ze = new Map(patches.map((p) => [p.key, p.zIndex]));
|
|
419
|
+
const persisted = elements.map((e) => {
|
|
420
|
+
const k = e.key ?? e.id;
|
|
421
|
+
const t = te.get(k);
|
|
422
|
+
return {
|
|
423
|
+
...e,
|
|
424
|
+
start: t ? t.start : e.start,
|
|
425
|
+
track: t ? t.track : e.track,
|
|
426
|
+
zIndex: ze.has(k) ? ze.get(k)! : e.zIndex,
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
const lane: Record<string, number> = {};
|
|
430
|
+
for (const e of normalizeToZones(persisted)) lane[e.key ?? e.id] = e.track;
|
|
431
|
+
return lane;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
it("TOP-insert of a clip that OVERLAPS NOTHING lands it on the top lane (not its z-rank)", async () => {
|
|
435
|
+
// top(z20)/mid(z15) share the upper lanes; dragged(z2) is sequential (no time
|
|
436
|
+
// overlap with anything) and low-z, so a global z-sort would sink it. Aim: top.
|
|
437
|
+
const elements = [vz("top", 0, 0, 3, 20), vz("mid", 5, 3, 1, 15), vz("dragged", 1, 30, 3, 2)];
|
|
438
|
+
const lane = await roundTripLane(
|
|
439
|
+
elements,
|
|
440
|
+
drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
|
|
441
|
+
[0, 1],
|
|
442
|
+
);
|
|
443
|
+
// Non-overlapping clips retain their authored/z-derived ordering; a lane
|
|
444
|
+
// gesture cannot invent a DOM stacking relationship where none overlaps.
|
|
445
|
+
expect(lane.dragged).toBe(2);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
|
|
449
|
+
const elements = [vz("a", 0, 0, 3, 9), vz("b", 1, 0, 3, 5), vz("x", 2, 20, 5, 1)];
|
|
450
|
+
const lane = await roundTripLane(
|
|
451
|
+
elements,
|
|
452
|
+
drag(elements[2], { previewStart: 20, previewTrack: 2, insertRow: 1 }),
|
|
453
|
+
[0, 1, 2],
|
|
454
|
+
);
|
|
455
|
+
expect(lane.a).toBe(0);
|
|
456
|
+
expect(lane.b).toBe(1);
|
|
457
|
+
expect(lane.x).toBe(2);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
|
|
461
|
+
// X overlaps M but NOT T (T tops the timeline, disjoint in time). Aiming X at
|
|
462
|
+
// the top must lift it past T even though they never overlap.
|
|
463
|
+
const elements = [vz("T", 0, 0, 3, 9), vz("M", 1, 10, 5, 5), vz("X", 2, 10, 5, 1)];
|
|
464
|
+
const lane = await roundTripLane(
|
|
465
|
+
elements,
|
|
466
|
+
drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
|
|
467
|
+
[0, 1, 2],
|
|
468
|
+
);
|
|
469
|
+
expect(lane.X).toBe(1); // X reorders against overlapping M, not disjoint T
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
|
|
473
|
+
// Four mutually-overlapping clips. Baseline lanes by z: n1,n2,x,n3. Drag x to
|
|
474
|
+
// the top. Everything above x shifts down by one to make room (unavoidable),
|
|
475
|
+
// but no TWO non-dragged clips may swap — a lane change of X must not reshuffle
|
|
476
|
+
// its neighbours among themselves.
|
|
477
|
+
const elements = [
|
|
478
|
+
vz("n1", 0, 0, 10, 4),
|
|
479
|
+
vz("n2", 1, 0, 10, 3),
|
|
480
|
+
vz("x", 2, 0, 10, 2),
|
|
481
|
+
vz("n3", 3, 0, 10, 1),
|
|
482
|
+
];
|
|
483
|
+
const baseOrder = normalizeToZones(elements)
|
|
484
|
+
.slice()
|
|
485
|
+
.sort((a, b) => a.track - b.track)
|
|
486
|
+
.map((e) => e.id)
|
|
487
|
+
.filter((id) => id !== "x");
|
|
488
|
+
const lane = await roundTripLane(
|
|
489
|
+
elements,
|
|
490
|
+
drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 0 }),
|
|
491
|
+
[0, 1, 2, 3],
|
|
492
|
+
);
|
|
493
|
+
expect(lane.x).toBe(0); // X lands where aimed
|
|
494
|
+
const finalOrder = Object.keys(lane)
|
|
495
|
+
.sort((a, b) => lane[a] - lane[b])
|
|
496
|
+
.filter((id) => id !== "x");
|
|
497
|
+
expect(finalOrder).toEqual(baseOrder); // neighbours keep their relative order
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("a drop that merely SHARES a lane with a non-overlapping neighbour issues no z nudge", async () => {
|
|
501
|
+
// a → b's lane; they don't overlap → they share the lane, aim already met, so
|
|
502
|
+
// no stacking patch (byte-identical to the pre-fix no-op path).
|
|
503
|
+
await expectNoStackingPatchOnNonOverlapLaneChange([
|
|
504
|
+
vz("a", 1, 0, 5, 0),
|
|
505
|
+
vz("b", 0, 10, 5, 0),
|
|
506
|
+
]);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe("BUG 1 — a plain horizontal move never patches z (fixture repro)", () => {
|
|
511
|
+
// Mirror the user's index.html: v-moodboard alone on the top display lane (0),
|
|
512
|
+
// highest z, over lower-lane clips it OVERLAPS in time. A horizontal drag (the
|
|
513
|
+
// preview yields insertRow=null, previewTrack unchanged) must be a pure time
|
|
514
|
+
// move: single-clip persist, and NOT a single z patch or history entry — even
|
|
515
|
+
// with the z-sync deps fully wired and time-overlapping neighbours present.
|
|
516
|
+
const fixture = (): TimelineElement[] => [
|
|
517
|
+
{
|
|
518
|
+
id: "v-moodboard",
|
|
519
|
+
key: "v-moodboard",
|
|
520
|
+
tag: "video",
|
|
521
|
+
start: 19,
|
|
522
|
+
duration: 5.5,
|
|
523
|
+
track: 0,
|
|
524
|
+
zIndex: 37,
|
|
525
|
+
domId: "v-moodboard",
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
id: "v-dashboard",
|
|
529
|
+
key: "v-dashboard",
|
|
530
|
+
tag: "video",
|
|
531
|
+
start: 19,
|
|
532
|
+
duration: 4,
|
|
533
|
+
track: 1,
|
|
534
|
+
zIndex: 16,
|
|
535
|
+
domId: "v-dashboard",
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: "v-globe",
|
|
539
|
+
key: "v-globe",
|
|
540
|
+
tag: "video",
|
|
541
|
+
start: 23,
|
|
542
|
+
duration: 1.5,
|
|
543
|
+
track: 1,
|
|
544
|
+
zIndex: 17,
|
|
545
|
+
domId: "v-globe",
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
id: "cap",
|
|
549
|
+
key: "cap",
|
|
550
|
+
tag: "text",
|
|
551
|
+
start: 20.82,
|
|
552
|
+
duration: 1.78,
|
|
553
|
+
track: 2,
|
|
554
|
+
zIndex: 0,
|
|
555
|
+
domId: "cap",
|
|
556
|
+
},
|
|
557
|
+
];
|
|
558
|
+
|
|
559
|
+
it("+2s on its own lane → single onMoveElement, zero stacking patches", async () => {
|
|
560
|
+
const elements = fixture();
|
|
561
|
+
const z: Record<string, number> = {
|
|
562
|
+
"v-moodboard": 37,
|
|
563
|
+
"v-dashboard": 16,
|
|
564
|
+
"v-globe": 17,
|
|
565
|
+
};
|
|
566
|
+
const onStackingPatches = vi.fn();
|
|
567
|
+
// previewTrack === element.track (0) and insertRow null → pure time move.
|
|
568
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
569
|
+
drag(elements[0], { previewStart: 21, previewTrack: 0 }),
|
|
570
|
+
{
|
|
571
|
+
elements,
|
|
572
|
+
trackOrder: [0, 1, 2],
|
|
573
|
+
readZIndex: (e) => z[e.key ?? e.id] ?? 0,
|
|
574
|
+
onStackingPatches,
|
|
575
|
+
},
|
|
576
|
+
);
|
|
577
|
+
await flushMicrotasks();
|
|
578
|
+
expect(onMoveElement).toHaveBeenCalledTimes(1);
|
|
579
|
+
expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 21, track: 0 });
|
|
580
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
581
|
+
expect(onStackingPatches).not.toHaveBeenCalled(); // zero z patches / history entries
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("guard: a topology call that AIMS at the clip's own display lane issues no z nudge", async () => {
|
|
585
|
+
// Belt-and-suspenders: even if a spurious insert whose boundary equals the
|
|
586
|
+
// clip's own lane slips into the topology branch, aiming at the current lane
|
|
587
|
+
// must never restack (syncStackingForEdit's aimedLane === currentLane no-op).
|
|
588
|
+
const elements = fixture();
|
|
589
|
+
const z: Record<string, number> = {
|
|
590
|
+
"v-moodboard": 37,
|
|
591
|
+
"v-dashboard": 16,
|
|
592
|
+
"v-globe": 17,
|
|
593
|
+
};
|
|
594
|
+
const onStackingPatches = vi.fn();
|
|
595
|
+
// insertRow 0 === v-moodboard's own display lane (0).
|
|
596
|
+
runClipMove(drag(elements[0], { previewStart: 21, previewTrack: 0, insertRow: 0 }), {
|
|
597
|
+
elements,
|
|
598
|
+
trackOrder: [0, 1, 2],
|
|
599
|
+
readZIndex: (e) => z[e.key ?? e.id] ?? 0,
|
|
600
|
+
onStackingPatches,
|
|
601
|
+
});
|
|
602
|
+
await flushMicrotasks();
|
|
603
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
describe("horizontal drag among overlapping neighbours touches exactly ONE clip (live repro)", () => {
|
|
608
|
+
// The disease: a plain horizontal drag of one caption rewrote its own track,
|
|
609
|
+
// added a z-index, and rewrote FOUR other clips' track-indexes. The cure: a
|
|
610
|
+
// horizontal move writes ONLY the dragged clip's start — no other clip's
|
|
611
|
+
// start/track, no z — even surrounded by time-overlapping clips.
|
|
612
|
+
it("writes only the dragged clip's start; no other clip, no z", async () => {
|
|
613
|
+
const elements: TimelineElement[] = [
|
|
614
|
+
{
|
|
615
|
+
id: "cap",
|
|
616
|
+
key: "cap",
|
|
617
|
+
tag: "text",
|
|
618
|
+
start: 4.5,
|
|
619
|
+
duration: 1.2,
|
|
620
|
+
track: 0,
|
|
621
|
+
zIndex: 26,
|
|
622
|
+
domId: "cap",
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
id: "n1",
|
|
626
|
+
key: "n1",
|
|
627
|
+
tag: "video",
|
|
628
|
+
start: 4,
|
|
629
|
+
duration: 3,
|
|
630
|
+
track: 1,
|
|
631
|
+
zIndex: 12,
|
|
632
|
+
domId: "n1",
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
id: "n2",
|
|
636
|
+
key: "n2",
|
|
637
|
+
tag: "video",
|
|
638
|
+
start: 4,
|
|
639
|
+
duration: 3,
|
|
640
|
+
track: 2,
|
|
641
|
+
zIndex: 19,
|
|
642
|
+
domId: "n2",
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
id: "n3",
|
|
646
|
+
key: "n3",
|
|
647
|
+
tag: "text",
|
|
648
|
+
start: 5,
|
|
649
|
+
duration: 2,
|
|
650
|
+
track: 3,
|
|
651
|
+
zIndex: 25,
|
|
652
|
+
domId: "n3",
|
|
653
|
+
},
|
|
654
|
+
];
|
|
655
|
+
const onStackingPatches = vi.fn();
|
|
656
|
+
// Pure horizontal: previewTrack === element.track (0), no insert.
|
|
657
|
+
const { updateElement, onMoveElement, onMoveElements } = runClipMove(
|
|
658
|
+
drag(elements[0], {
|
|
659
|
+
previewStart: 5.5,
|
|
660
|
+
previewTrack: 0,
|
|
661
|
+
desiredTrack: 0,
|
|
662
|
+
}),
|
|
663
|
+
{
|
|
664
|
+
elements,
|
|
665
|
+
trackOrder: [0, 1, 2, 3],
|
|
666
|
+
readZIndex: (e) => (e.zIndex as number) ?? 0,
|
|
667
|
+
onStackingPatches,
|
|
668
|
+
},
|
|
669
|
+
);
|
|
670
|
+
await flushMicrotasks();
|
|
671
|
+
// Exactly one clip written, start only, no z entry.
|
|
672
|
+
expect(updateElement).toHaveBeenCalledTimes(1);
|
|
673
|
+
expect(updateElement).toHaveBeenCalledWith("cap", {
|
|
674
|
+
start: 5.5,
|
|
675
|
+
track: 0,
|
|
676
|
+
});
|
|
677
|
+
expect(onMoveElement).toHaveBeenCalledTimes(1);
|
|
678
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
679
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe("horizontal collision relocation (ITEM 2 — dragged clip only, never z)", () => {
|
|
684
|
+
it("a horizontal drag bumped to a free lane writes ONLY the dragged clip, and never z", async () => {
|
|
685
|
+
// The pointer stayed on lane 0 (desiredTrack === element.track), but the
|
|
686
|
+
// target span was occupied there, so the collision rules relocated the DRAGGED
|
|
687
|
+
// clip to lane 1 (previewTrack 1). That is not a deliberate vertical move:
|
|
688
|
+
// persist just the dragged clip (new start + relocated lane), rewrite no other
|
|
689
|
+
// clip, and issue zero z patches even though it now overlaps a neighbour.
|
|
690
|
+
const elements: TimelineElement[] = [
|
|
691
|
+
{
|
|
692
|
+
id: "a",
|
|
693
|
+
key: "a",
|
|
694
|
+
tag: "video",
|
|
695
|
+
start: 0,
|
|
696
|
+
duration: 5,
|
|
697
|
+
track: 0,
|
|
698
|
+
zIndex: 2,
|
|
699
|
+
domId: "a",
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
id: "d",
|
|
703
|
+
key: "d",
|
|
704
|
+
tag: "video",
|
|
705
|
+
start: 0,
|
|
706
|
+
duration: 5,
|
|
707
|
+
track: 2,
|
|
708
|
+
zIndex: 0,
|
|
709
|
+
domId: "d",
|
|
710
|
+
},
|
|
711
|
+
];
|
|
712
|
+
const onStackingPatches = vi.fn();
|
|
713
|
+
const { onMoveElements } = runClipMove(
|
|
714
|
+
// desiredTrack 0 (pointer never left lane 0) but previewTrack 1 (bumped).
|
|
715
|
+
drag(elements[0], {
|
|
716
|
+
previewStart: 2,
|
|
717
|
+
previewTrack: 1,
|
|
718
|
+
desiredTrack: 0,
|
|
719
|
+
}),
|
|
720
|
+
{
|
|
721
|
+
elements,
|
|
722
|
+
trackOrder: [0, 1, 2],
|
|
723
|
+
readZIndex: (e) => (e.zIndex as number) ?? 0,
|
|
724
|
+
onStackingPatches,
|
|
725
|
+
},
|
|
726
|
+
);
|
|
727
|
+
await flushMicrotasks();
|
|
728
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
729
|
+
expect(map.a).toEqual({ start: 2, track: 1 }); // dragged clip relocated
|
|
730
|
+
expect(map.d).toBeUndefined(); // untouched
|
|
731
|
+
expect(onStackingPatches).not.toHaveBeenCalled(); // horizontal → never z
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe("capabilities gate (ITEM 2)", () => {
|
|
736
|
+
const locked = (
|
|
737
|
+
id: string,
|
|
738
|
+
track: number,
|
|
739
|
+
start: number,
|
|
740
|
+
duration: number,
|
|
741
|
+
): TimelineElement => ({
|
|
742
|
+
...el(id, track, start, duration),
|
|
743
|
+
timelineLocked: true,
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("a marquee containing a locked clip never persists an edit for the locked clip", () => {
|
|
747
|
+
const dragged = el("a", 0, 2, 3);
|
|
748
|
+
const elements = [dragged, locked("b", 1, 10, 3)];
|
|
749
|
+
// Pure time-move +5 on the same lane while {a, b} are marquee-selected.
|
|
750
|
+
const { map } = runMarqueeMove(dragged, 7, elements);
|
|
751
|
+
expect(map.a).toEqual({ start: 7, track: 0 });
|
|
752
|
+
expect(map.b).toBeUndefined(); // locked → filtered out of the moving set
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("a plain lane change never persists a locked neighbour (only the dragged clip is written)", async () => {
|
|
756
|
+
const dragged = el("a", 0, 0, 3);
|
|
757
|
+
const elements = [dragged, locked("b", 1, 0, 3)];
|
|
758
|
+
// Drag a onto b's lane. A plain lane change writes ONLY the dragged clip, so
|
|
759
|
+
// the locked neighbour b is inherently untouched (never even a candidate).
|
|
760
|
+
const { onMoveElements } = runClipMove(drag(dragged, { previewStart: 0, previewTrack: 1 }), {
|
|
761
|
+
elements,
|
|
762
|
+
trackOrder: [0, 1],
|
|
763
|
+
});
|
|
764
|
+
await flushMicrotasks();
|
|
765
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
766
|
+
expect(map.a).toBeDefined(); // movable dragged clip persists
|
|
767
|
+
expect(map.b).toBeUndefined(); // locked clip receives no patch
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("a lane change that produces ZERO move edits fires no z-sync (no orphaned z entry, ITEM 5)", async () => {
|
|
771
|
+
// Every clip in the set is locked (including the dragged one), so the moving
|
|
772
|
+
// set filters down to nothing. persistMoveEdits resolves true for the empty
|
|
773
|
+
// batch, but the z-sync must NOT fire — otherwise a "Reorder layers" history
|
|
774
|
+
// entry lands with no corresponding move.
|
|
775
|
+
const dragged = locked("a", 0, 0, 5);
|
|
776
|
+
const elements = [dragged, locked("b", 0, 0, 5)];
|
|
777
|
+
const onMoveElements = vi.fn();
|
|
778
|
+
const onStackingPatches = vi.fn();
|
|
779
|
+
// Drag onto lane 1 (a topology change) so the lane-change branch runs.
|
|
780
|
+
commitDraggedClipMove(drag(dragged, { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
|
|
781
|
+
elements,
|
|
782
|
+
trackOrder: [0],
|
|
783
|
+
updateElement: vi.fn(),
|
|
784
|
+
onMoveElement: vi.fn(),
|
|
785
|
+
onMoveElements,
|
|
786
|
+
readZIndex: () => 0,
|
|
787
|
+
onStackingPatches,
|
|
788
|
+
});
|
|
789
|
+
await flushMicrotasks();
|
|
790
|
+
expect(onMoveElements).not.toHaveBeenCalled(); // empty batch → no persist call
|
|
791
|
+
expect(onStackingPatches).not.toHaveBeenCalled(); // and no orphaned z entry
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe("z-sync serialization + rollback (ITEM 3)", () => {
|
|
796
|
+
it("defers the z-sync until the move persist resolves (no clobbering pre-write)", async () => {
|
|
797
|
+
const elements = overlapping();
|
|
798
|
+
let resolveMove!: () => void;
|
|
799
|
+
const onMoveElements = vi.fn(() => new Promise<void>((r) => (resolveMove = r)));
|
|
800
|
+
const onStackingPatches = vi.fn();
|
|
801
|
+
commitInsertAbove(elements, { onMoveElements, onStackingPatches });
|
|
802
|
+
await flushMicrotasks();
|
|
803
|
+
// Move persist still pending → the z patch must NOT have been issued yet.
|
|
804
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
805
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
806
|
+
resolveMove();
|
|
807
|
+
await flushMicrotasks();
|
|
808
|
+
// Only after the write lands does the z patch fire — once, for the edited clip.
|
|
809
|
+
expectZLiftedToSix(onStackingPatches);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("rolls back the move and skips the z-sync when the persist fails", async () => {
|
|
813
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
814
|
+
const elements = overlapping();
|
|
815
|
+
const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
|
|
816
|
+
const onStackingPatches = vi.fn();
|
|
817
|
+
const updateElement = vi.fn();
|
|
818
|
+
commitInsertAbove(elements, {
|
|
819
|
+
updateElement,
|
|
820
|
+
onMoveElements,
|
|
821
|
+
onStackingPatches,
|
|
822
|
+
});
|
|
823
|
+
await flushMicrotasks();
|
|
824
|
+
// Failed move → z patch never issued (no orphaned z change left behind)...
|
|
825
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
826
|
+
// ...and the optimistic start/track edit for the dragged clip is rolled back.
|
|
827
|
+
expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
|
|
828
|
+
errSpy.mockRestore();
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("does not let an older rejected move roll back a newer optimistic gesture", async () => {
|
|
832
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
833
|
+
const element = el("a", 0, 0, 5);
|
|
834
|
+
let rejectFirst!: (error: Error) => void;
|
|
835
|
+
const onMoveElement = vi
|
|
836
|
+
.fn()
|
|
837
|
+
.mockImplementationOnce(
|
|
838
|
+
() => new Promise<void>((_resolve, reject) => (rejectFirst = reject)),
|
|
839
|
+
)
|
|
840
|
+
.mockResolvedValueOnce(undefined);
|
|
841
|
+
const current = new Map([["a", { start: 0, track: 0 }]]);
|
|
842
|
+
const updateElement = vi.fn((key: string, updates: Partial<TimelineElement>) => {
|
|
843
|
+
current.set(key, { ...current.get(key)!, ...updates });
|
|
844
|
+
});
|
|
845
|
+
const deps = {
|
|
846
|
+
elements: [element],
|
|
847
|
+
trackOrder: [0],
|
|
848
|
+
updateElement,
|
|
849
|
+
onMoveElement,
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
commitDraggedClipMove(drag(element, { previewStart: 1, previewTrack: 0 }), deps);
|
|
853
|
+
commitDraggedClipMove(drag(element, { previewStart: 2, previewTrack: 0 }), deps);
|
|
854
|
+
await flushMicrotasks();
|
|
855
|
+
rejectFirst(new Error("older write failed"));
|
|
856
|
+
await flushMicrotasks();
|
|
857
|
+
|
|
858
|
+
expect(current.get("a")).toEqual({ start: 2, track: 0 });
|
|
859
|
+
errorSpy.mockRestore();
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe("lane-change undo coalescing (ITEM 3c)", () => {
|
|
864
|
+
const commitLaneChange = (elements: TimelineElement[]) => {
|
|
865
|
+
const onMoveElements = vi.fn();
|
|
866
|
+
const onStackingPatches = vi.fn();
|
|
867
|
+
commitInsertAbove(elements, { onMoveElements, onStackingPatches });
|
|
868
|
+
return { onMoveElements, onStackingPatches };
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the two records merge into a single undo entry", async () => {
|
|
872
|
+
const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
|
|
873
|
+
await flushMicrotasks();
|
|
874
|
+
|
|
875
|
+
// Both sides receive the SAME non-empty gesture key (second arg).
|
|
876
|
+
const moveKey = onMoveElements.mock.calls[0][1];
|
|
877
|
+
const zKey = onStackingPatches.mock.calls[0][1];
|
|
878
|
+
expect(typeof moveKey).toBe("string");
|
|
879
|
+
expect(moveKey).not.toBe("");
|
|
880
|
+
expect(zKey).toBe(moveKey);
|
|
881
|
+
|
|
882
|
+
// With that shared key, editHistory folds the two consecutive records (the
|
|
883
|
+
// "Move timeline clips" write + the "Reorder layers" z patch, same file,
|
|
884
|
+
// inside the coalesce window) into ONE undo entry spanning before→after.
|
|
885
|
+
const now = 1_000;
|
|
886
|
+
const moveEntry = buildEditHistoryEntry({
|
|
887
|
+
id: "m",
|
|
888
|
+
projectId: "p",
|
|
889
|
+
label: "Move timeline clips",
|
|
890
|
+
kind: "timeline",
|
|
891
|
+
coalesceKey: moveKey,
|
|
892
|
+
now,
|
|
893
|
+
files: { "index.html": { before: "<v0>", after: "<v1>" } },
|
|
894
|
+
});
|
|
895
|
+
const zEntry = buildEditHistoryEntry({
|
|
896
|
+
id: "z",
|
|
897
|
+
projectId: "p",
|
|
898
|
+
label: "Reorder layers",
|
|
899
|
+
kind: "timeline",
|
|
900
|
+
coalesceKey: zKey,
|
|
901
|
+
now: now + 50,
|
|
902
|
+
files: { "index.html": { before: "<v1>", after: "<v2>" } },
|
|
903
|
+
});
|
|
904
|
+
const state = pushEditHistoryEntry(
|
|
905
|
+
pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
|
|
906
|
+
zEntry,
|
|
907
|
+
);
|
|
908
|
+
expect(state.undo).toHaveLength(1);
|
|
909
|
+
expect(state.undo[0].files["index.html"]).toMatchObject({
|
|
910
|
+
before: "<v0>",
|
|
911
|
+
after: "<v2>",
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
|
|
916
|
+
const { onMoveElements: first } = commitLaneChange(overlapping());
|
|
917
|
+
const { onMoveElements: second } = commitLaneChange(overlapping());
|
|
918
|
+
await flushMicrotasks();
|
|
919
|
+
expect(first.mock.calls[0][1]).not.toBe(second.mock.calls[0][1]);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("a plain move that issues no z patch persists once and records no second entry (unchanged)", async () => {
|
|
923
|
+
// a & b do NOT overlap in time → the z-sync produces zero patches.
|
|
924
|
+
const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)];
|
|
925
|
+
const onMoveElements = vi.fn();
|
|
926
|
+
const onStackingPatches = vi.fn();
|
|
927
|
+
commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
|
|
928
|
+
elements,
|
|
929
|
+
trackOrder: [0, 1],
|
|
930
|
+
updateElement: vi.fn(),
|
|
931
|
+
onMoveElement: vi.fn(),
|
|
932
|
+
onMoveElements,
|
|
933
|
+
readZIndex: () => 0,
|
|
934
|
+
onStackingPatches,
|
|
935
|
+
});
|
|
936
|
+
await flushMicrotasks();
|
|
937
|
+
// Move persists exactly once; no z entry is created → nothing to merge, so
|
|
938
|
+
// the single "Move timeline clips" entry stands alone as before.
|
|
939
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
940
|
+
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
});
|