diff --git a/packages/studio/src/player/components/timelineMultiDragPreview.test.ts b/packages/studio/src/player/components/timelineMultiDragPreview.test.ts new file mode 100644 index 0000000000..6d7791991e --- /dev/null +++ b/packages/studio/src/player/components/timelineMultiDragPreview.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/packages/studio/src/player/components/timelineMultiDragPreview.ts b/packages/studio/src/player/components/timelineMultiDragPreview.ts new file mode 100644 index 0000000000..6b85466322 --- /dev/null +++ b/packages/studio/src/player/components/timelineMultiDragPreview.ts @@ -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; +} + +/** + * 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; +}