From ed8bf475d327951a63c61880e31e1d1f9121ea6e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 15:33:56 -0400 Subject: [PATCH] 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. --- .../player/components/timelineEditing.test.ts | 8 +- .../src/player/components/timelineEditing.ts | 7 +- .../components/timelineLayerDrag.test.ts | 70 ++++- .../player/components/timelineLayerDrag.ts | 242 +++++++++++++++--- .../src/player/components/timelineStacking.ts | 2 + .../components/timelineTrackOrder.test.ts | 52 ++-- .../player/components/timelineTrackOrder.ts | 59 +++-- .../components/useTimelineClipDrag.test.tsx | 185 ++++++++----- 8 files changed, 458 insertions(+), 167 deletions(-) diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index 312b216cd..dfada1656 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -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 }], }, }); }); diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 1a6320787..c7293d90f 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -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, diff --git a/packages/studio/src/player/components/timelineLayerDrag.test.ts b/packages/studio/src/player/components/timelineLayerDrag.test.ts index d000e1c37..4d129b6ff 100644 --- a/packages/studio/src/player/components/timelineLayerDrag.test.ts +++ b/packages/studio/src/player/components/timelineLayerDrag.test.ts @@ -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 }], + }, + }); + }); +}); diff --git a/packages/studio/src/player/components/timelineLayerDrag.ts b/packages/studio/src/player/components/timelineLayerDrag.ts index d87bf7ad2..f718fb542 100644 --- a/packages/studio/src/player/components/timelineLayerDrag.ts +++ b/packages/studio/src/player/components/timelineLayerDrag.ts @@ -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), diff --git a/packages/studio/src/player/components/timelineStacking.ts b/packages/studio/src/player/components/timelineStacking.ts index 5235cca19..af667b1dc 100644 --- a/packages/studio/src/player/components/timelineStacking.ts +++ b/packages/studio/src/player/components/timelineStacking.ts @@ -4,6 +4,8 @@ export interface TimelineStackingElement { id: string; key?: string; tag?: string; + start: number; + duration: number; track: number; zIndex?: number; stackingContextId?: string | null; diff --git a/packages/studio/src/player/components/timelineTrackOrder.test.ts b/packages/studio/src/player/components/timelineTrackOrder.test.ts index 56d40ea1b..e84cd431d 100644 --- a/packages/studio/src/player/components/timelineTrackOrder.test.ts +++ b/packages/studio/src/player/components/timelineTrackOrder.test.ts @@ -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 }), diff --git a/packages/studio/src/player/components/timelineTrackOrder.ts b/packages/studio/src/player/components/timelineTrackOrder.ts index dcbfa0068..43e0014f3 100644 --- a/packages/studio/src/player/components/timelineTrackOrder.ts +++ b/packages/studio/src/player/components/timelineTrackOrder.ts @@ -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; 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, b: Pick, ): 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(); 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, diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index 6f0a63640..efe4840da 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -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(); + }); + 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(); - }); - 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(); }); });