Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions packages/studio/src/player/components/timelineMultiDragPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
106 changes: 106 additions & 0 deletions packages/studio/src/player/components/timelineMultiDragPreview.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading