fix(studio): stacking sync (#2270)

* feat(studio): timeline collision and placement model

What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.

Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.

How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.

Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).

* feat(studio): timeline magnetic snapping

What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.

Why: the magnet math for clip drags/trims, reviewable standalone.

How: new files only; type-only playerStore imports; consumers land with the
drag engine.

Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): multi-clip drag preview math

What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.

Why: the group-drag math, standalone and DOM-free.

How: new files only; consumed later by TimelineLanes.

Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.

* feat(studio): timeline z-stacking sync model

What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.

Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.

How: new files only; consumed later by timelineZones and the stacking-sync
hook.

Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
This commit is contained in:
Miguel Ángel
2026-07-11 22:56:17 -04:00
committed by GitHub
co-authored by ukimsanov
parent 345f715c3f
commit e464b33bb3
4 changed files with 808 additions and 0 deletions
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import {
clampGroupMoveDelta,
isMultiDragActive,
isMultiDragPassenger,
multiDragDeltaSeconds,
multiDragPassengerOffsetPx,
type MultiDragPreviewInput,
} from "./timelineMultiDragPreview";
const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput => ({
dragStarted: true,
draggedKey: "a",
draggedOriginStart: 2,
draggedPreviewStart: 5,
selectedKeys: new Set(["a", "b", "c"]),
...over,
});
describe("isMultiDragActive", () => {
it("is active when a started drag's clip is part of a 2+ selection", () => {
expect(isMultiDragActive(base())).toBe(true);
});
it("is inactive before the drag starts", () => {
expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
});
it("is inactive for a single-clip selection (single-drag behavior)", () => {
expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
});
it("is inactive when the dragged clip is not itself selected", () => {
expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
});
});
describe("multiDragDeltaSeconds (the one formation delta)", () => {
it("is the grabbed clip's preview origin start when active", () => {
// The preview start is already group-clamped upstream, so this delta is the
// clamped delta every member (ghost + passengers) moves by.
expect(multiDragDeltaSeconds(base())).toBe(3);
});
it("supports a leftward (negative) delta", () => {
expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
});
it("is zero when no multi-drag is active", () => {
expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
});
});
describe("isMultiDragPassenger", () => {
it("marks a selected non-dragged clip as a passenger", () => {
expect(isMultiDragPassenger("b", base())).toBe(true);
expect(isMultiDragPassenger("c", base())).toBe(true);
});
it("never marks the dragged clip itself (it is the free ghost)", () => {
expect(isMultiDragPassenger("a", base())).toBe(false);
});
it("never marks an unselected clip", () => {
expect(isMultiDragPassenger("d", base())).toBe(false);
});
it("marks nothing when the drag is a single-drag", () => {
const single = base({ selectedKeys: new Set(["a"]) });
expect(isMultiDragPassenger("b", single)).toBe(false);
});
});
describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
it("converts the one formation delta to pixels for every passenger", () => {
// Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
});
it("is zero for the dragged clip and for non-passengers", () => {
expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
});
it("is zero for a non-finite pps", () => {
expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
});
it("follows a leftward delta", () => {
expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
});
});
describe("clampGroupMoveDelta (rigid group move)", () => {
it("passes a rightward delta through unchanged (no right wall)", () => {
expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
});
it("passes a leftward delta through when no member would cross 0", () => {
// Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
});
it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
// Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
// A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
// formation instead of out-running it.
expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
});
it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
// Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
});
it("already-at-0 member forbids any leftward move", () => {
expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
// rightward still allowed
expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
});
it("returns the raw delta for an empty formation", () => {
expect(clampGroupMoveDelta(-9, [])).toBe(-9);
});
});
@@ -0,0 +1,106 @@
/**
* Pure geometry for the LIVE multi-selection drag preview.
*
* Visual model (matches main): while a selected clip is dragged, ALL selected
* clips move together LIVE as one rigid formation following the cursor. The
* GRABBED clip is drawn as the free-floating ghost; every OTHER selected member
* ("passenger") slides by the SAME time delta via a cheap compositor
* `translateX` (no re-layout). Passengers do NOT stay still, and they do NOT lag
* behind individually — the whole formation moves by one delta, spacing locked.
*
* That single delta is the GRABBED clip's `draggedPreviewStart draggedOriginStart`.
* The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview
* runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
* the instant any member would cross 0 the grabbed clip stops and every passenger
* stops with it — the formation never deforms. On DROP the commit shifts every
* selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
*
* Track changes apply to the grabbed clip only (mirroring the commit); passengers
* keep their lanes, so only their x moves.
*/
export interface MultiDragPreviewInput {
/** The drag is live (past the movement threshold). */
dragStarted: boolean;
/** Key of the clip under the pointer. */
draggedKey: string;
/** The dragged clip's committed start (pre-drag). */
draggedOriginStart: number;
/** The dragged clip's live preview start (already group-clamped upstream). */
draggedPreviewStart: number;
/** The current multi-selection (store.selectedElementIds). */
selectedKeys: ReadonlySet<string>;
}
/**
* Whether a live multi-selection drag is in effect: the drag started, and the
* dragged clip is itself part of a 2+ multi-selection. Below this, single-drag
* behavior is unchanged and there are no passengers.
*/
export function isMultiDragActive(input: MultiDragPreviewInput): boolean {
return (
input.dragStarted && input.selectedKeys.size > 1 && input.selectedKeys.has(input.draggedKey)
);
}
/**
* The single time delta the WHOLE formation shifts by — the grabbed clip's
* preview start minus its origin start. Because the preview start is already
* group-clamped, this is the clamped delta every member (ghost + passengers)
* moves by. Zero when the clip hasn't moved (or no multi-drag).
*/
export function multiDragDeltaSeconds(input: MultiDragPreviewInput): number {
if (!isMultiDragActive(input)) return 0;
return input.draggedPreviewStart - input.draggedOriginStart;
}
/**
* Whether a specific rendered clip is a passenger — a selected clip that is NOT
* the dragged clip and NOT the same clip key. Passengers get the translateX
* treatment; the dragged clip is drawn as the free-floating ghost instead.
*/
export function isMultiDragPassenger(clipKey: string, input: MultiDragPreviewInput): boolean {
return (
isMultiDragActive(input) && clipKey !== input.draggedKey && input.selectedKeys.has(clipKey)
);
}
/**
* The passenger's rendered x offset in PIXELS (delta seconds × pixels/second),
* to apply as `transform: translateX(...px)`. Every passenger uses the SAME
* formation delta, so the group moves rigidly. Returns 0 for non-passengers so
* callers can compute unconditionally and only branch on the elevated styling.
*/
export function multiDragPassengerOffsetPx(
clipKey: string,
pixelsPerSecond: number,
input: MultiDragPreviewInput,
): number {
if (!isMultiDragPassenger(clipKey, input)) return 0;
const pps = Number.isFinite(pixelsPerSecond) ? pixelsPerSecond : 0;
return multiDragDeltaSeconds(input) * pps;
}
/**
* Clamp a group move so the WHOLE selection moves as ONE rigid formation.
*
* The grabbed clip proposes a raw delta (its desired preview start minus its
* origin start, after its own snapping). Applied naively, a passenger could be
* pushed below 0 (or past any other member bound), and the commit's per-clip
* `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
* the group while a passenger sticks at the wall. This ports main's model
* (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
* bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
* any member hits 0 and the formation never deforms.
*
* `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
* included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
* no fixed right wall (the composition grows on commit).
*/
export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number {
if (memberStarts.length === 0) return rawDelta;
// Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0.
const minStart = Math.min(...memberStarts);
const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
return rawDelta < minDelta ? minDelta : rawDelta;
}
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync";
function el(
key: string,
track: number,
start: number,
duration: number,
zIndex: number,
isAudio = false,
domIndex?: number,
): StackingElement {
return { key, track, start, duration, zIndex, isAudio, domIndex };
}
function patchMap(elements: StackingElement[], edited: string[]): Record<string, number> {
const out: Record<string, number> = {};
for (const p of computeStackingPatches(elements, edited)) out[p.key] = p.zIndex;
return out;
}
describe("laneIsAbove", () => {
it("lower track renders above (top of timeline wins)", () => {
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
expect(laneIsAbove({ track: 2 }, { track: 1 })).toBe(false);
expect(laneIsAbove({ track: 1 }, { track: 1 })).toBe(false);
});
});
describe("computeStackingPatches", () => {
it("no overlapping clips → no patch", () => {
// a (0..5 on track 0) and b (10..15 on track 1) never overlap in time.
const elements = [el("a", 0, 0, 5, 10), el("b", 1, 10, 5, 5)];
expect(patchMap(elements, ["a"])).toEqual({});
});
it("edited clip moved to a HIGHER lane (top) but z too low → raised above the below-neighbour", () => {
// a on top lane (0) overlaps b on lane 1; a.z=1 is below b.z=5 → wrong.
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
// Only a is edited → only a gets a patch, lifting it above b (5) → 6.
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
});
it("edited clip moved to a LOWER lane (bottom) but z too high → lowered below the above-neighbour", () => {
// a on lane 2 (bottom) overlaps b on lane 0 (top); a.z=9 above b.z=5 → wrong.
const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 5)];
expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
});
it("edited clip already correctly ordered → no patch (authored z preserved)", () => {
// a on top lane already has higher z than the lower-lane b it overlaps.
const elements = [el("a", 0, 0, 10, 8), el("b", 1, 0, 10, 3)];
expect(patchMap(elements, ["a"])).toEqual({});
});
it("untouched clips never get a patch even when they overlap the edit", () => {
// b is out of order relative to a, but a is the only edited clip.
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5), el("c", 2, 0, 10, 9)];
const patches = computeStackingPatches(elements, ["a"]);
expect(patches.map((p) => p.key)).toEqual(["a"]);
});
it("sits strictly between neighbours when there is integer room", () => {
// edited a on middle lane 1 between below-lane-2 (z=2) and above-lane-0 (z=10).
const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 2), el("above", 0, 0, 10, 10)];
// Between 2 and 10 → floor((2+10)/2)=6.
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
});
it("adjacent neighbours (no integer gap) → edited lands above lower, upper is bumped", () => {
// below z=4, above z=5 (adjacent). There is no integer strictly between 4 and
// 5, so the old single-patch a=5 left `a` TIED with `above` — and with no DOM
// order to break the tie, `above` no longer paints strictly above `a` (the
// under-patch bug). Tie-aware cascade: a→5 (above below's 4) AND above→6 so it
// stays strictly on top. Minimal: only the two overlapping neighbours move.
const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 4), el("above", 0, 0, 10, 5)];
expect(patchMap(elements, ["a"])).toEqual({ a: 5, above: 6 });
});
it("audio clips are excluded — an audio edit yields no patch", () => {
const elements = [el("music", 3, 0, 10, 0, true), el("v", 0, 0, 10, 5)];
expect(patchMap(elements, ["music"])).toEqual({});
});
it("audio clips are excluded as neighbours — a visual edit ignores overlapping audio", () => {
// The only overlapping clip is audio → treated as no visual overlap → no patch.
const elements = [el("v", 0, 0, 10, 3), el("music", 3, 0, 10, 99, true)];
expect(patchMap(elements, ["v"])).toEqual({});
});
it("only-below neighbours → maxBelow + 1", () => {
const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 3), el("c", 2, 0, 10, 7)];
// a on top overlaps b(3) and c(7), both below → 7+1=8.
expect(patchMap(elements, ["a"])).toEqual({ a: 8 });
});
it("only-above neighbours → minAbove - 1 (clamped ≥ 0)", () => {
const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 1), el("c", 1, 0, 10, 4)];
// a on bottom overlaps b(1) and c(4), both above → min(1)-1=0.
expect(patchMap(elements, ["a"])).toEqual({ a: 0 });
});
it("partial time overlap still counts", () => {
// a: 0..6, b: 5..15 overlap in [5,6).
const elements = [el("a", 0, 0, 6, 1), el("b", 1, 5, 10, 5)];
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
});
it("touching-but-not-overlapping intervals do NOT count", () => {
// a ends exactly where b starts (t=5) → half-open, no overlap.
const elements = [el("a", 0, 0, 5, 1), el("b", 1, 5, 5, 5)];
expect(patchMap(elements, ["a"])).toEqual({});
});
it("multi-clip edit: two dragged clips resolve consistently against the region", () => {
// Drag a (lane 0) and b (lane 1) onto a region already holding c (lane 2, z=5).
// Both overlap c. Lower-lane b resolves first (above c → 6), then a (above b → 7).
const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
expect(patchMap(elements, ["a", "b"])).toEqual({ a: 7, b: 6 });
});
it("multi-clip edit skips a member that is already correctly ordered", () => {
const elements = [el("a", 0, 0, 10, 20), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
// a(20) already above everything → no patch. b (lane 1) sits between
// below-neighbour c(5) and above-neighbour a(20) → floor((5+20)/2)=12.
expect(patchMap(elements, ["a", "b"])).toEqual({ b: 12 });
});
it("empty edited set → no patches", () => {
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
expect(computeStackingPatches(elements, [])).toEqual([]);
});
it("item 13: an unresolved neighbour (non-finite z) is EXCLUDED, not treated as z=0", () => {
// `ghost` is an overlapping upper-lane clip whose live node could not be
// resolved, so its z came back NaN. If it were fabricated to 0 it would be a
// real above-neighbour at the z-floor and drag the edited clip's cascade down.
// Excluded, the only real overlap is below-neighbour b(3) → a rises to 4.
const elements = [
el("a", 1, 0, 10, 0),
el("b", 2, 0, 10, 3),
el("ghost", 0, 0, 10, Number.NaN),
];
expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
});
it("item 13: an edited clip whose own z is unresolved (non-finite) yields no patch", () => {
// The edited clip itself couldn't be resolved → it is dropped from the working
// set and produces nothing (no fabricated-0 self-patch).
const elements = [el("a", 0, 0, 10, Number.NaN), el("b", 1, 0, 10, 5)];
expect(patchMap(elements, ["a"])).toEqual({});
});
});
describe("computeStackingPatches — tie-aware cascade (lane move always realisable)", () => {
it("drag below an overlapping z=0 neighbour → cascade bumps the neighbour, edit→0", () => {
// edited `v` (z=2) dragged to the BOTTOM lane, overlapping `r` (z=0) which is
// now on the upper lane. No z ≥ 0 fits strictly below 0, so the old resolver
// clamped v to 0 (tied with r) and nothing changed on canvas — the reported
// bug. Tie-aware: v→0 AND r bumped to 1 so r paints strictly above v.
const elements = [el("v", 1, 0, 10, 2), el("r", 0, 0, 10, 0)];
expect(patchMap(elements, ["v"])).toEqual({ v: 0, r: 1 });
});
it("equal-z + domIndex: dragging the LATER-in-DOM clip below is realised via a bump", () => {
// Two equal-z clips; `v` is later in DOM (domIndex 1) so it currently paints
// ON TOP of `r` (domIndex 0). User drags v to the lower lane (track 1). With
// domIndex the sync SEES that v is currently above r and must be lowered:
// v→0 (already 0, stays) then r bumped to 1 so r wins. Without domIndex the
// equal z would look already-correct and under-patch.
const elements = [el("r", 0, 0, 10, 0, false, 0), el("v", 1, 0, 10, 0, false, 1)];
expect(patchMap(elements, ["v"])).toEqual({ r: 1 });
});
it("equal-z without domIndex is ambiguous → conservatively bumps to guarantee order", () => {
// Same shape but NO domIndex: equal z is ambiguous, so the resolver cannot
// prove v is already below r and patches to make the order explicit (r above).
const elements = [el("r", 0, 0, 10, 0), el("v", 1, 0, 10, 0)];
const out = patchMap(elements, ["v"]);
// r must end up strictly above v (higher z) regardless of the exact numbers.
const vz = out.v ?? 0;
const rz = out.r ?? 0;
expect(rz).toBeGreaterThan(vz);
});
it("#2198 (Abhai repro): a lift cascades transitively so an UNTOUCHED pair never inverts", () => {
// m (z1, lane0, dom0), n (z0, lane1, dom1), e (z2, lane2, dom2, edited).
// e overlaps n [5,10); n overlaps m [12,15); e does NOT overlap m.
// Dragging e to the bottom lane forces n up to paint above e. A naive lift sets
// n→1, which TIES m (z1) and — n being later in the DOM — paints n above m,
// inverting the untouched (m,n) pair (which the next normalize would reshuffle).
// The transitive cascade lifts m too (→2) so m stays strictly above n.
const elements = [
el("m", 0, 12, 8, 1, false, 0),
el("n", 1, 5, 10, 0, false, 1),
el("e", 2, 0, 10, 2, false, 2),
];
expect(patchMap(elements, ["e"])).toEqual({ e: 0, n: 1, m: 2 });
});
it("cascade patches as FEW clips as possible (only the blockers move)", () => {
// v dragged to bottom under r(z0) and s(z0) both on higher lanes; a distant
// non-overlapping clip x is never touched.
const elements = [
el("v", 2, 0, 10, 5),
el("r", 0, 0, 10, 0),
el("s", 1, 0, 10, 0),
el("x", 3, 50, 10, 0), // no time overlap → untouched
];
const out = patchMap(elements, ["v"]);
expect("x" in out).toBe(false);
// v below both r and s.
expect(out.v).toBeLessThan(out.r ?? 0);
expect(out.v).toBeLessThan(out.s ?? 0);
});
});
describe("computeStackingPatches — DOM tie-break gates the cascade (item 12)", () => {
it("tie ACCEPTABLE: edited may sit AT minAbove when the above-neighbour is later in DOM → single patch, no neighbour bump", () => {
// below b (z3, lane2, dom0), edited e (lane1, dom1), above a (z4, lane0, dom2).
// e must paint above b and below a. There is no integer strictly between 3 and
// 4, but a is LATER in the DOM, so e=4 ties a and a still paints on top by DOM
// order — a valid SINGLE patch. The old gap<2 rule cascaded and needlessly
// bumped a's authored z (the over-patch). Only e changes here.
const elements = [
el("b", 2, 0, 10, 3, false, 0),
el("e", 1, 0, 10, 0, false, 1),
el("a", 0, 0, 10, 4, false, 2),
];
expect(patchMap(elements, ["e"])).toEqual({ e: 4 });
});
it("tie INVERTING: edited tying minAbove would paint ABOVE it (earlier in DOM) → cascade bumps the neighbour", () => {
// Same z's, but a is EARLIER in the DOM than e (dom0 vs dom2). Now e=4 would
// tie a AND paint on top (e later in DOM), violating the lane order, so the
// tie-break can't save it: e→4 and a is bumped to 5.
const elements = [
el("a", 0, 0, 10, 4, false, 0),
el("b", 2, 0, 10, 3, false, 1),
el("e", 1, 0, 10, 0, false, 2),
];
expect(patchMap(elements, ["e"])).toEqual({ e: 4, a: 5 });
});
});
@@ -0,0 +1,331 @@
/**
* timelineStackingSync — lane ↔ stacking unification (pure).
*
* The approved design: **lane order implies stacking**. A clip on a higher lane
* (rendered ABOVE another in the timeline) should render ON TOP of any clip it
* OVERLAPS IN TIME. But authored z-indexes are sacred: z only changes on a user
* edit, and ONLY for the clip(s) the user actually edited.
*
* Lane → screen mapping (see Timeline.tsx trackOrder / TimelineCanvas rows):
* tracks are sorted ASCENDING and rendered top → bottom, so a LOWER `track`
* value renders HIGHER on screen. Standard NLE convention = the top row wins,
* therefore **lower track ⇒ higher z-index**. We express this with a single
* comparator so callers never have to remember the polarity.
*
* This module is DOM-free and store-free. Callers project their world onto
* `StackingElement` (supplying the live z-index they read from the DOM/inline
* style) and apply the returned `StackingPatch[]` however they persist styles.
*/
/** Minimal element view this module reasons over. */
export interface StackingElement {
/** Stable identity (TimelineElement.key ?? id). */
key: string;
/** Absolute start time (seconds). */
start: number;
/** Duration (seconds). */
duration: number;
/**
* Display lane (the normalized timeline `track`). Lower = higher on screen =
* should stack on top. This is the post-edit lane for edited clips.
*/
track: number;
/**
* Current z-index (parsed from inline style / computed; "auto" ⇒ 0), or a
* NON-FINITE value (NaN) when the caller could NOT resolve the clip's live node
* (e.g. an unmounted / nested sub-comp element, or one outside the active file).
* A non-finite-z clip is EXCLUDED from the computation — it is neither a stacking
* neighbour nor resolvable as an edit — so an unresolved node never fabricates a
* z=0 neighbour that poisons the boundary math (item 13). The reader signals a
* miss with NaN rather than null so the value stays assignable to the existing
* `(el) => number` reader contract the drag hook / commit deps declare.
*/
zIndex: number;
/** Audio clips have no visual stacking and are excluded from the computation. */
isAudio: boolean;
/**
* Discovery / DOM document position (optional). Two clips with EQUAL z paint by
* DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
* B" uses (zIndex, domIndex); without it equal-z is ambiguous and the sync can
* under-patch (the reported bug: a clip dragged to the bottom lane over an
* equal-z neighbour changed nothing on canvas). Callers pass the index of the
* element in the discovery order array.
*/
domIndex?: number;
}
/** A minimal z-index change for one clip. */
export interface StackingPatch {
key: string;
zIndex: number;
}
const EPS = 1e-6;
/**
* Two clips overlap in time when their half-open [start, end) intervals intersect.
*
* NOTE the `- EPS`: this DELIBERATELY diverges from `timeRangesOverlap`'s exact
* strict-`<` (timelineCollision.ts). A boolean collision decision is idempotent, so
* exact `<` is fine there; here the result drives a VISIBLE stacking re-lane, so the
* epsilon guards against float fuzz (e.g. 5.0000001 vs 5) spuriously overlapping two
* abutting clips and shuffling lanes. The two are intended to differ, not align.
*/
function overlapsInTime(a: StackingElement, b: StackingElement): boolean {
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
}
/**
* Is `a` visually ABOVE `b` (should stack on top)? Lower track renders higher on
* screen, so a lower track number means "above". Exposed for tests / callers.
*/
export function laneIsAbove(
a: Pick<StackingElement, "track">,
b: Pick<StackingElement, "track">,
): boolean {
return a.track < b.track;
}
/**
* Working record for the cascade resolver: a live-mutable, RESOLVED (non-null) z
* the resolver can bump, plus the immutable identity/lane/time/dom fields. Clips
* whose z could not be resolved (null) are dropped before this stage.
*/
interface MutZ extends StackingElement {
zIndex: number;
}
/**
* Does `a` currently paint ON TOP of `b`? Higher z wins; equal z breaks by DOM
* order (later in DOM paints on top). When either domIndex is absent, equal z is
* treated as "not strictly above" (ambiguous) — callers should supply domIndex to
* disambiguate (see StackingElement.domIndex). Operates on resolved (`MutZ`) clips.
*/
function paintsAbove(a: MutZ, b: MutZ): boolean {
if (a.zIndex !== b.zIndex) return a.zIndex > b.zIndex;
if (a.domIndex != null && b.domIndex != null) return a.domIndex > b.domIndex;
return false;
}
/** Reduce a neighbour set's z-indices to a single bound, or null when empty. */
function boundaryZ(neighbours: MutZ[], reduce: (zs: number[]) => number): number | null {
return neighbours.length > 0 ? reduce(neighbours.map((o) => o.zIndex)) : null;
}
/**
* Resolve `edited` so that, among the clips it OVERLAPS IN TIME, its paint order
* matches its lane order (lower lane ⇒ paints on top). Records every z change
* (edited clip AND any neighbours that must be bumped) into `patchZ`.
*
* Fast path (unchanged behaviour): when a single non-negative z for the edited
* clip alone realises the order — strictly between the neighbours if there is
* integer room, else just above the lower neighbour, else just below the upper —
* emit only that. This keeps every existing single-patch test passing.
*
* Cascade path: when ties/clamping make the single-clip patch impossible or
* ineffective (must sit below an overlapping z=0 neighbour, or between adjacent /
* equal-z neighbours where DOM order alone can't express it), bump the minimum set
* of overlapping neighbours that must stay ABOVE by +1 (cascading only as far as
* needed) so the edited clip's intended lane order is realised with all z ≥ 0.
* "Authored z sacred" stays the default — neighbours are touched only when the
* user's explicit lane move is otherwise inexpressible (same precedent as the
* canvas context-menu tie-aware fix).
*
* Returns true when any z changed (recorded in `patchZ`), false for a no-op.
*/
function resolveEditedZ(
edited: MutZ,
overlapping: MutZ[],
overlappersOf: (clip: MutZ) => MutZ[],
patchZ: (clip: MutZ, z: number) => void,
): boolean {
const visualOverlap = overlapping.filter((o) => !o.isAudio);
if (visualOverlap.length === 0) return false;
// Neighbours that must end up BELOW edited (lower lane) vs ABOVE (higher lane).
const below = visualOverlap.filter((o) => laneIsAbove(edited, o));
const above = visualOverlap.filter((o) => laneIsAbove(o, edited));
// Already correct against every overlapping neighbour → no-op (authored z kept).
const correct =
below.every((o) => paintsAbove(edited, o)) && above.every((o) => paintsAbove(o, edited));
if (correct) return false;
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
// ── Fast path: try to realise the order by moving only `edited`. ──────────────
const single = trySingleZ(edited, below, above);
if (single != null) {
if (single !== edited.zIndex) patchZ(edited, single);
// Even at an unchanged z the DOM-order ties may already be satisfied; if not,
// `trySingleZ` returned null and we fall through to the cascade.
return single !== edited.zIndex;
}
// ── Cascade path: can't fit `edited` between the neighbours with one z ≥ 0. ───
// Sit edited at maxBelow+1 (or 0 when it only has above-neighbours) and lift the
// above-neighbours that are now not strictly above, minimally, one step past it.
const target = maxBelow != null ? maxBelow + 1 : 0;
const clamped = Math.max(0, target);
if (clamped !== edited.zIndex) patchZ(edited, clamped);
liftAbove(edited, overlappersOf, patchZ);
return true;
}
/**
* Pick a single non-negative z for `edited` that lands it correctly against its
* neighbours (paints above every below-neighbour, below every above-neighbour), or
* null when no such z exists and the caller must cascade.
*
* The candidate is verified with the SAME `paintsAbove` predicate the resolver uses
* (z + DOM tie-break), so an authored z that already paints correctly by DOM order
* is honoured instead of over-patched: with below=z3 and an above-neighbour at z4
* that is LATER in the DOM, edited=4 ties the neighbour but the neighbour still
* paints on top by DOM order — a valid single patch, no neighbour bump (item 12).
* When the tie would INVERT (the above-neighbour is earlier in DOM) the candidate
* fails verification and the caller cascades.
*/
// edited has neighbours on BOTH sides: prefer the integer midpoint of a real gap;
// with no strict gap a DOM tie-break may still let it sit AT minAbove (item 12),
// else the caller cascades.
function zBetweenNeighbours(
maxBelow: number,
minAbove: number,
correctAt: (z: number) => boolean,
): number | null {
if (minAbove - maxBelow >= 2) {
const mid = Math.floor((maxBelow + minAbove) / 2);
return mid > maxBelow && mid < minAbove ? mid : null;
}
return correctAt(minAbove) ? minAbove : null;
}
// edited has only above-neighbours: sit one step below minAbove, or at the z=0
// floor tie minAbove when a DOM tie-break keeps that neighbour on top.
function zBelowOnly(minAbove: number, correctAt: (z: number) => boolean): number | null {
const candidate = minAbove - 1;
if (candidate >= 0) return candidate;
return correctAt(minAbove) ? minAbove : null;
}
function trySingleZ(edited: MutZ, below: MutZ[], above: MutZ[]): number | null {
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
const minAbove = boundaryZ(above, (zs) => Math.min(...zs));
const correctAt = (z: number): boolean => {
const probe: MutZ = { ...edited, zIndex: z };
return below.every((b) => paintsAbove(probe, b)) && above.every((a) => paintsAbove(a, probe));
};
if (maxBelow != null && minAbove != null)
return zBetweenNeighbours(maxBelow, minAbove, correctAt);
if (maxBelow != null) return maxBelow + 1; // only below-neighbours → grow upward
if (minAbove != null) return zBelowOnly(minAbove, correctAt);
return null;
}
/**
* Enforce the module invariant — for every OVERLAPPING pair, the clip on the upper
* lane paints on top — starting from `edited` and cascading TRANSITIVELY.
*
* Seeded with `edited`: each of its upper-lane overlappers must paint strictly
* above it (the deliberate lane move). Raising a clip can then tie or cross ANOTHER
* clip it overlaps that sits on an even higher lane — that clip must be lifted too,
* and so on. Without the cascade a lifted neighbour could tie an untouched clip on
* a higher lane and, being later in the DOM, paint above it — an untouched pair
* visibly inverting (#2198). The condition is LANE order (not "was originally
* above"), so a clip that was already violating lane order — e.g. a bottom-lane
* clip painting on top — is fixed, never preserved. Only clips whose z actually
* changes are patched; z climbs by +1 each step so the walk terminates.
*/
function liftAbove(
edited: MutZ,
overlappersOf: (clip: MutZ) => MutZ[],
patchZ: (clip: MutZ, z: number) => void,
): void {
const queue: MutZ[] = [edited];
const raiseAbove = (clip: MutZ, floor: MutZ): void => {
if (paintsAbove(clip, floor)) return; // already strictly on top
patchZ(clip, floor.zIndex + 1); // patchZ mutates clip.zIndex in place
queue.push(clip);
};
while (queue.length > 0) {
const floor = queue.shift()!;
for (const other of overlappersOf(floor)) {
if (laneIsAbove(other, floor) && !paintsAbove(other, floor)) raiseAbove(other, floor);
}
}
}
/**
* Compute z-index patches so each edited clip's stacking matches its lane order.
*
* @param elements The FULL post-edit element set (edited clips already carry
* their new lane/time). Untouched clips keep their current z.
* @param editedKeys Keys of the clip(s) the user just edited.
* @returns Minimal z patches. When a single-clip patch realises the order it is
* the only patch (authored z of neighbours untouched); when ties or a
* z=0 floor make that impossible, the minimum set of overlapping
* neighbours is bumped too so the lane move is always realisable with
* all z ≥ 0. Non-overlapping / already-correct edits yield nothing.
*
* Multi-clip edits: each edited clip is resolved against the CURRENT (already-
* patched) z of all OTHER clips, lower lane first, so a group dragged onto a busy
* region stacks consistently.
*/
export function computeStackingPatches(
elements: StackingElement[],
editedKeys: Iterable<string>,
): StackingPatch[] {
const editedSet = new Set(editedKeys);
if (editedSet.size === 0) return [];
// Drop clips whose live z couldn't be resolved (non-finite / NaN): a fabricated
// z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
// unresolved clip is neither a neighbour nor resolvable as an edit, so it is
// excluded outright (item 13).
const resolved = elements.filter((e) => Number.isFinite(e.zIndex));
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
const edited = resolved
.filter((e) => editedSet.has(e.key) && !e.isAudio)
.map((e) => byKey.get(e.key)!)
// Resolve lower-lane (renders below) clips first so their new z is visible
// to higher-lane siblings resolved after them.
.sort((a, b) => b.track - a.track);
const changed = new Map<string, number>();
const patchZ = (clip: MutZ, z: number): void => {
clip.zIndex = z;
changed.set(clip.key, z);
};
// The full live set, so the transitive cascade can reach clips that overlap a
// LIFTED neighbour without overlapping the edited clip itself (#2198).
const all = [...byKey.values()];
const overlappersOf = (clip: MutZ): MutZ[] =>
all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o));
for (const clip of edited) {
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
}
// Emit in a stable order (edited clips first in their resolve order, then any
// cascaded neighbours) — deterministic for tests and undo grouping.
const emitted = new Set<string>();
const patches: StackingPatch[] = [];
for (const clip of edited) {
if (changed.has(clip.key) && !emitted.has(clip.key)) {
patches.push({ key: clip.key, zIndex: changed.get(clip.key)! });
emitted.add(clip.key);
}
}
for (const [key, zIndex] of changed) {
if (!emitted.has(key)) {
patches.push({ key, zIndex });
emitted.add(key);
}
}
return patches;
}