feat(studio): add variable timeline timing and layout

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:05 +02:00
parent 521bba6437
commit d518972f8b
14 changed files with 671 additions and 188 deletions
@@ -6,7 +6,7 @@ import {
type DragPreviewContext,
} from "./timelineClipDragPreview";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
// ─────────────────────────────────────────────────────────────────────────────
// Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
@@ -55,13 +55,17 @@ function fakeScroll(): HTMLDivElement {
} as unknown as HTMLDivElement;
}
function ctx(): DragPreviewContext {
function ctx(
rowHeights?: readonly number[],
elements: TimelineElement[] = fixtureElements,
): DragPreviewContext {
return {
scroll: fakeScroll(),
pps: PPS,
duration: 44.5,
trackOrder: [0, 1, 2],
elements: fixtureElements,
elements,
rowHeights,
selectedKeys: new Set<string>(),
buildSnapTargets: () => [],
audioTracks: new Set<number>(),
@@ -145,6 +149,49 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
});
it("keeps a horizontal drag in the body of an expanded row out of insert mode", () => {
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
const clientY = RULER_H + TRACKS_TOP_PAD + rowHeights[0] - 8;
const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
const next = computeDragPreview(
{ ...drag, originClientY: clientY, pointerClientY: clientY },
clientX,
clientY,
ctx(rowHeights),
);
expect(next.insertRow).toBeNull();
expect(next.previewTrack).toBe(0);
});
it("uses the expanded row midpoint when choosing the side for an automatic insert", () => {
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H];
const dragged = clip("dragged", 0, 0, 1, 3);
const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
const clientY = RULER_H + TRACKS_TOP_PAD + 30;
const drag: DraggedClipState = {
element: dragged,
originClientX: 0,
originClientY: clientY,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: clientY,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: 0,
previewTrack: 0,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
const next = computeDragPreview(drag, 0, clientY, {
...ctx(rowHeights, occupied),
trackOrder: [0, 1],
});
expect(next.insertRow).toBe(0);
});
});
describe("computeResizePreview — composition source continuity", () => {
@@ -1,6 +1,11 @@
import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
import type { TimelineElement } from "../store/playerStore";
import { TRACK_H, getTimelineRowFromY, INSERT_BOUNDARY_BAND } from "./timelineLayout";
import {
getTimelineInsertBoundaryBand,
getTimelineRowFromY,
getTimelineRowHeight,
getTimelineRowPositionFromY,
} from "./timelineLayout";
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
import {
TIMELINE_SNAP_PX,
@@ -27,6 +32,7 @@ export interface DragPreviewContext {
pps: number;
duration: number;
trackOrder: number[];
rowHeights?: readonly number[];
elements: TimelineElement[];
selectedKeys: ReadonlySet<string>;
buildSnapTargets: BuildSnapTargets;
@@ -81,20 +87,26 @@ function resolveDropPlacement(
desiredTrack: number,
ctx: DragPreviewContext,
): { track: number; insertRow: number | null } {
const { scroll, trackOrder, elements } = ctx;
const { scroll, trackOrder, rowHeights, elements } = ctx;
// rowFloat = the pointer's position in track-heights from the top lane; a
// near-boundary hover requests a deliberate new-track insert. Uses the
// shared row→y inverse so the top breathing pad is subtracted consistently.
const rowFloat = scroll
? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop)
: 0;
// Geometry-exact band (the clip inset) so an insert only arms in the visible
// gutter BETWEEN clip bodies — dragging over a clip body is a lane move, never a
// phantom insert (the plain-horizontal-drag misfire). See INSERT_BOUNDARY_BAND.
const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND);
const rowPosition = scroll
? getTimelineRowPositionFromY(
clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
rowHeights,
)
: { rowFloat: 0, row: 0, fraction: 0, rowHeight: getTimelineRowHeight(0, rowHeights) };
// Geometry-exact band (the clip inset divided by this row's actual height) so
// an insert only arms in the visible gutter between clip bodies.
const rawInsertRow = resolveInsertRow(
rowPosition.rowFloat,
trackOrder.length,
getTimelineInsertBoundaryBand(rowPosition.rowHeight),
);
// Pointer sub-row half: when a drop must auto-create a track (aimed span
// occupied, no free lane), open it on the side the pointer is nearer.
const preferInsertAbove = rowFloat - Math.floor(rowFloat) < 0.5;
const preferInsertAbove = rowPosition.fraction < 0.5;
const audioTracks =
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
return resolveZoneDropPlacement({
@@ -120,24 +132,34 @@ export function computeDragPreview(
): DraggedClipState {
const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
const originRow = getTimelineRowFromY(
drag.originClientY - scrollRectTop + drag.originScrollTop,
ctx.rowHeights,
);
const currentRow = getTimelineRowFromY(clientY - scrollRectTop + scrollTop, ctx.rowHeights);
// resolveTimelineMove's vertical axis is expressed in track-height units.
// Feeding cumulative row coordinates with a unit height preserves its existing
// threshold/create-track behavior while supporting variable pixel heights.
const nextMove = resolveTimelineMove(
{
start: drag.element.start,
track: drag.element.track,
duration: drag.element.duration,
originClientX: drag.originClientX,
originClientY: drag.originClientY,
originClientY: originRow,
originScrollLeft: drag.originScrollLeft,
originScrollTop: drag.originScrollTop,
originScrollTop: 0,
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
currentScrollTop: 0,
pixelsPerSecond: pps,
trackHeight: TRACK_H,
trackHeight: 1,
maxStart: dragMaxStart,
trackOrder,
},
clientX,
clientY,
currentRow,
);
// The music track defines the beats, so it must not snap to them —
// but it still snaps to the playhead and other clip edges.
@@ -1,4 +1,5 @@
import type { TimelineElement } from "../store/playerStore";
import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
/**
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
@@ -139,28 +140,18 @@ export function resolveZoneDropPlacement(input: {
return { track: placement.track, insertRow: null };
}
/**
* Fallback half-width (fraction of a track height) of the insert band straddling
* a lane boundary — used only when the caller passes no explicit band. Production
* threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
* inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
* NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
* back toward the old 0.32 (which armed an insert across ~64% of every row — the
* misfire that turned a plain horizontal drag into a phantom track insert).
*/
const INSERT_BAND = 3 / 48;
/**
* Decide whether a vertical drag is inserting a new track at a lane boundary.
* `rowFloat` is the pointer's position in track-height units from the top of the
* first lane (0 = top of lane 0). Returns the boundary row to insert at
* (0 = above the top lane, `trackCount` = below the bottom), or null when the
* pointer is over a lane's middle band (a normal move/target).
* pointer is over a lane's middle band (a normal move/target). The default band
* preserves collapsed-row behavior; production passes the concrete row's band.
*/
export function resolveInsertRow(
rowFloat: number,
trackCount: number,
band: number = INSERT_BAND,
band: number = INSERT_BOUNDARY_BAND,
): number | null {
if (trackCount === 0) return 0;
if (rowFloat <= 0) return 0;
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
RULER_H,
TRACK_H,
LANE_H,
TRACKS_TOP_PAD,
TRACKS_BOTTOM_PAD,
GUTTER,
@@ -9,10 +10,84 @@ import {
getTimelineRowTop,
getTimelineScrubTime,
getTimelineRowFromY,
getTimelineRowOffsets,
getTimelineCanvasHeight,
trackHeights,
resolveTimelineAssetDrop,
} from "./timelineLayout";
describe("variable timeline row geometry", () => {
const tracks = [
[{ clipId: "a", laneCount: 0 }],
[{ clipId: "b", laneCount: 2 }],
[{ clipId: "c", laneCount: 1 }],
];
it("resolves every row to the base height when no clip is expanded", () => {
expect(trackHeights(tracks)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
expect(trackHeights(3)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
});
it("adds one lane height per lane on an expanded clip", () => {
expect(trackHeights(tracks, new Set(["b"]))).toEqual([TRACK_H, TRACK_H + 2 * LANE_H, TRACK_H]);
});
it("derives row tops from cumulative offsets", () => {
const heights = trackHeights(tracks, new Set(["b"]));
expect(getTimelineRowOffsets(heights)).toEqual([
0,
TRACK_H,
2 * TRACK_H + 2 * LANE_H,
3 * TRACK_H + 2 * LANE_H,
]);
expect(getTimelineRowTop(2, heights)).toBe(RULER_H + TRACKS_TOP_PAD + 2 * TRACK_H + 2 * LANE_H);
});
it("maps y inside an expanded lane region back to the expanded track", () => {
const heights = trackHeights(tracks, new Set(["b"]));
const yInSecondExpandedLane = getTimelineRowTop(1, heights) + TRACK_H + LANE_H * 1.5;
const row = getTimelineRowFromY(yInSecondExpandedLane, heights);
expect(Math.floor(row)).toBe(1);
expect(row).toBeGreaterThan(1.5);
expect(row).toBeLessThan(2);
});
it("sums resolved row heights into the canvas height", () => {
const heights = trackHeights(tracks, new Set(["b"]));
expect(getTimelineCanvasHeight(heights)).toBe(
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD,
);
});
});
describe("collapsed timeline row geometry characterization", () => {
it.each([
[0, 74],
[1, 122],
[4, 266],
])("keeps row %i at content y=%i", (row, expectedTop) => {
expect(getTimelineRowTop(row)).toBe(expectedTop);
});
it.each([
[74, 0],
[86, 0.25],
[146, 1.5],
[290, 4.5],
])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
});
it.each([
[0, 146],
[1, 194],
[3, 290],
[5, 386],
])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
expect(getTimelineCanvasHeight(trackCount)).toBe(expectedHeight);
});
});
describe("track-area breathing pad y-math", () => {
describe("getTimelineRowTop", () => {
it("offsets the first lane below the ruler by the top pad", () => {
@@ -4,25 +4,20 @@ import type { ZoomMode } from "../store/playerStore";
/* ── Layout constants ──────────────────────────────────────────────── */
export const GUTTER = 32;
export const TRACK_H = 48;
export const LANE_H = 28;
export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
/**
* Half-width (as a fraction of TRACK_H) of the new-track INSERT band that
* straddles each lane boundary. Deliberately equals the clip's vertical inset
* (`CLIP_Y / TRACK_H`): a clip body fills [CLIP_Y, TRACK_H CLIP_Y] of its row,
* so the ONLY region this band covers is the visible empty gutter between two
* clip bodies (plus the top/bottom breathing pads, handled separately by the
* rowFloat ≤ 0 / ≥ trackCount extremes). Aiming at a clip body is therefore a
* move-to-that-lane; only the inter-clip gap arms an insert — see resolveInsertRow.
* Threaded into resolveInsertRow by the drag preview so the hit band can never
* drift from the rendered clip geometry.
* Collapsed-row characterization value for the new-track INSERT band. Runtime
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
*/
export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
/**
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
* offset; a track row's top is always `RULER_H + TRACKS_TOP_PAD + row*TRACK_H`.
* offset; a track row's top is always ruler + top pad + cumulative row heights.
*
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
* (~half a track height) so the first clip isn't jammed under the ruler.
@@ -50,17 +45,108 @@ export const TRACKS_LEFT_PAD = 48;
* placeholder/insertion top and every pointer-y→row inversion goes through this
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
*/
export function getTimelineRowTop(row: number): number {
return RULER_H + TRACKS_TOP_PAD + row * TRACK_H;
interface TimelineTrackHeightClip {
clipId: string;
laneCount: number;
}
type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
/**
* Resolve each track's full height. Without expansion state every row is the
* legacy TRACK_H; if multiple clips in one track expand, the tallest one owns
* the shared row height.
*/
export function trackHeights(
tracks: number | TimelineTrackHeightInput,
expandedClipIds?: ReadonlySet<string>,
): number[] {
if (typeof tracks === "number") {
return Array.from({ length: Math.max(0, Math.trunc(tracks)) }, () => TRACK_H);
}
return tracks.map((clips) => {
let laneCount = 0;
if (expandedClipIds) {
for (const clip of clips) {
if (expandedClipIds.has(clip.clipId)) laneCount = Math.max(laneCount, clip.laneCount);
}
}
return TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H;
});
}
function validRowHeight(height: number | undefined): number {
if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H;
return height;
}
/** Cumulative top offsets, including the final bottom boundary. */
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
const offsets = [0];
for (const height of rowHeights) {
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
}
return offsets;
}
export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number {
return validRowHeight(rowHeights[row]);
}
function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
if (rowHeights.length === 0) return row * TRACK_H;
const offsets = getTimelineRowOffsets(rowHeights);
if (row <= 0) return row * getTimelineRowHeight(0, rowHeights);
if (row >= rowHeights.length) {
return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H;
}
const wholeRow = Math.floor(row);
const fraction = row - wholeRow;
return (offsets[wholeRow] ?? 0) + fraction * getTimelineRowHeight(wholeRow, rowHeights);
}
export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number {
return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
}
/**
* Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
* space y (used for insert-row / drop-lane decisions). Subtracts the ruler and
* top pad before dividing by the track height.
* space y (used for insert-row / drop-lane decisions). Locates the concrete row
* from cumulative offsets, then returns its local fractional position.
*/
export function getTimelineRowFromY(contentY: number): number {
return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H;
export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number {
const y = contentY - RULER_H - TRACKS_TOP_PAD;
if (rowHeights.length === 0) return y / TRACK_H;
if (y < 0) return y / getTimelineRowHeight(0, rowHeights);
const offsets = getTimelineRowOffsets(rowHeights);
for (let row = 0; row < rowHeights.length; row += 1) {
const bottom = offsets[row + 1] ?? 0;
if (y < bottom) {
const top = offsets[row] ?? 0;
return row + (y - top) / getTimelineRowHeight(row, rowHeights);
}
}
return rowHeights.length + (y - (offsets[rowHeights.length] ?? 0)) / TRACK_H;
}
export function getTimelineRowPositionFromY(
contentY: number,
rowHeights: readonly number[] = [],
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
const rowFloat = getTimelineRowFromY(contentY, rowHeights);
const row = Math.floor(rowFloat);
return {
rowFloat,
row,
fraction: rowFloat - row,
rowHeight: getTimelineRowHeight(row, rowHeights),
};
}
/** Fractional insert band for the concrete row under a pointer. */
export function getTimelineInsertBoundaryBand(rowHeight: number): number {
return CLIP_Y / validRowHeight(rowHeight);
}
/**
* While a clip drag is live, the rendered timeline extends this far past the
@@ -344,11 +430,16 @@ export function getTimelineScrubTime(input: {
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
}
export function getTimelineCanvasHeight(trackCount: number): number {
export function getTimelineCanvasHeight(trackCountOrHeights: number | readonly number[]): number {
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
// below the last lane is real scrollable surface, not a hidden buffer.
return RULER_H + TRACKS_TOP_PAD + Math.max(0, trackCount) * TRACK_H + TRACKS_BOTTOM_PAD;
const heights =
typeof trackCountOrHeights === "number"
? trackHeights(trackCountOrHeights)
: trackCountOrHeights;
const rowsHeight = getTimelineRowOffsets(heights).at(-1) ?? 0;
return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
}
/* ── UI helpers ───────────────────────────────────────────────────── */
@@ -9,6 +9,7 @@ import {
} from "./timelineMarquee";
import {
GUTTER,
LANE_H,
TRACK_H,
RULER_H,
CLIP_Y,
@@ -16,7 +17,9 @@ import {
getTimelineRowTop,
} from "./timelineLayout";
// Canvas-space time origin: right edge of the sticky gutter + the left pad.
// Canvas-space time origin used by the breathing-pad (default) test cases: right
// edge of the sticky gutter + the left pad. Other cases pass GUTTER or LABEL_COL_W
// directly as contentOrigin to test the plain/keyframe-label-column origins.
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
describe("isTimelineRulerPress", () => {
@@ -94,9 +97,9 @@ describe("getTimelineClipRect", () => {
const trackOrder = [0, 2, 5];
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100, GUTTER);
expect(rect).toEqual({
left: ORIGIN + 200,
left: GUTTER + 200,
top: getTimelineRowTop(1) + CLIP_Y,
width: 300,
height: TRACK_H - CLIP_Y * 2,
@@ -104,25 +107,55 @@ describe("getTimelineClipRect", () => {
});
it("places the first visible track below the ruler + top breathing pad", () => {
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50, GUTTER);
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
expect(rect?.left).toBe(ORIGIN);
expect(rect?.left).toBe(GUTTER);
});
it("uses the row index in trackOrder, not the raw track number", () => {
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50);
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50, GUTTER);
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
});
it("uses cumulative tops and the resolved height for an expanded row", () => {
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
const rect = getTimelineClipRect(
{ start: 0, duration: 1, track: 0 },
trackOrder,
50,
GUTTER,
rowHeights,
);
expect(rect).toMatchObject({
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
height: rowHeights[0] - CLIP_Y * 2,
});
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights)
?.top,
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
});
it("enforces the 4px minimum rendered width", () => {
const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, trackOrder, 10);
const rect = getTimelineClipRect(
{ start: 0, duration: 0.01, track: 0 },
trackOrder,
10,
GUTTER,
);
expect(rect?.width).toBe(4);
});
it("returns null for a track that is not displayed or an invalid pps", () => {
expect(getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100)).toBeNull();
expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull();
expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull();
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER),
).toBeNull();
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER),
).toBeNull();
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER),
).toBeNull();
});
});
@@ -140,29 +173,48 @@ describe("computeMarqueeSelection", () => {
it("selects only the clips the marquee rect intersects", () => {
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
const { ids, primaryId } = computeMarqueeSelection({
clips,
trackOrder,
pps,
contentOrigin: ORIGIN,
marquee,
});
expect(ids).toEqual(new Set(["a"]));
expect(primaryId).toBe("a");
});
it("selects across tracks when the rect spans multiple rows", () => {
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
const { ids } = computeMarqueeSelection({
clips,
trackOrder,
pps,
contentOrigin: ORIGIN,
marquee,
});
expect(ids).toEqual(new Set(["a", "c"]));
});
it("excludes clips outside the rect horizontally", () => {
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
const { ids } = computeMarqueeSelection({
clips,
trackOrder,
pps,
contentOrigin: ORIGIN,
marquee,
});
expect(ids).toEqual(new Set());
});
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({
clips,
trackOrder,
pps,
contentOrigin: GUTTER,
marquee,
baseSelection: ["b"],
});
@@ -171,11 +223,12 @@ describe("computeMarqueeSelection", () => {
});
it("unions additive base selection with new hits; primary comes from the marquee", () => {
const marquee = { left: ORIGIN, top: row1Top, width: 100, height: 10 };
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({
clips,
trackOrder,
pps,
contentOrigin: GUTTER,
marquee,
baseSelection: ["b"],
});
@@ -186,12 +239,13 @@ describe("computeMarqueeSelection", () => {
it("shrinking the rect live drops clips it no longer covers", () => {
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
new Set(["a", "b"]),
);
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual(
new Set(["a"]),
);
expect(
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids,
).toEqual(new Set(["a", "b"]));
expect(
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow })
.ids,
).toEqual(new Set(["a"]));
});
it("ignores clips on hidden/undisplayed tracks", () => {
@@ -200,6 +254,7 @@ describe("computeMarqueeSelection", () => {
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
trackOrder,
pps,
contentOrigin: GUTTER,
marquee,
});
expect(ids).toEqual(new Set());
@@ -1,9 +1,9 @@
import {
GUTTER,
TRACK_H,
RULER_H,
CLIP_Y,
TRACKS_LEFT_PAD,
getTimelineRowHeight,
getTimelineRowTop,
} from "./timelineLayout";
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
@@ -68,22 +68,24 @@ export function getMarqueeRect(
/**
* A clip's rendered rect in canvas/content coordinates (the same space the
* marquee rect lives in): x from GUTTER + start * pps, y from the clip's row
* index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y).
* marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row
* index within the visible track order (cumulative row top + CLIP_Y).
* Returns null when the clip's track is not currently displayed.
*/
export function getTimelineClipRect(
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
trackOrder: number[],
pps: number,
contentOrigin: number = GUTTER + TRACKS_LEFT_PAD,
rowHeights: readonly number[] = [],
): Rect | null {
const row = trackOrder.indexOf(clip.track);
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
return {
left: GUTTER + TRACKS_LEFT_PAD + clip.start * pps,
top: getTimelineRowTop(row) + CLIP_Y,
left: contentOrigin + clip.start * pps,
top: getTimelineRowTop(row, rowHeights) + CLIP_Y,
width: Math.max(clip.duration * pps, MIN_CLIP_W),
height: TRACK_H - CLIP_Y * 2,
height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2,
};
}
@@ -103,13 +105,21 @@ export function computeMarqueeSelection(input: {
clips: MarqueeClipInput[];
trackOrder: number[];
pps: number;
contentOrigin?: number;
marquee: Rect;
baseSelection?: Iterable<string>;
rowHeights?: readonly number[];
}): MarqueeSelectionResult {
const ids = new Set<string>(input.baseSelection ?? []);
let primaryId: string | null = null;
for (const clip of input.clips) {
const rect = getTimelineClipRect(clip, input.trackOrder, input.pps);
const rect = getTimelineClipRect(
clip,
input.trackOrder,
input.pps,
input.contentOrigin,
input.rowHeights,
);
if (rect && rectsOverlap(rect, input.marquee)) {
ids.add(clip.id);
primaryId = clip.id;
@@ -48,6 +48,7 @@ interface UseTimelineClipDragInput {
ppsRef: React.RefObject<number>;
durationRef: React.RefObject<number>;
trackOrderRef: React.RefObject<number[]>;
rowHeightsRef?: React.RefObject<readonly number[]>;
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
@@ -85,6 +86,7 @@ export function useTimelineClipDrag({
ppsRef,
durationRef,
trackOrderRef,
rowHeightsRef,
onMoveElement,
onMoveElements,
onResizeElement,
@@ -241,13 +243,14 @@ export function useTimelineClipDrag({
pps: ppsRef.current,
duration: durationRef.current,
trackOrder: trackOrderRef.current,
rowHeights: rowHeightsRef?.current,
elements: elementsRef.current,
selectedKeys: usePlayerStore.getState().selectedElementIds,
buildSnapTargets,
audioTracks: dragAudioTracksRef.current,
});
},
[scrollRef, ppsRef, durationRef, trackOrderRef, buildSnapTargets],
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, buildSnapTargets],
);
// Recompute the trim preview for a pointer x. Shared by the pointermove resize