@hyperframes/studio 0.7.56 → 0.7.58
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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
- 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 +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
3
|
+
// Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
|
|
4
|
+
// value-import edge stays acyclic.
|
|
5
|
+
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
3
6
|
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
4
7
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
5
8
|
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
@@ -8,11 +11,19 @@ import {
|
|
|
8
11
|
beginTimelineOptimisticGesture,
|
|
9
12
|
isLatestTimelineOptimisticGesture,
|
|
10
13
|
} from "./timelineOptimisticRevision";
|
|
14
|
+
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
11
15
|
|
|
12
16
|
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
13
17
|
export interface TimelineMoveEdit {
|
|
14
18
|
element: TimelineElement;
|
|
15
19
|
updates: StartTrack;
|
|
20
|
+
/**
|
|
21
|
+
* File-space track override for the persist. The store's `updates.track` is a
|
|
22
|
+
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
|
|
23
|
+
* 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
|
|
24
|
+
* silently re-targets the wrong row. Omitted → persist `updates.track` as-is.
|
|
25
|
+
*/
|
|
26
|
+
persistTrack?: number;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
export interface DragCommitDeps {
|
|
@@ -24,11 +35,14 @@ export interface DragCommitDeps {
|
|
|
24
35
|
/** Atomic multi-clip persist (single undo) for lane changes + track inserts.
|
|
25
36
|
* `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
|
|
26
37
|
* history entry so it merges with the lane change's z-reorder entry (see the
|
|
27
|
-
* lane-change branch below).
|
|
38
|
+
* lane-change branch below). `coalesceMs` widens that entry's fold window
|
|
39
|
+
* (per-gesture-unique keys make an unbounded window safe) — required when a
|
|
40
|
+
* server round-trip sits between the gesture's two records. */
|
|
28
41
|
onMoveElements?: (
|
|
29
42
|
edits: TimelineMoveEdit[],
|
|
30
43
|
coalesceKey?: string,
|
|
31
44
|
operation?: TimelineMoveOperation,
|
|
45
|
+
coalesceMs?: number,
|
|
32
46
|
) => Promise<void> | void;
|
|
33
47
|
/**
|
|
34
48
|
* The current multi-selection (store.selectedElementIds). When the dragged
|
|
@@ -53,18 +67,13 @@ export interface DragCommitDeps {
|
|
|
53
67
|
* the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
|
|
54
68
|
* research/STAGE3-NEEDED-WIRING.md.
|
|
55
69
|
*/
|
|
56
|
-
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
|
|
70
|
+
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
60
74
|
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
61
75
|
|
|
62
|
-
// One coalesce key
|
|
63
|
-
// entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
|
|
64
|
-
// layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
|
|
65
|
-
// records into a single undo step. A monotonic counter — NOT Date.now() /
|
|
66
|
-
// Math.random(), which the determinism rules forbid — suffices: the key only has
|
|
67
|
-
// to be unique per gesture and identical across the gesture's two records.
|
|
76
|
+
// One deterministic coalesce key shared by both records in a lane-change gesture.
|
|
68
77
|
let laneChangeGestureSeq = 0;
|
|
69
78
|
|
|
70
79
|
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
|
@@ -94,12 +103,17 @@ function canMoveElement(element: TimelineElement): boolean {
|
|
|
94
103
|
* `false` so the caller also skips the z-sync (no orphaned z patch).
|
|
95
104
|
*
|
|
96
105
|
* The DOM is updated synchronously up front; the returned promise never rejects.
|
|
106
|
+
*
|
|
107
|
+
* Exported for reuse by non-drag batch time-moves (track gap closing — see
|
|
108
|
+
* timelineGapCommit.ts) so they share the same optimistic-apply, rollback, and
|
|
109
|
+
* atomic single-undo persist semantics as a drag commit.
|
|
97
110
|
*/
|
|
98
|
-
function persistMoveEdits(
|
|
111
|
+
export function persistMoveEdits(
|
|
99
112
|
edits: TimelineMoveEdit[],
|
|
100
113
|
deps: DragCommitDeps,
|
|
101
114
|
coalesceKey?: string,
|
|
102
115
|
operation: TimelineMoveOperation = "timing",
|
|
116
|
+
coalesceMs?: number,
|
|
103
117
|
): Promise<boolean> {
|
|
104
118
|
if (edits.length === 0) return Promise.resolve(true);
|
|
105
119
|
const { updateElement, onMoveElement, onMoveElements } = deps;
|
|
@@ -114,21 +128,41 @@ function persistMoveEdits(
|
|
|
114
128
|
key: keyOf(e.element),
|
|
115
129
|
start: e.element.start,
|
|
116
130
|
track: e.element.track,
|
|
131
|
+
authoredTrack: e.element.authoredTrack,
|
|
117
132
|
}));
|
|
118
133
|
const revision = beginTimelineOptimisticGesture(
|
|
119
134
|
updateElement,
|
|
120
135
|
edits.map((edit) => keyOf(edit.element)),
|
|
121
136
|
);
|
|
122
|
-
|
|
137
|
+
// The file write below targets `persistTrack` (authored space) when supplied,
|
|
138
|
+
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
|
|
139
|
+
// that written value into the store's `authoredTrack` so a SECOND drag before
|
|
140
|
+
// any reload resolves authored tracks from what the file now says, not stale
|
|
141
|
+
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
|
142
|
+
for (const e of edits) {
|
|
143
|
+
const writtenTrack =
|
|
144
|
+
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
|
|
145
|
+
updateElement(
|
|
146
|
+
keyOf(e.element),
|
|
147
|
+
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
|
151
|
+
// track when one was resolved (see TimelineMoveEdit.persistTrack).
|
|
152
|
+
const persistEdits = edits.map((e) =>
|
|
153
|
+
e.persistTrack == null || e.persistTrack === e.updates.track
|
|
154
|
+
? e
|
|
155
|
+
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
|
|
156
|
+
);
|
|
123
157
|
const persisted = onMoveElements
|
|
124
|
-
? onMoveElements(
|
|
125
|
-
: Promise.all(
|
|
158
|
+
? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
|
|
159
|
+
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
|
126
160
|
return Promise.resolve(persisted).then(
|
|
127
161
|
() => true,
|
|
128
162
|
(error) => {
|
|
129
163
|
for (const p of prev) {
|
|
130
164
|
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
|
131
|
-
updateElement(p.key, { start: p.start, track: p.track });
|
|
165
|
+
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
|
|
132
166
|
}
|
|
133
167
|
}
|
|
134
168
|
console.error("[Timeline] Failed to persist clip edits", error);
|
|
@@ -143,6 +177,59 @@ function persistMoveEdits(
|
|
|
143
177
|
* then compacts it to a distinct integer lane between its neighbours, and the
|
|
144
178
|
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
|
145
179
|
*/
|
|
180
|
+
/** Same-source-file predicate: authored track numbers only compare within ONE
|
|
181
|
+
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
|
|
182
|
+
* file, not the host timeline's). `undefined` means the active composition. */
|
|
183
|
+
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
184
|
+
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
|
|
188
|
+
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
|
|
189
|
+
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
|
|
190
|
+
* a host clip next to expanded rows) carries authored values in a different
|
|
191
|
+
* coordinate space, and borrowing them would write a foreign file's numbering.
|
|
192
|
+
*
|
|
193
|
+
* Lane semantics after normalizeToZones: each distinct authored track owns one
|
|
194
|
+
* base lane, and time-overlapping same-track clips spill onto adjacent display
|
|
195
|
+
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
|
|
196
|
+
* trackOrder lists it): its occupants share the base lane's authored track by
|
|
197
|
+
* construction, so the same-file occupant lookup returns that authored track and
|
|
198
|
+
* the drop persists as a same-track join. The clip may then DISPLAY on a
|
|
199
|
+
* different sub-lane than it was dropped on — the spill re-packs
|
|
200
|
+
* deterministically by stable id, first-fit — but the persisted track is
|
|
201
|
+
* correct.
|
|
202
|
+
*
|
|
203
|
+
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
|
|
204
|
+
* dropped on a lane holding only other files' clips — the display-lane integer
|
|
205
|
+
* must NOT be persisted into a sparse file):
|
|
206
|
+
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
|
|
207
|
+
* preserving "one lane up = one authored track up" in the clip's own file.
|
|
208
|
+
* 2. No same-file peers at all → the lane value itself (single-clip files:
|
|
209
|
+
* display and authored spaces coincide for want of any other anchor).
|
|
210
|
+
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
|
|
211
|
+
* never here.
|
|
212
|
+
*/
|
|
213
|
+
export function authoredTrackForLane(
|
|
214
|
+
lane: number,
|
|
215
|
+
elements: TimelineElement[],
|
|
216
|
+
dragged: TimelineElement,
|
|
217
|
+
): number {
|
|
218
|
+
const dragKey = keyOf(dragged);
|
|
219
|
+
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
|
|
220
|
+
const occupant = peers.find((e) => e.track === lane);
|
|
221
|
+
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
|
222
|
+
let nearest: TimelineElement | null = null;
|
|
223
|
+
for (const p of peers) {
|
|
224
|
+
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
|
|
225
|
+
}
|
|
226
|
+
if (!nearest) return lane;
|
|
227
|
+
// Rounded: expanded children live on FRACTIONAL synthetic display rows (see
|
|
228
|
+
// buildChildElements), so a lane distance measured against one can carry a
|
|
229
|
+
// fraction — an authored data-track-index must stay an integer.
|
|
230
|
+
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
|
|
231
|
+
}
|
|
232
|
+
|
|
146
233
|
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
147
234
|
if (trackOrder.length === 0) return 0;
|
|
148
235
|
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
@@ -251,6 +338,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
251
338
|
const dragEdit: TimelineMoveEdit = {
|
|
252
339
|
element: drag.element,
|
|
253
340
|
updates: { start: drag.previewStart, track: drag.previewTrack },
|
|
341
|
+
persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
|
|
254
342
|
};
|
|
255
343
|
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
|
|
256
344
|
|
|
@@ -265,12 +353,21 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
265
353
|
// The drop-intent set for the z-sync: the dragged clip at its new lane, others
|
|
266
354
|
// as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
|
|
267
355
|
// move; computeStackingPatches only compares lanes relatively.
|
|
268
|
-
const candidate = elements.map((e) =>
|
|
269
|
-
keyOf(e) === dragKey
|
|
270
|
-
|
|
356
|
+
const candidate = elements.map((e) => {
|
|
357
|
+
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
|
|
358
|
+
// Selection members shift in time with the drag — the z-sync must reason on
|
|
359
|
+
// their POST-move overlap sets, same as the insert branch's candidate.
|
|
360
|
+
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
361
|
+
return e;
|
|
362
|
+
});
|
|
271
363
|
const multiKeys = multi ? multi.keys : null;
|
|
272
|
-
|
|
273
|
-
|
|
364
|
+
if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
|
|
365
|
+
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
void runLaneZGesture({
|
|
369
|
+
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
|
370
|
+
commitZ: () =>
|
|
274
371
|
syncStackingForEdit(
|
|
275
372
|
candidate,
|
|
276
373
|
dragKey,
|
|
@@ -279,67 +376,123 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
279
376
|
multiKeys,
|
|
280
377
|
deps,
|
|
281
378
|
coalesceKey,
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
});
|
|
379
|
+
),
|
|
380
|
+
}).catch(() => undefined);
|
|
285
381
|
}
|
|
286
382
|
|
|
287
|
-
/**
|
|
288
|
-
*
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
*/
|
|
295
|
-
function commitTrackInsert(
|
|
296
|
-
drag: DraggedClipState,
|
|
297
|
-
deps: DragCommitDeps,
|
|
383
|
+
/** Build the one sanctioned multi-clip write: atomically insert and compact a
|
|
384
|
+
* source-file zone, then let the caller sync the deliberate vertical stacking. */
|
|
385
|
+
// fallow-ignore-next-line complexity
|
|
386
|
+
function buildTrackInsertEdits(
|
|
387
|
+
element: TimelineElement,
|
|
388
|
+
previewStart: number,
|
|
389
|
+
insertRow: number,
|
|
298
390
|
multi: {
|
|
299
391
|
keys: ReadonlySet<string>;
|
|
300
392
|
movedStart: (e: TimelineElement) => number;
|
|
301
393
|
} | null,
|
|
302
|
-
|
|
394
|
+
deps: DragCommitDeps,
|
|
395
|
+
): { candidate: TimelineElement[]; edits: TimelineMoveEdit[] } | null {
|
|
303
396
|
const { elements, trackOrder } = deps;
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
397
|
+
const editKey = keyOf(element);
|
|
398
|
+
// Expanded-child rows are synthetic host lanes, not source-file topology.
|
|
399
|
+
if (element.expandedParentStart != null) return null;
|
|
400
|
+
const targetTrack = insertTrackValue(trackOrder, insertRow);
|
|
308
401
|
const candidate = elements.map((e) => {
|
|
309
|
-
if (keyOf(e) ===
|
|
402
|
+
if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
|
|
310
403
|
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
311
404
|
return e;
|
|
312
405
|
});
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
const
|
|
406
|
+
// Foreign display rows and the opposite zone must not affect this topology.
|
|
407
|
+
const writableZone = classifyZone(element);
|
|
408
|
+
const writable = (src: TimelineElement): boolean =>
|
|
409
|
+
sameSourceFile(src, element) &&
|
|
410
|
+
classifyZone(src) === writableZone &&
|
|
411
|
+
src.expandedParentStart == null;
|
|
412
|
+
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
|
413
|
+
(a, b) => a - b,
|
|
414
|
+
);
|
|
415
|
+
const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
|
|
416
|
+
const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
|
|
417
|
+
const normalized = normalizeToZones(
|
|
418
|
+
elements.filter(writable).map((e) => {
|
|
419
|
+
if (keyOf(e) === editKey) {
|
|
420
|
+
return { ...e, start: previewStart, track: topologyTargetTrack };
|
|
421
|
+
}
|
|
422
|
+
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
423
|
+
return e;
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
316
426
|
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
|
|
427
|
+
// A partial zone renumber creates collisions; refuse a shifted locked row.
|
|
428
|
+
for (const norm of normalized) {
|
|
429
|
+
const src = bySrc.get(keyOf(norm));
|
|
430
|
+
if (
|
|
431
|
+
src &&
|
|
432
|
+
writable(src) &&
|
|
433
|
+
!canMoveElement(src) &&
|
|
434
|
+
norm.track !== (src.authoredTrack ?? src.track)
|
|
435
|
+
) {
|
|
436
|
+
console.warn(
|
|
437
|
+
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
|
|
438
|
+
);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
317
442
|
const edits: TimelineMoveEdit[] = [];
|
|
443
|
+
if (multi) {
|
|
444
|
+
for (const src of elements) {
|
|
445
|
+
const srcKey = keyOf(src);
|
|
446
|
+
if (srcKey !== editKey && multi.keys.has(srcKey) && !writable(src) && canMoveElement(src)) {
|
|
447
|
+
edits.push({
|
|
448
|
+
element: src,
|
|
449
|
+
updates: { start: multi.movedStart(src), track: src.track },
|
|
450
|
+
persistTrack: src.authoredTrack,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
318
455
|
for (const norm of normalized) {
|
|
319
456
|
const src = bySrc.get(keyOf(norm));
|
|
320
|
-
if (!src) continue;
|
|
321
|
-
// Capabilities gate: never write a locked/implicit clip, even one only swept
|
|
322
|
-
// along by the renumber (not just a marquee member).
|
|
323
|
-
if (!canMoveElement(src)) continue;
|
|
457
|
+
if (!src || !canMoveElement(src)) continue;
|
|
324
458
|
const start =
|
|
325
|
-
keyOf(norm) ===
|
|
326
|
-
? (multi?.movedStart(src) ??
|
|
459
|
+
keyOf(norm) === editKey || multi?.keys.has(keyOf(norm))
|
|
460
|
+
? (multi?.movedStart(src) ?? previewStart)
|
|
327
461
|
: src.start;
|
|
328
462
|
edits.push({ element: src, updates: { start, track: norm.track } });
|
|
329
463
|
}
|
|
464
|
+
return { candidate, edits };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function commitTrackInsert(
|
|
468
|
+
drag: DraggedClipState,
|
|
469
|
+
deps: DragCommitDeps,
|
|
470
|
+
multi: {
|
|
471
|
+
keys: ReadonlySet<string>;
|
|
472
|
+
movedStart: (e: TimelineElement) => number;
|
|
473
|
+
} | null,
|
|
474
|
+
): void {
|
|
475
|
+
const dragKey = keyOf(drag.element);
|
|
476
|
+
const built = buildTrackInsertEdits(
|
|
477
|
+
drag.element,
|
|
478
|
+
drag.previewStart,
|
|
479
|
+
drag.insertRow!,
|
|
480
|
+
multi,
|
|
481
|
+
deps,
|
|
482
|
+
);
|
|
483
|
+
if (!built) return;
|
|
484
|
+
const { candidate, edits } = built;
|
|
485
|
+
if (edits.length === 0) return;
|
|
330
486
|
|
|
331
487
|
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
// fractional insert lane) — NOT the re-normalized lanes — so the sync sees
|
|
341
|
-
// the user's move. The guard lane is the aimed insert row (a boundary in
|
|
342
|
-
// display-lane space, comparable to the clip's contiguous current lane).
|
|
488
|
+
if (!deps.readZIndex || !deps.onStackingPatches) {
|
|
489
|
+
void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
void runLaneZGesture({
|
|
493
|
+
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
|
494
|
+
commitZ: () =>
|
|
495
|
+
// Sync from the fractional drop intent, not the normalized persisted lanes.
|
|
343
496
|
syncStackingForEdit(
|
|
344
497
|
candidate,
|
|
345
498
|
dragKey,
|
|
@@ -348,9 +501,54 @@ function commitTrackInsert(
|
|
|
348
501
|
multi ? multi.keys : null,
|
|
349
502
|
deps,
|
|
350
503
|
coalesceKey,
|
|
351
|
-
)
|
|
352
|
-
|
|
353
|
-
|
|
504
|
+
),
|
|
505
|
+
}).catch(() => undefined);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Commit the timeline lane move that MIRRORS a canvas z-order menu action
|
|
510
|
+
* (resolveZMirrorLaneMove's non-null result). Same machinery as a lane drag:
|
|
511
|
+
*
|
|
512
|
+
* - kind "move": persistMoveEdits with `{start: element.start, track: displayTrack}`
|
|
513
|
+
* + `persistTrack` — identical shape to commitDraggedClipMove's lane-change
|
|
514
|
+
* branch (optimistic store update, authoredTrack mirror, rollback on failure).
|
|
515
|
+
* - kind "insert": buildTrackInsertEdits — the SAME renumber core commitTrackInsert
|
|
516
|
+
* uses — then the same atomic persist.
|
|
517
|
+
*
|
|
518
|
+
* Deliberately NO syncStackingForEdit here: the z values were just set by the
|
|
519
|
+
* user's menu action, and the lane→z sync would recompute (and fight) them. The
|
|
520
|
+
* mirror caller also omits `readZIndex`/`onStackingPatches` from `deps`, so even
|
|
521
|
+
* a future call into the sync would no-op (double protection; see
|
|
522
|
+
* useCanvasZOrderTimelineMirror).
|
|
523
|
+
*
|
|
524
|
+
* `coalesceKey` MUST be the z persist's key (`z-reorder:<action>:<ids>:g<seq>`)
|
|
525
|
+
* so editHistory folds the z write and this track write into ONE undo entry, and
|
|
526
|
+
* `coalesceMs` MUST widen this record's fold window: the mirror only runs after
|
|
527
|
+
* the z persist's server round-trip resolved, so under real network latency the
|
|
528
|
+
* gap between the two records exceeds the reducer's 300ms default and the fold
|
|
529
|
+
* would never happen live. The key is unique per gesture, so an unbounded
|
|
530
|
+
* window can never merge distinct gestures.
|
|
531
|
+
*
|
|
532
|
+
* Resolves `true` once the move persisted, `false` on rollback / refused insert.
|
|
533
|
+
*/
|
|
534
|
+
export function commitZMirrorLaneMove(
|
|
535
|
+
element: TimelineElement,
|
|
536
|
+
move: NonNullable<ZMirrorLaneMove>,
|
|
537
|
+
deps: DragCommitDeps,
|
|
538
|
+
coalesceKey: string,
|
|
539
|
+
coalesceMs?: number,
|
|
540
|
+
): Promise<boolean> {
|
|
541
|
+
if (move.kind === "move") {
|
|
542
|
+
const edit: TimelineMoveEdit = {
|
|
543
|
+
element,
|
|
544
|
+
updates: { start: element.start, track: move.displayTrack },
|
|
545
|
+
persistTrack: move.persistTrack,
|
|
546
|
+
};
|
|
547
|
+
return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
|
|
548
|
+
}
|
|
549
|
+
const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
|
|
550
|
+
if (!built || built.edits.length === 0) return Promise.resolve(false);
|
|
551
|
+
return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
|
|
354
552
|
}
|
|
355
553
|
|
|
356
554
|
/**
|
|
@@ -371,18 +569,16 @@ function syncStackingForEdit(
|
|
|
371
569
|
multiKeys: ReadonlySet<string> | null,
|
|
372
570
|
deps: DragCommitDeps,
|
|
373
571
|
coalesceKey?: string,
|
|
374
|
-
): void {
|
|
572
|
+
): Promise<void> {
|
|
375
573
|
const { readZIndex, onStackingPatches } = deps;
|
|
376
|
-
if (!readZIndex || !onStackingPatches) return;
|
|
574
|
+
if (!readZIndex || !onStackingPatches) return Promise.resolve();
|
|
377
575
|
|
|
378
576
|
// Aiming at the clip's OWN current display lane is not a relocation — never
|
|
379
577
|
// touch z (guards the pure-time-move invariant even if a spurious topology call
|
|
380
578
|
// slips through). Every real lane-realization drop aims at a DIFFERENT lane.
|
|
381
|
-
if (aimedLane === currentLane) return;
|
|
579
|
+
if (aimedLane === currentLane) return Promise.resolve();
|
|
382
580
|
|
|
383
|
-
//
|
|
384
|
-
// position. Equal-z clips paint by DOM order, so the sync needs it to decide
|
|
385
|
-
// "is A above B" (see StackingElement.domIndex).
|
|
581
|
+
// Discovery order is DOM order, which breaks equal-z ties.
|
|
386
582
|
const stackingEls = candidate.map((el, domIndex) => ({
|
|
387
583
|
key: keyOf(el),
|
|
388
584
|
start: el.start,
|
|
@@ -390,12 +586,15 @@ function syncStackingForEdit(
|
|
|
390
586
|
track: el.track,
|
|
391
587
|
zIndex: readZIndex(el),
|
|
392
588
|
isAudio: classifyZone(el) === "audio",
|
|
589
|
+
sourceFile: el.sourceFile,
|
|
393
590
|
domIndex,
|
|
591
|
+
stackingContextId: el.stackingContextId ?? null,
|
|
394
592
|
}));
|
|
395
593
|
|
|
396
594
|
const editedKeys = [dragKey];
|
|
397
595
|
if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
|
|
398
596
|
|
|
399
597
|
const patches = computeStackingPatches(stackingEls, editedKeys);
|
|
400
|
-
if (patches.length
|
|
598
|
+
if (patches.length === 0) return Promise.resolve();
|
|
599
|
+
return Promise.resolve(onStackingPatches(patches, coalesceKey)).then(() => undefined);
|
|
401
600
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
|
4
|
+
import {
|
|
5
|
+
canShiftTrackGapClips,
|
|
6
|
+
commitCloseAllTrackGaps,
|
|
7
|
+
commitCloseTrackGap,
|
|
8
|
+
} from "./timelineGapCommit";
|
|
9
|
+
import { resolveAllTrackGaps } from "./timelineGaps";
|
|
10
|
+
|
|
11
|
+
function el(id: string, start: number, duration: number, track = 0): TimelineElement {
|
|
12
|
+
// domId + video tag → getTimelineEditCapabilities(...).canMove === true
|
|
13
|
+
return { id, tag: "video", start, duration, track, domId: id };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function lockedEl(id: string, start: number, duration: number): TimelineElement {
|
|
17
|
+
return { ...el(id, start, duration), timelineLocked: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeDeps(laneElements: TimelineElement[]) {
|
|
21
|
+
const onMoveElements = vi.fn(() => Promise.resolve());
|
|
22
|
+
const updateElement = vi.fn();
|
|
23
|
+
const deps: DragCommitDeps = {
|
|
24
|
+
elements: laneElements,
|
|
25
|
+
trackOrder: [0],
|
|
26
|
+
updateElement,
|
|
27
|
+
onMoveElements,
|
|
28
|
+
};
|
|
29
|
+
return { deps, onMoveElements, updateElement };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Assert exactly ONE atomic persist batch; return its flattened edits + coalesce key. */
|
|
33
|
+
function singleBatch(onMoveElements: ReturnType<typeof vi.fn>) {
|
|
34
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
35
|
+
const [edits, coalesceKey] = onMoveElements.mock.calls[0] as unknown as [
|
|
36
|
+
Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
|
|
37
|
+
string,
|
|
38
|
+
];
|
|
39
|
+
return { coalesceKey, edits: edits.map((e) => ({ id: e.element.id, ...e.updates })) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("commitCloseTrackGap", () => {
|
|
43
|
+
it("persists ONE atomic batch shifting the next clip and every clip after it", () => {
|
|
44
|
+
const lane = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
45
|
+
const { deps, onMoveElements } = makeDeps(lane);
|
|
46
|
+
|
|
47
|
+
expect(commitCloseTrackGap(lane, 3, deps)).toBe(true);
|
|
48
|
+
|
|
49
|
+
const { edits, coalesceKey } = singleBatch(onMoveElements);
|
|
50
|
+
// Gap is [2, 5) → width 3; b and c shift left by 3, tracks unchanged.
|
|
51
|
+
expect(edits).toEqual([
|
|
52
|
+
{ id: "b", start: 2, track: 0 },
|
|
53
|
+
{ id: "c", start: 7, track: 0 },
|
|
54
|
+
]);
|
|
55
|
+
expect(typeof coalesceKey).toBe("string");
|
|
56
|
+
expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("optimistically applies the same starts to the store", () => {
|
|
60
|
+
const lane = [el("a", 0, 2), el("b", 5, 3)];
|
|
61
|
+
const { deps, updateElement } = makeDeps(lane);
|
|
62
|
+
commitCloseTrackGap(lane, 3, deps);
|
|
63
|
+
expect(updateElement).toHaveBeenCalledWith("b", { start: 2, track: 0 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("closes the leading gap (first clip lands at 0)", () => {
|
|
67
|
+
const lane = [el("a", 2, 3), el("b", 6, 1)];
|
|
68
|
+
const { deps, onMoveElements } = makeDeps(lane);
|
|
69
|
+
|
|
70
|
+
expect(commitCloseTrackGap(lane, 1, deps)).toBe(true);
|
|
71
|
+
const [edits] = onMoveElements.mock.calls[0] as unknown as [
|
|
72
|
+
Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
|
|
73
|
+
];
|
|
74
|
+
expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
|
|
75
|
+
{ id: "a", start: 0 },
|
|
76
|
+
{ id: "b", start: 4 },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("uses a fresh coalesce key per gesture", () => {
|
|
81
|
+
const lane = [el("a", 0, 2), el("b", 5, 3)];
|
|
82
|
+
const first = makeDeps(lane);
|
|
83
|
+
const second = makeDeps(lane);
|
|
84
|
+
commitCloseTrackGap(lane, 3, first.deps);
|
|
85
|
+
commitCloseTrackGap(lane, 3, second.deps);
|
|
86
|
+
const keyA = first.onMoveElements.mock.calls[0][1 as never];
|
|
87
|
+
const keyB = second.onMoveElements.mock.calls[0][1 as never];
|
|
88
|
+
expect(keyA).not.toEqual(keyB);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("refuses (no write) when there is no clip right of the point", () => {
|
|
92
|
+
const lane = [el("a", 0, 2)];
|
|
93
|
+
const { deps, onMoveElements, updateElement } = makeDeps(lane);
|
|
94
|
+
expect(commitCloseTrackGap(lane, 5, deps)).toBe(false);
|
|
95
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
96
|
+
expect(updateElement).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("refuses (no partial compaction) when ANY shifting clip is unmovable", () => {
|
|
100
|
+
const lane = [el("a", 0, 2), el("b", 5, 3), lockedEl("c", 10, 1)];
|
|
101
|
+
const { deps, onMoveElements, updateElement } = makeDeps(lane);
|
|
102
|
+
expect(commitCloseTrackGap(lane, 3, deps)).toBe(false);
|
|
103
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
104
|
+
expect(updateElement).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("commitCloseAllTrackGaps", () => {
|
|
109
|
+
it("compacts the whole lane in ONE atomic batch (leading gap included)", () => {
|
|
110
|
+
const lane = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
111
|
+
const { deps, onMoveElements } = makeDeps(lane);
|
|
112
|
+
|
|
113
|
+
expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
|
|
114
|
+
|
|
115
|
+
const { edits, coalesceKey } = singleBatch(onMoveElements);
|
|
116
|
+
expect(edits).toEqual([
|
|
117
|
+
{ id: "a", start: 0, track: 0 },
|
|
118
|
+
{ id: "b", start: 2, track: 0 },
|
|
119
|
+
{ id: "c", start: 5, track: 0 },
|
|
120
|
+
]);
|
|
121
|
+
expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("refuses when the track is already contiguous (no gaps)", () => {
|
|
125
|
+
const lane = [el("a", 0, 2), el("b", 2, 3)];
|
|
126
|
+
const { deps, onMoveElements } = makeDeps(lane);
|
|
127
|
+
expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
|
|
128
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("refuses when any shifting clip is unmovable, even if others could move", () => {
|
|
132
|
+
const lane = [lockedEl("a", 1, 2), el("b", 5, 3)];
|
|
133
|
+
const { deps, onMoveElements, updateElement } = makeDeps(lane);
|
|
134
|
+
expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
|
|
135
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
136
|
+
expect(updateElement).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("proceeds when an unmovable clip does NOT need to shift", () => {
|
|
140
|
+
// Locked clip already sits flush at 0 — only movable clips shift.
|
|
141
|
+
const lane = [lockedEl("a", 0, 2), el("b", 4, 1)];
|
|
142
|
+
const { deps, onMoveElements } = makeDeps(lane);
|
|
143
|
+
expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
|
|
144
|
+
const [edits] = onMoveElements.mock.calls[0] as unknown as [
|
|
145
|
+
Array<{ element: TimelineElement; updates: { start: number } }>,
|
|
146
|
+
];
|
|
147
|
+
expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
|
|
148
|
+
{ id: "b", start: 2 },
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("canShiftTrackGapClips", () => {
|
|
154
|
+
it("is true only when every named clip is movable", () => {
|
|
155
|
+
const lane = [el("a", 1, 2), lockedEl("b", 5, 3)];
|
|
156
|
+
expect(canShiftTrackGapClips(lane, [{ key: "a", newStart: 0 }])).toBe(true);
|
|
157
|
+
expect(canShiftTrackGapClips(lane, resolveAllTrackGaps(lane))).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("is false for unknown keys", () => {
|
|
161
|
+
expect(canShiftTrackGapClips([el("a", 0, 1)], [{ key: "ghost", newStart: 0 }])).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|