diff --git a/packages/studio/src/player/components/timelineStackingSync.test.ts b/packages/studio/src/player/components/timelineStackingSync.test.ts new file mode 100644 index 0000000000..925787e772 --- /dev/null +++ b/packages/studio/src/player/components/timelineStackingSync.test.ts @@ -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 { + const out: Record = {}; + 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 }); + }); +}); diff --git a/packages/studio/src/player/components/timelineStackingSync.ts b/packages/studio/src/player/components/timelineStackingSync.ts new file mode 100644 index 0000000000..4362879bef --- /dev/null +++ b/packages/studio/src/player/components/timelineStackingSync.ts @@ -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, + b: Pick, +): 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, +): 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(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(); + 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(); + 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; +}