feat(studio): lane-model timeline (non-overlapping clips share a row)

Rework the timeline row model from one-row-per-stacking-layer to NLE
"lanes": a row holds a time-sequence of non-overlapping clips, ordered
top = front. Stacking only matters between clips that overlap in time.

- Lane packing: per stacking context, sort clips by effective z-index
  desc (DOM order tiebreak) and greedily pack each onto the first lane
  whose members don't overlap it in time; else open a new lane. Lane's
  representative z is the max member z. Audio stays one clip per lane.
- Lane-aware drag: dropping onto a lane joins it (clip takes the lane's
  z) when there's no time-conflict; a conflicting drop is rejected and
  the preview snaps to the nearest valid lane or a new-lane insertion.
  Between/above/below still create a new lane at that stacking level.
- Overlap checks use the drag-preview start/duration, so conflict is
  judged by where the clip lands, not where it started.

Only explicit drags write z-index; authored z is preserved and
data-track-index is never rewritten.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:26 -04:00
parent bc5fc410ee
commit ed8bf475d3
8 changed files with 458 additions and 167 deletions
@@ -158,7 +158,7 @@ describe("resolveTimelineMove", () => {
).toEqual({ start: 2, track: 2 });
});
it("resolves vertical stacking movement as a layer join without changing data-track-index", () => {
it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => {
const stackingElements = [
{
id: "root-front",
@@ -209,12 +209,12 @@ describe("resolveTimelineMove", () => {
expect(result).toEqual({
start: 0,
track: 1,
previewLayerId: layers[0]!.id,
previewLayerId: `preview:root-back:above:${layers[0]!.id}`,
previewLayerIndex: 0,
stackingReorder: {
contextKey: "root",
placement: { type: "onto", layerId: layers[0]!.id },
zIndexChanges: [{ key: "root-back", zIndex: 2 }],
placement: { type: "above", layerId: layers[0]!.id },
zIndexChanges: [{ key: "root-back", zIndex: 3 }],
},
});
});
@@ -110,10 +110,15 @@ export function resolveTimelineMove(
// Stacking mode: vertical movement writes z-index only. The authored
// data-track-index is preserved even when the pointer crosses rows.
if (input.stackingElement) {
const stackingElement = {
...input.stackingElement,
start: nextStart,
duration: input.duration,
};
const layerMove =
input.timelineLayers && input.layerOrder
? resolveTimelineLayerStackingMove({
element: input.stackingElement,
element: stackingElement,
layers: input.timelineLayers,
layerOrder: input.layerOrder,
trackDeltaRaw,
@@ -1,14 +1,23 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import type { StackingTimelineLayer } from "./timelineTrackOrder";
import { resolveTimelineLayerZIndexChanges } from "./timelineLayerDrag";
import {
resolveTimelineLayerStackingMove,
resolveTimelineLayerZIndexChanges,
} from "./timelineLayerDrag";
function element(input: { id: string; zIndex: number; tag?: string }): TimelineElement {
function element(input: {
id: string;
zIndex: number;
tag?: string;
start?: number;
duration?: number;
}): TimelineElement {
return {
id: input.id,
tag: input.tag ?? "div",
start: 0,
duration: 1,
start: input.start ?? 0,
duration: input.duration ?? 1,
track: 0,
zIndex: input.zIndex,
hasExplicitZIndex: true,
@@ -18,30 +27,48 @@ function element(input: { id: string; zIndex: number; tag?: string }): TimelineE
};
}
function layer(id: string, zIndex: number): StackingTimelineLayer {
function layer(
id: string,
zIndex: number,
elements: TimelineElement[] = [element({ id, zIndex })],
): StackingTimelineLayer {
return {
id,
kind: "visual",
contextKey: "root",
zIndex,
placementTrack: 0,
elements: [element({ id, zIndex })],
elements,
};
}
describe("resolveTimelineLayerZIndexChanges", () => {
it("joins an existing layer by assigning the dragged clip that layer's z-index", () => {
const dragged = element({ id: "dragged", zIndex: 1 });
it("joins an existing lane by assigning the dragged clip that lane's z-index", () => {
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
expect(
resolveTimelineLayerZIndexChanges({
element: dragged,
layers: [layer("front", 10), layer("back", 1)],
layers: [layer("front", 10, [front]), layer("back", 1)],
placement: { type: "onto", layerId: "front" },
})?.zIndexChanges,
).toEqual([{ key: "dragged", zIndex: 10 }]);
});
it("rejects an onto-lane join when the dragged clip would overlap that lane", () => {
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
expect(
resolveTimelineLayerZIndexChanges({
element: dragged,
layers: [layer("front", 10, [front]), layer("back", 1)],
placement: { type: "onto", layerId: "front" },
}),
).toBeNull();
});
it("interpolates a new integer z-index strictly between neighboring layers", () => {
const dragged = element({ id: "dragged", zIndex: 1 });
@@ -100,3 +127,28 @@ describe("resolveTimelineLayerZIndexChanges", () => {
).toBeNull();
});
});
describe("resolveTimelineLayerStackingMove", () => {
it("snaps a blocked onto-lane drop to the nearest new lane instead of committing the conflict", () => {
const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 });
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
expect(
resolveTimelineLayerStackingMove({
element: dragged,
layers,
layerOrder: layers.map((item) => item.id),
trackDeltaRaw: -1,
}),
).toEqual({
previewLayerId: "preview:dragged:above:front",
previewLayerIndex: 0,
stackingReorder: {
contextKey: "root",
placement: { type: "above", layerId: "front" },
zIndexChanges: [{ key: "dragged", zIndex: 11 }],
},
});
});
});
@@ -1,6 +1,10 @@
import { resolveStackingContextKey } from "../lib/layerOrdering";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
import {
timelineElementsOverlap,
type StackingTimelineLayer,
type TimelineLayerId,
} from "./timelineTrackOrder";
import {
toStackingOrderItem,
type TimelineLayerDropPlacement,
@@ -25,6 +29,18 @@ function layerContainsElement(layer: StackingTimelineLayer, key: string): boolea
return layer.elements.some((element) => getTimelineElementIdentity(element) === key);
}
function layerConflictsWithElement(
layer: StackingTimelineLayer,
element: TimelineStackingElement,
draggedKey: string,
): boolean {
return layer.elements.some(
(candidate) =>
getTimelineElementIdentity(candidate) !== draggedKey &&
timelineElementsOverlap(candidate, element),
);
}
function addElementChange(
changes: TimelineStackingZIndexChange[],
element: TimelineStackingElement,
@@ -161,6 +177,77 @@ function resolveBetweenChanges(input: {
return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes;
}
function resolveOntoChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
layerId: string;
}): TimelineStackingZIndexChange[] | null {
const target = findLayer(input.layers, input.layerId);
if (!target) return null;
if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) {
return null;
}
return resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: target.zIndex,
});
}
function resolveEdgeChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
layerId: string;
offset: number;
}): TimelineStackingZIndexChange[] | null {
const target = findLayer(input.layers, input.layerId);
if (!target) return null;
return resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: target.zIndex + input.offset,
});
}
function resolvePlacementChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
placement: TimelineLayerDropPlacement;
}): TimelineStackingZIndexChange[] | null {
switch (input.placement.type) {
case "onto":
return resolveOntoChanges({
element: input.element,
layers: input.layers,
layerId: input.placement.layerId,
});
case "above":
return resolveEdgeChanges({
element: input.element,
layers: input.layers,
layerId: input.placement.layerId,
offset: 1,
});
case "below":
return resolveEdgeChanges({
element: input.element,
layers: input.layers,
layerId: input.placement.layerId,
offset: -1,
});
case "between": {
const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId);
const afterLayer = findLayer(input.layers, input.placement.afterLayerId);
return beforeLayer && afterLayer
? resolveBetweenChanges({
element: input.element,
layers: input.layers,
beforeLayer,
afterLayer,
})
: null;
}
}
}
export function resolveTimelineLayerZIndexChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
@@ -169,37 +256,13 @@ export function resolveTimelineLayerZIndexChanges(input: {
if (isAudioElement(input.element)) return null;
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
const layers = getContextLayers(input.layers, contextKey);
let changes: TimelineStackingZIndexChange[] = [];
const changes = resolvePlacementChanges({
element: input.element,
layers,
placement: input.placement,
});
if (input.placement.type === "onto") {
const target = findLayer(layers, input.placement.layerId);
if (!target) return null;
changes = resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: target.zIndex,
});
} else if (input.placement.type === "above") {
const target = findLayer(layers, input.placement.layerId);
if (!target) return null;
changes = resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: target.zIndex + 1,
});
} else if (input.placement.type === "below") {
const target = findLayer(layers, input.placement.layerId);
if (!target) return null;
changes = resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: target.zIndex - 1,
});
} else {
const beforeLayer = findLayer(layers, input.placement.beforeLayerId);
const afterLayer = findLayer(layers, input.placement.afterLayerId);
if (!beforeLayer || !afterLayer) return null;
changes = resolveBetweenChanges({ element: input.element, layers, beforeLayer, afterLayer });
}
return changes.length > 0
return changes && changes.length > 0
? { contextKey, placement: input.placement, zIndexChanges: changes }
: null;
}
@@ -233,6 +296,111 @@ function resolveDragPlacement(
: null;
}
function buildInsertionPlacement(
layers: readonly StackingTimelineLayer[],
insertionIndex: number,
): TimelineLayerDropPlacement | null {
const first = layers[0];
const last = layers[layers.length - 1];
if (!first || !last) return null;
if (insertionIndex <= 0) return { type: "above", layerId: first.id };
if (insertionIndex >= layers.length) return { type: "below", layerId: last.id };
const before = layers[insertionIndex - 1];
const after = layers[insertionIndex];
return before && after
? { type: "between", beforeLayerId: before.id, afterLayerId: after.id }
: null;
}
interface ValidPlacementCandidate {
placement: TimelineLayerDropPlacement;
position: number;
kind: "onto" | "insert";
}
function comparePlacementCandidates(input: {
targetPosition: number;
currentIndex: number;
a: ValidPlacementCandidate;
b: ValidPlacementCandidate;
}): number {
const distanceA = Math.abs(input.a.position - input.targetPosition);
const distanceB = Math.abs(input.b.position - input.targetPosition);
if (distanceA !== distanceB) return distanceA - distanceB;
const direction = Math.sign(input.targetPosition - input.currentIndex);
if (direction !== 0) {
const biasA =
direction < 0
? input.a.position <= input.targetPosition
: input.a.position >= input.targetPosition;
const biasB =
direction < 0
? input.b.position <= input.targetPosition
: input.b.position >= input.targetPosition;
if (biasA !== biasB) return biasA ? -1 : 1;
}
if (input.a.kind !== input.b.kind) return input.a.kind === "onto" ? -1 : 1;
return input.a.position - input.b.position;
}
function resolveNearestValidPlacement(input: {
layers: readonly StackingTimelineLayer[];
element: TimelineStackingElement;
draggedKey: string;
targetPosition: number;
currentIndex: number;
}): TimelineLayerDropPlacement | null {
const candidates: ValidPlacementCandidate[] = [];
input.layers.forEach((layer, index) => {
if (!layerConflictsWithElement(layer, input.element, input.draggedKey)) {
candidates.push({
placement: { type: "onto", layerId: layer.id },
position: index,
kind: "onto",
});
}
});
for (let insertionIndex = 0; insertionIndex <= input.layers.length; insertionIndex += 1) {
const placement = buildInsertionPlacement(input.layers, insertionIndex);
if (!placement) continue;
candidates.push({
placement,
position: insertionIndex - 0.5,
kind: "insert",
});
}
return (
candidates.sort((a, b) =>
comparePlacementCandidates({
targetPosition: input.targetPosition,
currentIndex: input.currentIndex,
a,
b,
}),
)[0]?.placement ?? null
);
}
function resolveLaneAwareDragPlacement(input: {
layers: readonly StackingTimelineLayer[];
element: TimelineStackingElement;
draggedKey: string;
placement: TimelineLayerDropPlacement;
targetPosition: number;
currentIndex: number;
}): TimelineLayerDropPlacement | null {
if (input.placement.type !== "onto") return input.placement;
const target = findLayer(input.layers, input.placement.layerId);
if (!target) return null;
if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement;
return resolveNearestValidPlacement(input);
}
function getPreviewLayerId(
draggedKey: string,
placement: TimelineLayerDropPlacement,
@@ -275,7 +443,17 @@ export function resolveTimelineLayerStackingMove(input: {
const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey));
if (currentIndex < 0) return null;
const placement = resolveDragPlacement(contextLayers, currentIndex + input.trackDeltaRaw);
const targetPosition = currentIndex + input.trackDeltaRaw;
const rawPlacement = resolveDragPlacement(contextLayers, targetPosition);
if (!rawPlacement) return null;
const placement = resolveLaneAwareDragPlacement({
layers: contextLayers,
element: input.element,
draggedKey,
placement: rawPlacement,
targetPosition,
currentIndex,
});
if (!placement) return null;
return {
previewLayerId: getPreviewLayerId(draggedKey, placement),
@@ -4,6 +4,8 @@ export interface TimelineStackingElement {
id: string;
key?: string;
tag?: string;
start: number;
duration: number;
track: number;
zIndex?: number;
stackingContextId?: string | null;
@@ -34,33 +34,43 @@ function rowIds(rows: readonly { elements: readonly TimelineElement[] }[]): stri
}
describe("buildStackingTimelineLayers", () => {
it("merges explicit same-z clips in one context when they do not overlap in time", () => {
it("packs non-overlapping clips into the same lane even when their z-index differs", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "a", zIndex: 5, start: 0, duration: 1 }),
rowElement({ id: "b", zIndex: 5, start: 1, duration: 1 }),
rowElement({ id: "back", zIndex: 1, start: 0, duration: 1 }),
rowElement({ id: "front", zIndex: 10, start: 1, duration: 1 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["back", "front"]]);
expect(result.visualLayers[0]?.zIndex).toBe(10);
});
it("splits clips into separate lanes when they overlap in time", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "front", zIndex: 10, start: 0, duration: 2 }),
rowElement({ id: "back", zIndex: 1, start: 1, duration: 2 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
});
it("uses DOM order to break stacking ties before lane packing", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "first", track: 2, zIndex: 5, start: 0, duration: 2 }),
rowElement({ id: "second", track: 0, zIndex: 5, start: 1, duration: 2 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["first"], ["second"]]);
});
it("packs auto-z clips by time instead of forcing one row per clip", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false, start: 0, duration: 1 }),
rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false, start: 1, duration: 1 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["a", "b"]]);
});
it("splits explicit same-z clips in one context when they overlap in time", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "a", track: 2, zIndex: 5, start: 0, duration: 2 }),
rowElement({ id: "b", track: 0, zIndex: 5, start: 1, duration: 2 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["b"], ["a"]]);
});
it("keeps auto-z clips in their own rows even when their computed z-index ties", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false }),
rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false }),
]);
expect(rowIds(result.visualLayers)).toEqual([["a"], ["b"]]);
});
it("does not merge equal z-index clips across stacking contexts", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "root", zIndex: 4, start: 0, duration: 1 }),
@@ -24,13 +24,10 @@ type TimelineLayerOrderItem = TimelineStackingOrderItem & {
start: number;
duration: number;
index: number;
hasExplicitZIndex: boolean;
element: TimelineElement;
};
type BuildLayer = StackingTimelineLayer & {
hasExplicitZIndex: boolean;
};
type BuildLayer = Omit<StackingTimelineLayer, "id">;
function toTimelineLayerOrderItem(element: TimelineElement, index: number): TimelineLayerOrderItem {
return {
@@ -38,12 +35,11 @@ function toTimelineLayerOrderItem(element: TimelineElement, index: number): Time
start: element.start,
duration: element.duration,
index,
hasExplicitZIndex: element.hasExplicitZIndex === true,
element,
};
}
function timelineElementsOverlap(
export function timelineElementsOverlap(
a: Pick<TimelineElement, "start" | "duration">,
b: Pick<TimelineElement, "start" | "duration">,
): boolean {
@@ -52,13 +48,10 @@ function timelineElementsOverlap(
function compareLayerItems(a: TimelineLayerOrderItem, b: TimelineLayerOrderItem): number {
if (a.zIndex !== b.zIndex) return b.zIndex - a.zIndex;
if (a.hasExplicitZIndex && b.hasExplicitZIndex && a.track !== b.track) {
return a.track - b.track;
}
return a.index - b.index;
}
function buildLayerId(
function buildElementLayerId(
prefix: string,
contextKey: string,
element: TimelineElement,
@@ -66,6 +59,15 @@ function buildLayerId(
return `${prefix}:${contextKey}:${getTimelineElementIdentity(element)}`;
}
function buildLaneId(
prefix: string,
contextKey: string,
elements: TimelineElement[],
): TimelineLayerId {
const memberKey = elements.map(getTimelineElementIdentity).sort().join("|");
return `${prefix}:${contextKey}:${memberKey}`;
}
function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string[] {
const keys: string[] = [];
for (const item of resolveContextOrder(items)) {
@@ -77,14 +79,16 @@ function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string
function canJoinLayer(layer: BuildLayer, item: TimelineLayerOrderItem): boolean {
return (
layer.hasExplicitZIndex &&
item.hasExplicitZIndex &&
layer.contextKey === resolveStackingContextKey(item) &&
layer.zIndex === item.zIndex &&
layer.elements.every((element) => !timelineElementsOverlap(element, item.element))
);
}
function compareElementsByStart(a: TimelineElement, b: TimelineElement): number {
if (a.start !== b.start) return a.start - b.start;
return getTimelineElementIdentity(a).localeCompare(getTimelineElementIdentity(b));
}
function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
const byContext = new Map<string, TimelineLayerOrderItem[]>();
for (const item of items) {
@@ -99,42 +103,37 @@ function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): Stackin
const contextRows: BuildLayer[] = [];
const contextItems = [...(byContext.get(contextKey) ?? [])].sort(compareLayerItems);
for (const item of contextItems) {
if (!item.hasExplicitZIndex) {
contextRows.push({
id: buildLayerId("auto", contextKey, item.element),
kind: "visual",
contextKey,
zIndex: item.zIndex,
placementTrack: item.element.track,
elements: [item.element],
hasExplicitZIndex: false,
});
continue;
}
const existing = contextRows.find((row) => canJoinLayer(row, item));
if (existing) {
existing.elements.push(item.element);
existing.zIndex = Math.max(existing.zIndex, item.zIndex);
continue;
}
contextRows.push({
id: buildLayerId("layer", contextKey, item.element),
kind: "visual",
contextKey,
zIndex: item.zIndex,
placementTrack: item.element.track,
elements: [item.element],
hasExplicitZIndex: true,
});
}
rows.push(...contextRows);
rows.push(
...contextRows.map((row) => {
const elements = [...row.elements].sort(compareElementsByStart);
return {
...row,
id: buildLaneId("lane", contextKey, elements),
elements,
};
}),
);
}
return rows;
}
function buildAudioLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
return items.map((item) => ({
id: buildLayerId("audio", resolveStackingContextKey(item), item.element),
id: buildElementLayerId("audio", resolveStackingContextKey(item), item.element),
kind: "audio",
contextKey: resolveStackingContextKey(item),
zIndex: item.zIndex,
@@ -34,89 +34,134 @@ afterEach(() => {
usePlayerStore.getState().reset();
});
function renderDragHarness(elements: TimelineElement[]) {
const layers = buildStackingTimelineLayers(elements).rows;
const scroll = document.createElement("div");
document.body.append(scroll);
const onMoveElement = vi.fn();
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
function Harness() {
const hook = useTimelineClipDrag({
scrollRef: { current: scroll },
ppsRef: { current: 100 },
durationRef: { current: 10 },
trackOrderRef: { current: layers.map((layer) => layer.id) },
timelineLayersRef: { current: layers },
timelineElementsRef: { current: elements },
onMoveElement,
onResizeElement: vi.fn(),
onBlockedEditAttempt: vi.fn(),
setShowPopover: vi.fn(),
setRangeSelectionRef: { current: vi.fn() },
});
setDraggedClip = hook.setDraggedClip;
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
if (!setDraggedClip) throw new Error("Expected drag setter");
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
return {
layers,
onMoveElement,
startDrag(element: TimelineElement, layerIndex: number) {
act(() => {
applyDraggedClip({
element,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: element.start,
previewTrack: element.track,
previewLayerId: layers[layerIndex]!.id,
previewLayerIndex: layerIndex,
previewStackingReorder: null,
snapBeatTime: null,
started: false,
});
});
},
movePointer(clientX: number, clientY: number) {
act(() => {
window.dispatchEvent(
new MouseEvent("pointermove", {
bubbles: true,
clientX,
clientY,
}),
);
});
},
async dropPointer() {
await act(async () => {
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
});
},
unmount() {
act(() => root.unmount());
},
};
}
describe("useTimelineClipDrag", () => {
it("passes sibling-scoped stacking intent on vertical drag commit", async () => {
it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => {
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
const layers = buildStackingTimelineLayers([front, middle, back]).rows;
const scroll = document.createElement("div");
document.body.append(scroll);
const onMoveElement = vi.fn();
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
const harness = renderDragHarness([front, middle, back]);
function Harness() {
const hook = useTimelineClipDrag({
scrollRef: { current: scroll },
ppsRef: { current: 100 },
durationRef: { current: 10 },
trackOrderRef: { current: layers.map((layer) => layer.id) },
timelineLayersRef: { current: layers },
timelineElementsRef: { current: [front, middle, back] },
onMoveElement,
onResizeElement: vi.fn(),
onBlockedEditAttempt: vi.fn(),
setShowPopover: vi.fn(),
setRangeSelectionRef: { current: vi.fn() },
});
setDraggedClip = hook.setDraggedClip;
return null;
}
harness.startDrag(back, 2);
harness.movePointer(0, -2 * TRACK_H);
await harness.dropPointer();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
if (!setDraggedClip) throw new Error("Expected drag setter");
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
act(() => {
applyDraggedClip({
element: back,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: back.start,
previewTrack: back.track,
previewLayerId: layers[2]!.id,
previewLayerIndex: 2,
previewStackingReorder: null,
snapBeatTime: null,
started: false,
});
});
act(() => {
window.dispatchEvent(
new MouseEvent("pointermove", {
bubbles: true,
clientX: 0,
clientY: -2 * TRACK_H,
}),
);
});
await act(async () => {
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
});
expect(onMoveElement).toHaveBeenCalledTimes(1);
expect(onMoveElement.mock.calls[0]![1]).toMatchObject({
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
start: 0,
track: 2,
stackingReorder: {
contextKey: "root",
placement: { type: "onto", layerId: layers[0]!.id },
placement: { type: "above", layerId: harness.layers[0]!.id },
zIndexChanges: [{ key: "back", zIndex: 4 }],
},
});
harness.unmount();
});
it("uses the preview start when resolving whether a dragged clip can join a lane", async () => {
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
back.start = 0;
front.start = 0;
const harness = renderDragHarness([front, back]);
harness.startDrag(back, 1);
harness.movePointer(200, -TRACK_H);
await harness.dropPointer();
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
start: 2,
track: 1,
stackingReorder: {
contextKey: "root",
placement: { type: "onto", layerId: harness.layers[0]!.id },
zIndexChanges: [{ key: "back", zIndex: 3 }],
},
});
act(() => root.unmount());
harness.unmount();
});
});