mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
ukimsanov
parent
345f715c3f
commit
e464b33bb3
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user