feat(studio): timeline track = stacking layer (NLE-style layering)

Re-architect the timeline row model from data-track-index rows to stacking
layers. Rows represent stacking layers per context: explicit-z clips merge onto
one track when they share a z and don't overlap in time; auto-z clips stay one
row each (DOM order); audio is pulled into its own bottom lanes. Rows keyed by a
stable layer id, not data-track-index.

Vertical drag always writes z-index, never track: drop onto a layer joins it
(same z), between layers interpolates a new z, past the ends creates a new
front/back layer. data-track-index is never rewritten; #958 holds. Adds
hasExplicitZIndex capture (computed z != auto).

L1: hasExplicitZIndex on the element model
L2: buildStackingTimelineLayers (layer-based rows)
L3: layer-aware vertical drag (join / interpolate / new-extreme)
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:25 -04:00
parent 03f8089e52
commit e5ed512529
24 changed files with 1078 additions and 426 deletions
@@ -2,12 +2,14 @@ import { useCallback, useState, type RefObject } from "react";
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
scrollRef: RefObject<HTMLDivElement | null>;
ppsRef: RefObject<number>;
durationRef: RefObject<number>;
trackOrderRef: RefObject<number[]>;
trackOrderRef: RefObject<TimelineLayerId[]>;
timelineLayersRef: RefObject<StackingTimelineLayer[]>;
}
export function useTimelineAssetDrop({
@@ -15,6 +17,7 @@ export function useTimelineAssetDrop({
ppsRef,
durationRef,
trackOrderRef,
timelineLayersRef,
onFileDrop,
onAssetDrop,
onBlockDrop,
@@ -39,6 +42,10 @@ export function useTimelineAssetDrop({
setIsDragOver(false);
const scroll = scrollRef.current;
const rect = scroll?.getBoundingClientRect();
const layerById = new Map(timelineLayersRef.current.map((layer) => [layer.id, layer]));
const trackOrder = trackOrderRef.current
.map((id) => layerById.get(id)?.placementTrack)
.filter((track): track is number => track != null);
const dropInput = {
rectLeft: rect?.left ?? 0,
rectTop: rect?.top ?? 0,
@@ -47,7 +54,7 @@ export function useTimelineAssetDrop({
pixelsPerSecond: ppsRef.current,
duration: durationRef.current,
trackHeight: TRACK_H,
trackOrder: trackOrderRef.current,
trackOrder,
};
if (onFileDrop && e.dataTransfer.files.length > 0) {
void onFileDrop(
@@ -84,7 +91,16 @@ export function useTimelineAssetDrop({
}
}
},
[onAssetDrop, onBlockDrop, onFileDrop, scrollRef, ppsRef, durationRef, trackOrderRef],
[
onAssetDrop,
onBlockDrop,
onFileDrop,
scrollRef,
ppsRef,
durationRef,
trackOrderRef,
timelineLayersRef,
],
);
return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop };