@hyperframes/studio 0.7.57 → 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--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.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 +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- 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 +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- 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/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- 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/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- 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 +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- 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/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- 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 +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -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/dist/assets/index-_pqzyxB1.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,6 +11,7 @@ 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 {
|
|
@@ -31,11 +35,14 @@ export interface DragCommitDeps {
|
|
|
31
35
|
/** Atomic multi-clip persist (single undo) for lane changes + track inserts.
|
|
32
36
|
* `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
|
|
33
37
|
* history entry so it merges with the lane change's z-reorder entry (see the
|
|
34
|
-
* 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. */
|
|
35
41
|
onMoveElements?: (
|
|
36
42
|
edits: TimelineMoveEdit[],
|
|
37
43
|
coalesceKey?: string,
|
|
38
44
|
operation?: TimelineMoveOperation,
|
|
45
|
+
coalesceMs?: number,
|
|
39
46
|
) => Promise<void> | void;
|
|
40
47
|
/**
|
|
41
48
|
* The current multi-selection (store.selectedElementIds). When the dragged
|
|
@@ -60,18 +67,13 @@ export interface DragCommitDeps {
|
|
|
60
67
|
* the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
|
|
61
68
|
* research/STAGE3-NEEDED-WIRING.md.
|
|
62
69
|
*/
|
|
63
|
-
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
|
|
70
|
+
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
67
74
|
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
68
75
|
|
|
69
|
-
// One coalesce key
|
|
70
|
-
// entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
|
|
71
|
-
// layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
|
|
72
|
-
// records into a single undo step. A monotonic counter — NOT Date.now() /
|
|
73
|
-
// Math.random(), which the determinism rules forbid — suffices: the key only has
|
|
74
|
-
// 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.
|
|
75
77
|
let laneChangeGestureSeq = 0;
|
|
76
78
|
|
|
77
79
|
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
|
@@ -101,12 +103,17 @@ function canMoveElement(element: TimelineElement): boolean {
|
|
|
101
103
|
* `false` so the caller also skips the z-sync (no orphaned z patch).
|
|
102
104
|
*
|
|
103
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.
|
|
104
110
|
*/
|
|
105
|
-
function persistMoveEdits(
|
|
111
|
+
export function persistMoveEdits(
|
|
106
112
|
edits: TimelineMoveEdit[],
|
|
107
113
|
deps: DragCommitDeps,
|
|
108
114
|
coalesceKey?: string,
|
|
109
115
|
operation: TimelineMoveOperation = "timing",
|
|
116
|
+
coalesceMs?: number,
|
|
110
117
|
): Promise<boolean> {
|
|
111
118
|
if (edits.length === 0) return Promise.resolve(true);
|
|
112
119
|
const { updateElement, onMoveElement, onMoveElements } = deps;
|
|
@@ -148,7 +155,7 @@ function persistMoveEdits(
|
|
|
148
155
|
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
|
|
149
156
|
);
|
|
150
157
|
const persisted = onMoveElements
|
|
151
|
-
? onMoveElements(persistEdits, coalesceKey, operation)
|
|
158
|
+
? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
|
|
152
159
|
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
|
153
160
|
return Promise.resolve(persisted).then(
|
|
154
161
|
() => true,
|
|
@@ -173,7 +180,7 @@ function persistMoveEdits(
|
|
|
173
180
|
/** Same-source-file predicate: authored track numbers only compare within ONE
|
|
174
181
|
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
|
|
175
182
|
* file, not the host timeline's). `undefined` means the active composition. */
|
|
176
|
-
const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
183
|
+
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
177
184
|
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
|
178
185
|
|
|
179
186
|
/**
|
|
@@ -203,7 +210,7 @@ const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
|
203
210
|
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
|
|
204
211
|
* never here.
|
|
205
212
|
*/
|
|
206
|
-
function authoredTrackForLane(
|
|
213
|
+
export function authoredTrackForLane(
|
|
207
214
|
lane: number,
|
|
208
215
|
elements: TimelineElement[],
|
|
209
216
|
dragged: TimelineElement,
|
|
@@ -217,7 +224,10 @@ function authoredTrackForLane(
|
|
|
217
224
|
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
|
|
218
225
|
}
|
|
219
226
|
if (!nearest) return lane;
|
|
220
|
-
|
|
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));
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
@@ -351,8 +361,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
351
361
|
return e;
|
|
352
362
|
});
|
|
353
363
|
const multiKeys = multi ? multi.keys : null;
|
|
354
|
-
|
|
355
|
-
|
|
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: () =>
|
|
356
371
|
syncStackingForEdit(
|
|
357
372
|
candidate,
|
|
358
373
|
dragKey,
|
|
@@ -361,81 +376,123 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
361
376
|
multiKeys,
|
|
362
377
|
deps,
|
|
363
378
|
coalesceKey,
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
});
|
|
379
|
+
),
|
|
380
|
+
}).catch(() => undefined);
|
|
367
381
|
}
|
|
368
382
|
|
|
369
|
-
/**
|
|
370
|
-
*
|
|
371
|
-
* fractional insert lane; normalizeToZones then compacts every lane to a contiguous
|
|
372
|
-
* integer, which shifts the clips at/below the insert down by one. That +1
|
|
373
|
-
* renumber is the ONLY sanctioned multi-clip write; it is index-only (never z).
|
|
374
|
-
* The whole affected set is persisted atomically (single undo), and the deliberate
|
|
375
|
-
* vertical move syncs the dragged clip's stacking afterwards.
|
|
376
|
-
*/
|
|
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. */
|
|
377
385
|
// fallow-ignore-next-line complexity
|
|
378
|
-
function
|
|
379
|
-
|
|
380
|
-
|
|
386
|
+
function buildTrackInsertEdits(
|
|
387
|
+
element: TimelineElement,
|
|
388
|
+
previewStart: number,
|
|
389
|
+
insertRow: number,
|
|
381
390
|
multi: {
|
|
382
391
|
keys: ReadonlySet<string>;
|
|
383
392
|
movedStart: (e: TimelineElement) => number;
|
|
384
393
|
} | null,
|
|
385
|
-
|
|
394
|
+
deps: DragCommitDeps,
|
|
395
|
+
): { candidate: TimelineElement[]; edits: TimelineMoveEdit[] } | null {
|
|
386
396
|
const { elements, trackOrder } = deps;
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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);
|
|
391
401
|
const candidate = elements.map((e) => {
|
|
392
|
-
if (keyOf(e) ===
|
|
402
|
+
if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
|
|
393
403
|
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
394
404
|
return e;
|
|
395
405
|
});
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
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
|
+
);
|
|
399
426
|
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
|
|
400
|
-
//
|
|
401
|
-
// clip whose lane shifts leaves its track colliding with a renumbered
|
|
402
|
-
// neighbour, and the next normalize merges the two lanes. If any shifted clip
|
|
403
|
-
// can't be written, refuse the insert instead of persisting a broken layout.
|
|
427
|
+
// A partial zone renumber creates collisions; refuse a shifted locked row.
|
|
404
428
|
for (const norm of normalized) {
|
|
405
429
|
const src = bySrc.get(keyOf(norm));
|
|
406
|
-
if (
|
|
430
|
+
if (
|
|
431
|
+
src &&
|
|
432
|
+
writable(src) &&
|
|
433
|
+
!canMoveElement(src) &&
|
|
434
|
+
norm.track !== (src.authoredTrack ?? src.track)
|
|
435
|
+
) {
|
|
407
436
|
console.warn(
|
|
408
437
|
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
|
|
409
438
|
);
|
|
410
|
-
return;
|
|
439
|
+
return null;
|
|
411
440
|
}
|
|
412
441
|
}
|
|
413
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
|
+
}
|
|
414
455
|
for (const norm of normalized) {
|
|
415
456
|
const src = bySrc.get(keyOf(norm));
|
|
416
|
-
if (!src) continue;
|
|
417
|
-
// Capabilities gate (unchanged-lane clips only reach here now): never write
|
|
418
|
-
// a locked/implicit clip.
|
|
419
|
-
if (!canMoveElement(src)) continue;
|
|
457
|
+
if (!src || !canMoveElement(src)) continue;
|
|
420
458
|
const start =
|
|
421
|
-
keyOf(norm) ===
|
|
422
|
-
? (multi?.movedStart(src) ??
|
|
459
|
+
keyOf(norm) === editKey || multi?.keys.has(keyOf(norm))
|
|
460
|
+
? (multi?.movedStart(src) ?? previewStart)
|
|
423
461
|
: src.start;
|
|
424
462
|
edits.push({ element: src, updates: { start, track: norm.track } });
|
|
425
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;
|
|
426
486
|
|
|
427
487
|
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
// fractional insert lane) — NOT the re-normalized lanes — so the sync sees
|
|
437
|
-
// the user's move. The guard lane is the aimed insert row (a boundary in
|
|
438
|
-
// 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.
|
|
439
496
|
syncStackingForEdit(
|
|
440
497
|
candidate,
|
|
441
498
|
dragKey,
|
|
@@ -444,9 +501,54 @@ function commitTrackInsert(
|
|
|
444
501
|
multi ? multi.keys : null,
|
|
445
502
|
deps,
|
|
446
503
|
coalesceKey,
|
|
447
|
-
)
|
|
448
|
-
|
|
449
|
-
|
|
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);
|
|
450
552
|
}
|
|
451
553
|
|
|
452
554
|
/**
|
|
@@ -467,18 +569,16 @@ function syncStackingForEdit(
|
|
|
467
569
|
multiKeys: ReadonlySet<string> | null,
|
|
468
570
|
deps: DragCommitDeps,
|
|
469
571
|
coalesceKey?: string,
|
|
470
|
-
): void {
|
|
572
|
+
): Promise<void> {
|
|
471
573
|
const { readZIndex, onStackingPatches } = deps;
|
|
472
|
-
if (!readZIndex || !onStackingPatches) return;
|
|
574
|
+
if (!readZIndex || !onStackingPatches) return Promise.resolve();
|
|
473
575
|
|
|
474
576
|
// Aiming at the clip's OWN current display lane is not a relocation — never
|
|
475
577
|
// touch z (guards the pure-time-move invariant even if a spurious topology call
|
|
476
578
|
// slips through). Every real lane-realization drop aims at a DIFFERENT lane.
|
|
477
|
-
if (aimedLane === currentLane) return;
|
|
579
|
+
if (aimedLane === currentLane) return Promise.resolve();
|
|
478
580
|
|
|
479
|
-
//
|
|
480
|
-
// position. Equal-z clips paint by DOM order, so the sync needs it to decide
|
|
481
|
-
// "is A above B" (see StackingElement.domIndex).
|
|
581
|
+
// Discovery order is DOM order, which breaks equal-z ties.
|
|
482
582
|
const stackingEls = candidate.map((el, domIndex) => ({
|
|
483
583
|
key: keyOf(el),
|
|
484
584
|
start: el.start,
|
|
@@ -486,6 +586,7 @@ function syncStackingForEdit(
|
|
|
486
586
|
track: el.track,
|
|
487
587
|
zIndex: readZIndex(el),
|
|
488
588
|
isAudio: classifyZone(el) === "audio",
|
|
589
|
+
sourceFile: el.sourceFile,
|
|
489
590
|
domIndex,
|
|
490
591
|
stackingContextId: el.stackingContextId ?? null,
|
|
491
592
|
}));
|
|
@@ -494,5 +595,6 @@ function syncStackingForEdit(
|
|
|
494
595
|
if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
|
|
495
596
|
|
|
496
597
|
const patches = computeStackingPatches(stackingEls, editedKeys);
|
|
497
|
-
if (patches.length
|
|
598
|
+
if (patches.length === 0) return Promise.resolve();
|
|
599
|
+
return Promise.resolve(onStackingPatches(patches, coalesceKey)).then(() => undefined);
|
|
498
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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
3
|
+
import {
|
|
4
|
+
persistMoveEdits,
|
|
5
|
+
type DragCommitDeps,
|
|
6
|
+
type TimelineMoveEdit,
|
|
7
|
+
} from "./timelineClipDragCommit";
|
|
8
|
+
import {
|
|
9
|
+
laneGapFloor,
|
|
10
|
+
resolveAllTrackGaps,
|
|
11
|
+
resolveCloseGapShifts,
|
|
12
|
+
resolveTrackGapAt,
|
|
13
|
+
type TrackGapShift,
|
|
14
|
+
} from "./timelineGaps";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Commit layer for the track-gap context menu ("Close gap" / "Close all gaps").
|
|
18
|
+
*
|
|
19
|
+
* Each action is ONE atomic {@link persistMoveEdits} batch — pure time moves
|
|
20
|
+
* (`updates.track === element.track`, no authored-track rewrite) tagged with a
|
|
21
|
+
* per-gesture-unique coalesce key, so an action is exactly one undo entry and
|
|
22
|
+
* flows through the existing move pipeline (optimistic store apply + rollback,
|
|
23
|
+
* SDK fast path, patchIframeDomTiming preview).
|
|
24
|
+
*
|
|
25
|
+
* Refusal rule: if ANY clip that must shift is unmovable
|
|
26
|
+
* ({@link getTimelineEditCapabilities}.canMove === false), the whole action is
|
|
27
|
+
* refused — never a partial compaction. The menu disables the item via
|
|
28
|
+
* {@link canShiftTrackGapClips}; the commit re-checks as defense in depth.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
32
|
+
|
|
33
|
+
// Per-gesture-unique coalesce key. A monotonic counter — NOT Date.now() /
|
|
34
|
+
// Math.random() (determinism rules) — mirrors laneChangeGestureSeq in
|
|
35
|
+
// timelineClipDragCommit.ts.
|
|
36
|
+
let gapCloseGestureSeq = 0;
|
|
37
|
+
|
|
38
|
+
/** True when every clip named in `shifts` may be time-moved. */
|
|
39
|
+
export function canShiftTrackGapClips(
|
|
40
|
+
laneElements: readonly TimelineElement[],
|
|
41
|
+
shifts: readonly TrackGapShift[],
|
|
42
|
+
): boolean {
|
|
43
|
+
const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
|
|
44
|
+
return shifts.every((s) => {
|
|
45
|
+
const element = byKey.get(s.key);
|
|
46
|
+
return element != null && getTimelineEditCapabilities(element).canMove;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildShiftEdits(
|
|
51
|
+
laneElements: readonly TimelineElement[],
|
|
52
|
+
shifts: readonly TrackGapShift[],
|
|
53
|
+
): TimelineMoveEdit[] | null {
|
|
54
|
+
if (shifts.length === 0 || !canShiftTrackGapClips(laneElements, shifts)) return null;
|
|
55
|
+
const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
|
|
56
|
+
return shifts.map((s) => {
|
|
57
|
+
const element = byKey.get(s.key)!;
|
|
58
|
+
return { element, updates: { start: s.newStart, track: element.track } };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commitShifts(
|
|
63
|
+
laneElements: readonly TimelineElement[],
|
|
64
|
+
shifts: readonly TrackGapShift[],
|
|
65
|
+
deps: DragCommitDeps,
|
|
66
|
+
): boolean {
|
|
67
|
+
const edits = buildShiftEdits(laneElements, shifts);
|
|
68
|
+
if (!edits) return false;
|
|
69
|
+
void persistMoveEdits(edits, deps, `track-gap-close:${gapCloseGestureSeq++}`);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Close the ONE gap under `time` on the lane: the next clip and every clip
|
|
75
|
+
* after it on that lane shift left by the gap's width. Returns false (and
|
|
76
|
+
* writes nothing) when there is no gap at the point or a shifting clip is
|
|
77
|
+
* unmovable.
|
|
78
|
+
*/
|
|
79
|
+
export function commitCloseTrackGap(
|
|
80
|
+
laneElements: readonly TimelineElement[],
|
|
81
|
+
time: number,
|
|
82
|
+
deps: DragCommitDeps,
|
|
83
|
+
): boolean {
|
|
84
|
+
const gap = resolveTrackGapAt(laneElements, time, undefined, laneGapFloor(laneElements));
|
|
85
|
+
if (!gap) return false;
|
|
86
|
+
return commitShifts(laneElements, resolveCloseGapShifts(laneElements, gap), deps);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compact the whole lane (leading gap included): clips become contiguous from
|
|
91
|
+
* 0, order and durations preserved. Returns false (and writes nothing) when
|
|
92
|
+
* the lane has no gaps or a shifting clip is unmovable.
|
|
93
|
+
*/
|
|
94
|
+
export function commitCloseAllTrackGaps(
|
|
95
|
+
laneElements: readonly TimelineElement[],
|
|
96
|
+
deps: DragCommitDeps,
|
|
97
|
+
): boolean {
|
|
98
|
+
return commitShifts(
|
|
99
|
+
laneElements,
|
|
100
|
+
resolveAllTrackGaps(laneElements, undefined, laneGapFloor(laneElements)),
|
|
101
|
+
deps,
|
|
102
|
+
);
|
|
103
|
+
}
|