Skip to content

Commit 6730438

Browse files
committed
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.
1 parent 25372cc commit 6730438

2 files changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
clampGroupMoveDelta,
4+
isMultiDragActive,
5+
isMultiDragPassenger,
6+
multiDragDeltaSeconds,
7+
multiDragPassengerOffsetPx,
8+
type MultiDragPreviewInput,
9+
} from "./timelineMultiDragPreview";
10+
11+
const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput => ({
12+
dragStarted: true,
13+
draggedKey: "a",
14+
draggedOriginStart: 2,
15+
draggedPreviewStart: 5,
16+
selectedKeys: new Set(["a", "b", "c"]),
17+
...over,
18+
});
19+
20+
describe("isMultiDragActive", () => {
21+
it("is active when a started drag's clip is part of a 2+ selection", () => {
22+
expect(isMultiDragActive(base())).toBe(true);
23+
});
24+
25+
it("is inactive before the drag starts", () => {
26+
expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
27+
});
28+
29+
it("is inactive for a single-clip selection (single-drag behavior)", () => {
30+
expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
31+
});
32+
33+
it("is inactive when the dragged clip is not itself selected", () => {
34+
expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
35+
});
36+
});
37+
38+
describe("multiDragDeltaSeconds (the one formation delta)", () => {
39+
it("is the grabbed clip's preview − origin start when active", () => {
40+
// The preview start is already group-clamped upstream, so this delta is the
41+
// clamped delta every member (ghost + passengers) moves by.
42+
expect(multiDragDeltaSeconds(base())).toBe(3);
43+
});
44+
45+
it("supports a leftward (negative) delta", () => {
46+
expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
47+
});
48+
49+
it("is zero when no multi-drag is active", () => {
50+
expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
51+
});
52+
});
53+
54+
describe("isMultiDragPassenger", () => {
55+
it("marks a selected non-dragged clip as a passenger", () => {
56+
expect(isMultiDragPassenger("b", base())).toBe(true);
57+
expect(isMultiDragPassenger("c", base())).toBe(true);
58+
});
59+
60+
it("never marks the dragged clip itself (it is the free ghost)", () => {
61+
expect(isMultiDragPassenger("a", base())).toBe(false);
62+
});
63+
64+
it("never marks an unselected clip", () => {
65+
expect(isMultiDragPassenger("d", base())).toBe(false);
66+
});
67+
68+
it("marks nothing when the drag is a single-drag", () => {
69+
const single = base({ selectedKeys: new Set(["a"]) });
70+
expect(isMultiDragPassenger("b", single)).toBe(false);
71+
});
72+
});
73+
74+
describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
75+
it("converts the one formation delta to pixels for every passenger", () => {
76+
// Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
77+
expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
78+
expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
79+
});
80+
81+
it("is zero for the dragged clip and for non-passengers", () => {
82+
expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
83+
expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
84+
});
85+
86+
it("is zero for a non-finite pps", () => {
87+
expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
88+
});
89+
90+
it("follows a leftward delta", () => {
91+
expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
92+
});
93+
});
94+
95+
describe("clampGroupMoveDelta (rigid group move)", () => {
96+
it("passes a rightward delta through unchanged (no right wall)", () => {
97+
expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
98+
expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
99+
});
100+
101+
it("passes a leftward delta through when no member would cross 0", () => {
102+
// Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
103+
expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
104+
});
105+
106+
it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
107+
// Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
108+
// A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
109+
// formation instead of out-running it.
110+
expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
111+
});
112+
113+
it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
114+
// Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
115+
expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
116+
});
117+
118+
it("already-at-0 member forbids any leftward move", () => {
119+
expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
120+
// rightward still allowed
121+
expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
122+
});
123+
124+
it("returns the raw delta for an empty formation", () => {
125+
expect(clampGroupMoveDelta(-9, [])).toBe(-9);
126+
});
127+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Pure geometry for the LIVE multi-selection drag preview.
3+
*
4+
* Visual model (matches main): while a selected clip is dragged, ALL selected
5+
* clips move together LIVE as one rigid formation following the cursor. The
6+
* GRABBED clip is drawn as the free-floating ghost; every OTHER selected member
7+
* ("passenger") slides by the SAME time delta via a cheap compositor
8+
* `translateX` (no re-layout). Passengers do NOT stay still, and they do NOT lag
9+
* behind individually — the whole formation moves by one delta, spacing locked.
10+
*
11+
* That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`.
12+
* The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview
13+
* runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
14+
* the instant any member would cross 0 the grabbed clip stops and every passenger
15+
* stops with it — the formation never deforms. On DROP the commit shifts every
16+
* selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
17+
*
18+
* Track changes apply to the grabbed clip only (mirroring the commit); passengers
19+
* keep their lanes, so only their x moves.
20+
*/
21+
22+
export interface MultiDragPreviewInput {
23+
/** The drag is live (past the movement threshold). */
24+
dragStarted: boolean;
25+
/** Key of the clip under the pointer. */
26+
draggedKey: string;
27+
/** The dragged clip's committed start (pre-drag). */
28+
draggedOriginStart: number;
29+
/** The dragged clip's live preview start (already group-clamped upstream). */
30+
draggedPreviewStart: number;
31+
/** The current multi-selection (store.selectedElementIds). */
32+
selectedKeys: ReadonlySet<string>;
33+
}
34+
35+
/**
36+
* Whether a live multi-selection drag is in effect: the drag started, and the
37+
* dragged clip is itself part of a 2+ multi-selection. Below this, single-drag
38+
* behavior is unchanged and there are no passengers.
39+
*/
40+
export function isMultiDragActive(input: MultiDragPreviewInput): boolean {
41+
return (
42+
input.dragStarted && input.selectedKeys.size > 1 && input.selectedKeys.has(input.draggedKey)
43+
);
44+
}
45+
46+
/**
47+
* The single time delta the WHOLE formation shifts by — the grabbed clip's
48+
* preview start minus its origin start. Because the preview start is already
49+
* group-clamped, this is the clamped delta every member (ghost + passengers)
50+
* moves by. Zero when the clip hasn't moved (or no multi-drag).
51+
*/
52+
export function multiDragDeltaSeconds(input: MultiDragPreviewInput): number {
53+
if (!isMultiDragActive(input)) return 0;
54+
return input.draggedPreviewStart - input.draggedOriginStart;
55+
}
56+
57+
/**
58+
* Whether a specific rendered clip is a passenger — a selected clip that is NOT
59+
* the dragged clip and NOT the same clip key. Passengers get the translateX
60+
* treatment; the dragged clip is drawn as the free-floating ghost instead.
61+
*/
62+
export function isMultiDragPassenger(clipKey: string, input: MultiDragPreviewInput): boolean {
63+
return (
64+
isMultiDragActive(input) && clipKey !== input.draggedKey && input.selectedKeys.has(clipKey)
65+
);
66+
}
67+
68+
/**
69+
* The passenger's rendered x offset in PIXELS (delta seconds × pixels/second),
70+
* to apply as `transform: translateX(...px)`. Every passenger uses the SAME
71+
* formation delta, so the group moves rigidly. Returns 0 for non-passengers so
72+
* callers can compute unconditionally and only branch on the elevated styling.
73+
*/
74+
export function multiDragPassengerOffsetPx(
75+
clipKey: string,
76+
pixelsPerSecond: number,
77+
input: MultiDragPreviewInput,
78+
): number {
79+
if (!isMultiDragPassenger(clipKey, input)) return 0;
80+
const pps = Number.isFinite(pixelsPerSecond) ? pixelsPerSecond : 0;
81+
return multiDragDeltaSeconds(input) * pps;
82+
}
83+
84+
/**
85+
* Clamp a group move so the WHOLE selection moves as ONE rigid formation.
86+
*
87+
* The grabbed clip proposes a raw delta (its desired preview start minus its
88+
* origin start, after its own snapping). Applied naively, a passenger could be
89+
* pushed below 0 (or past any other member bound), and the commit's per-clip
90+
* `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
91+
* the group while a passenger sticks at the wall. This ports main's model
92+
* (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
93+
* bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
94+
* any member hits 0 and the formation never deforms.
95+
*
96+
* `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
97+
* included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
98+
* no fixed right wall (the composition grows on commit).
99+
*/
100+
export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number {
101+
if (memberStarts.length === 0) return rawDelta;
102+
// Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0.
103+
const minStart = Math.min(...memberStarts);
104+
const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
105+
return rawDelta < minDelta ? minDelta : rawDelta;
106+
}

0 commit comments

Comments
 (0)