feat(studio): add timeline marquee selection

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent 1cf601202d
commit 45d09047b7
9 changed files with 856 additions and 60 deletions
@@ -8,6 +8,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { EditPopover } from "./EditModal"; import { EditPopover } from "./EditModal";
import { defaultTimelineTheme } from "./timelineTheme"; import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelineMarqueeSelection } from "./useTimelineMarqueeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineActiveClips } from "./useTimelineActiveClips"; import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons"; import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
@@ -21,6 +22,7 @@ import {
type KeyframeDiamondContextMenuState, type KeyframeDiamondContextMenuState,
} from "./KeyframeDiamondContextMenu"; } from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
import { ClipContextMenu } from "./ClipContextMenu"; import { ClipContextMenu } from "./ClipContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint"; import { TimelineShortcutHint } from "./TimelineShortcutHint";
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder"; import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
@@ -36,7 +38,6 @@ import {
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes"; import type { TimelineProps } from "./TimelineTypes";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export { export {
generateTicks, generateTicks,
formatTimelineTickLabel, formatTimelineTickLabel,
@@ -206,7 +207,6 @@ export const Timeline = memo(function Timeline({
const ppsRef = useRef(100); const ppsRef = useRef(100);
const durationRef = useRef(Number.isFinite(duration) ? duration : 0); const durationRef = useRef(Number.isFinite(duration) ? duration : 0);
// Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep
const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null); const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null);
const { const {
@@ -230,7 +230,6 @@ export const Timeline = memo(function Timeline({
setRangeSelectionRef, setRangeSelectionRef,
}); });
// basis drives the zoom (committed); effective adds the live preview (see timelineLayout).
const basisDuration = useMemo( const basisDuration = useMemo(
() => () =>
computeTimelineBasisDuration( computeTimelineBasisDuration(
@@ -269,6 +268,15 @@ export const Timeline = memo(function Timeline({
const keyframeCache = usePlayerStore((s) => s.keyframeCache); const keyframeCache = usePlayerStore((s) => s.keyframeCache);
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const keyframeHandlers = useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
});
const selectedElement = useMemo( const selectedElement = useMemo(
() => () =>
@@ -278,7 +286,6 @@ export const Timeline = memo(function Timeline({
const selectedElementRef = useRef<TimelineElement | null>(selectedElement); const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
selectedElementRef.current = selectedElement; selectedElementRef.current = selectedElement;
// Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom.
const fitPps = const fitPps =
viewportWidth > GUTTER && basisDuration > 0 viewportWidth > GUTTER && basisDuration > 0
? (viewportWidth - GUTTER - 2) / basisDuration ? (viewportWidth - GUTTER - 2) / basisDuration
@@ -346,7 +353,20 @@ export const Timeline = memo(function Timeline({
isDragging, isDragging,
setShowPopover, setShowPopover,
}); });
// Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag const {
marqueeRect,
handlePointerDown: handleMarqueePointerDown,
handlePointerMove: handleMarqueePointerMove,
handlePointerUp: handleMarqueePointerUp,
} = useTimelineMarqueeSelection({
scrollRef,
ppsRef,
trackOrderRef,
timelineLayersRef,
disabled: activeTool === "razor",
setShowPopover,
setRangeSelectionRef,
});
setRangeSelectionRef.current = setRangeSelection; setRangeSelectionRef.current = setRangeSelection;
const prevSelectedRef = useRef(selectedElementRef.current); const prevSelectedRef = useRef(selectedElementRef.current);
@@ -445,11 +465,21 @@ export const Timeline = memo(function Timeline({
onRazorSplitAll?.(splitTime); onRazorSplitAll?.(splitTime);
return; return;
} }
if (handleMarqueePointerDown(e)) return;
handlePointerDown(e); handlePointerDown(e);
}} }}
onPointerMove={handlePointerMove} onPointerMove={(e) => {
onPointerUp={handlePointerUp} if (handleMarqueePointerMove(e)) return;
onLostPointerCapture={handlePointerUp} handlePointerMove(e);
}}
onPointerUp={(e) => {
if (handleMarqueePointerUp(e)) return;
handlePointerUp();
}}
onLostPointerCapture={(e) => {
if (handleMarqueePointerUp(e)) return;
handlePointerUp();
}}
> >
<TimelineCanvas <TimelineCanvas
major={major} major={major}
@@ -460,6 +490,7 @@ export const Timeline = memo(function Timeline({
effectiveDuration={effectiveDuration} effectiveDuration={effectiveDuration}
majorTickInterval={majorTickInterval} majorTickInterval={majorTickInterval}
rangeSelection={rangeSelection} rangeSelection={rangeSelection}
marqueeRect={marqueeRect}
theme={theme} theme={theme}
displayTrackOrder={displayTrackOrder} displayTrackOrder={displayTrackOrder}
trackOrder={trackOrder} trackOrder={trackOrder}
@@ -491,41 +522,10 @@ export const Timeline = memo(function Timeline({
selectedKeyframes={selectedKeyframes} selectedKeyframes={selectedKeyframes}
currentTime={currentTime} currentTime={currentTime}
beatAnalysis={adjustedBeatAnalysis} beatAnalysis={adjustedBeatAnalysis}
onClickKeyframe={(el, pct) => { onClickKeyframe={keyframeHandlers.onClickKeyframe}
usePlayerStore.getState().clearSelectedKeyframes(); onShiftClickKeyframe={keyframeHandlers.onShiftClickKeyframe}
const elKey = el.key ?? el.id;
setSelectedElementId(elKey);
onSelectElement?.(el);
// Visually select the clicked diamond (matches shift-click / motion-path
// selection); cleared above so this single-selects it.
toggleSelectedKeyframe(`${elKey}:${pct}`);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
const kfData = keyframeCache?.get(elKey);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
}}
onShiftClickKeyframe={(elId, pct) => {
toggleSelectedKeyframe(`${elId}:${pct}`);
}}
onMoveKeyframe={onMoveKeyframe} onMoveKeyframe={onMoveKeyframe}
onContextMenuKeyframe={(e, elId, pct) => { onContextMenuKeyframe={keyframeHandlers.onContextMenuKeyframe}
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
if (el) {
setSelectedElementId(elId);
onSelectElement?.(el);
}
const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
setKfContextMenu({
x: e.clientX + 4,
y: e.clientY + 2,
elementId: elId,
percentage: pct,
tweenPercentage: kf?.tweenPercentage,
currentEase: kf?.ease ?? kfData?.ease,
});
}}
onContextMenuClip={(e, el) => { onContextMenuClip={(e, el) => {
e.preventDefault(); e.preventDefault();
setSelectedElementId(el.key ?? el.id); setSelectedElementId(el.key ?? el.id);
@@ -11,7 +11,7 @@ import {
type TimelineRangeSelection, type TimelineRangeSelection,
} from "./timelineEditing"; } from "./timelineEditing";
import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"; import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
import { import {
usePlayerStore, usePlayerStore,
type TimelineElement, type TimelineElement,
@@ -32,6 +32,8 @@ import {
import { resolveTimelineDropIndicator } from "./timelineDropIndicator"; import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine"; import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine";
import { TimelineDragGhost } from "./TimelineDragGhost"; import { TimelineDragGhost } from "./TimelineDragGhost";
import { TimelineSelectionOverlays } from "./TimelineSelectionOverlays";
import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection";
function ClipLintDot({ element }: { element: TimelineElement }) { function ClipLintDot({ element }: { element: TimelineElement }) {
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id)); const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
@@ -54,6 +56,7 @@ interface TimelineCanvasProps {
effectiveDuration: number; effectiveDuration: number;
majorTickInterval: number; majorTickInterval: number;
rangeSelection: TimelineRangeSelection | null; rangeSelection: TimelineRangeSelection | null;
marqueeRect: TimelineMarqueeOverlayRect | null;
theme: TimelineTheme; theme: TimelineTheme;
displayTrackOrder: TimelineLayerId[]; displayTrackOrder: TimelineLayerId[];
trackOrder: TimelineLayerId[]; trackOrder: TimelineLayerId[];
@@ -112,6 +115,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
effectiveDuration, effectiveDuration,
majorTickInterval, majorTickInterval,
rangeSelection, rangeSelection,
marqueeRect,
theme, theme,
displayTrackOrder, displayTrackOrder,
trackOrder, trackOrder,
@@ -561,22 +565,11 @@ export const TimelineCanvas = memo(function TimelineCanvas({
</TimelineDragGhost> </TimelineDragGhost>
)} )}
{/* Range highlight */} <TimelineSelectionOverlays
{rangeSelection && ( rangeSelection={rangeSelection}
<div marqueeRect={marqueeRect}
className="absolute pointer-events-none" pps={pps}
style={{ />
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
top: RULER_H,
bottom: 0,
backgroundColor: "rgba(59, 130, 246, 0.12)",
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
zIndex: 50,
}}
/>
)}
{/* Playhead — hidden while dragging a beat so its guideline doesn't {/* Playhead — hidden while dragging a beat so its guideline doesn't
track the scrub and clutter the beat being moved. */} track the scrub and clutter the beat being moved. */}
@@ -2,7 +2,7 @@ import type { TimelineTheme } from "./timelineTheme";
import { GUTTER } from "./timelineLayout"; import { GUTTER } from "./timelineLayout";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
const TIMELINE_LAYER_GROUP_HEADER_H = 18; export const TIMELINE_LAYER_GROUP_HEADER_H = 18;
export function shouldShowTimelineLayerGroupHeader( export function shouldShowTimelineLayerGroupHeader(
contextKey: string, contextKey: string,
@@ -0,0 +1,52 @@
import type { TimelineRangeSelection } from "./timelineEditing";
import { GUTTER, RULER_H } from "./timelineLayout";
import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection";
interface TimelineSelectionOverlaysProps {
rangeSelection: TimelineRangeSelection | null;
marqueeRect: TimelineMarqueeOverlayRect | null;
pps: number;
}
export function TimelineSelectionOverlays({
rangeSelection,
marqueeRect,
pps,
}: TimelineSelectionOverlaysProps) {
return (
<>
{rangeSelection && (
<div
className="absolute pointer-events-none"
style={{
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
top: RULER_H,
bottom: 0,
backgroundColor: "rgba(59, 130, 246, 0.12)",
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
zIndex: 50,
}}
/>
)}
{marqueeRect && (
<div
aria-hidden="true"
className="absolute pointer-events-none"
data-timeline-marquee="true"
style={{
left: marqueeRect.left,
top: marqueeRect.top,
width: marqueeRect.width,
height: marqueeRect.height,
backgroundColor: "rgba(96, 165, 250, 0.16)",
border: "1px solid rgba(147, 197, 253, 0.72)",
boxShadow: "0 0 0 1px rgba(15, 23, 42, 0.35)",
zIndex: 60,
}}
/>
)}
</>
);
}
@@ -253,6 +253,108 @@ export interface TimelineRangeSelection {
anchorY: number; anchorY: number;
} }
export interface TimelineMarqueeSelectionRect {
startTime: number;
endTime: number;
top: number;
bottom: number;
}
export interface TimelineMarqueeSelectionInput {
rect: TimelineMarqueeSelectionRect;
layers: readonly StackingTimelineLayer[];
layerOrder: readonly TimelineLayerId[];
rulerHeight: number;
trackHeight: number;
groupHeaderHeight?: number;
}
interface NormalizedTimelineMarqueeRect {
startTime: number;
endTime: number;
top: number;
bottom: number;
}
function timelineIntervalsOverlap(
startA: number,
endA: number,
startB: number,
endB: number,
): boolean {
return startA < endB && startB < endA;
}
function normalizeTimelineMarqueeRect(
rect: TimelineMarqueeSelectionRect,
): NormalizedTimelineMarqueeRect | null {
const normalized = {
startTime: Math.max(0, Math.min(rect.startTime, rect.endTime)),
endTime: Math.max(0, Math.max(rect.startTime, rect.endTime)),
top: Math.min(rect.top, rect.bottom),
bottom: Math.max(rect.top, rect.bottom),
};
if (normalized.endTime <= normalized.startTime || normalized.bottom <= normalized.top) {
return null;
}
return normalized;
}
function buildTimelineLayerMap(layers: readonly StackingTimelineLayer[]) {
const layerById = new Map<TimelineLayerId, StackingTimelineLayer>();
for (const layer of layers) layerById.set(layer.id, layer);
return layerById;
}
function appendMarqueeLayerSelection(
selected: string[],
layer: StackingTimelineLayer,
rect: NormalizedTimelineMarqueeRect,
) {
for (const element of layer.elements) {
if (
timelineIntervalsOverlap(
rect.startTime,
rect.endTime,
element.start,
element.start + element.duration,
)
) {
selected.push(element.key ?? element.id);
}
}
}
export function selectTimelineElementsInMarquee({
rect,
layers,
layerOrder,
rulerHeight,
trackHeight,
groupHeaderHeight = 0,
}: TimelineMarqueeSelectionInput): string[] {
const normalized = normalizeTimelineMarqueeRect(rect);
if (!normalized) return [];
const layerById = buildTimelineLayerMap(layers);
const selected: string[] = [];
let previousContextKey = "";
let rowTop = rulerHeight;
for (const layerId of layerOrder) {
const layer = layerById.get(layerId);
if (!layer) continue;
if (layer.contextKey !== "" && layer.contextKey !== previousContextKey) {
rowTop += groupHeaderHeight;
}
const rowBottom = rowTop + trackHeight;
if (timelineIntervalsOverlap(normalized.top, normalized.bottom, rowTop, rowBottom)) {
appendMarqueeLayerSelection(selected, layer, normalized);
}
previousContextKey = layer.contextKey;
rowTop = rowBottom;
}
return selected;
}
function isDeterministicTimelineWindow(input: { function isDeterministicTimelineWindow(input: {
tag: string; tag: string;
compositionSrc?: string; compositionSrc?: string;
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { RULER_H, TRACK_H } from "./timelineLayout";
import { selectTimelineElementsInMarquee } from "./timelineEditing";
import type { StackingTimelineLayer } from "./timelineTrackOrder";
function element(id: string, start: number, duration: number, track: number): TimelineElement {
return { id, tag: "div", start, duration, track };
}
function layer(id: string, elements: TimelineElement[]): StackingTimelineLayer {
return {
id,
kind: "visual",
contextKey: "",
zIndex: 0,
placementTrack: elements[0]?.track ?? 0,
elements,
};
}
describe("selectTimelineElementsInMarquee", () => {
it("selects clips intersecting both the marquee time span and lane span", () => {
const layers = [
layer("lane-0", [element("first-hit", 1, 1, 0), element("time-miss", 5, 1, 0)]),
layer("lane-1", [element("second-hit", 2.25, 1, 1)]),
layer("lane-2", [element("lane-miss", 1.5, 1, 2)]),
];
expect(
selectTimelineElementsInMarquee({
rect: {
startTime: 0.5,
endTime: 3,
top: RULER_H,
bottom: RULER_H + TRACK_H * 2,
},
layers,
layerOrder: layers.map((item) => item.id),
rulerHeight: RULER_H,
trackHeight: TRACK_H,
}),
).toEqual(["first-hit", "second-hit"]);
});
it("returns no ids when the marquee intersects no clip", () => {
const layers = [layer("lane-0", [element("outside", 4, 1, 0)])];
expect(
selectTimelineElementsInMarquee({
rect: {
startTime: 0,
endTime: 1,
top: RULER_H,
bottom: RULER_H + TRACK_H,
},
layers,
layerOrder: ["lane-0"],
rulerHeight: RULER_H,
trackHeight: TRACK_H,
}),
).toEqual([]);
});
it("accounts for context group headers before row hit testing", () => {
const layers: StackingTimelineLayer[] = [
{ ...layer("lane-0", [element("above", 0, 1, 0)]), contextKey: "root" },
{ ...layer("lane-1", [element("below", 0, 1, 1)]), contextKey: "nested" },
];
expect(
selectTimelineElementsInMarquee({
rect: {
startTime: 0,
endTime: 1,
top: RULER_H + 18 + TRACK_H,
bottom: RULER_H + 18 + TRACK_H + 18 + TRACK_H,
},
layers,
layerOrder: ["lane-0", "lane-1"],
rulerHeight: RULER_H,
trackHeight: TRACK_H,
groupHeaderHeight: 18,
}),
).toEqual(["below"]);
});
});
@@ -0,0 +1,73 @@
import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
interface UseTimelineKeyframeHandlersInput {
expandedElements: TimelineElement[];
keyframeCache: Map<string, KeyframeCacheEntry>;
onSelectElement?: (element: TimelineElement | null) => void;
onSeek?: (time: number) => void;
setSelectedElementId: (id: string | null) => void;
setKfContextMenu: (state: KeyframeDiamondContextMenuState | null) => void;
toggleSelectedKeyframe: (key: string) => void;
}
export function useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
}: UseTimelineKeyframeHandlersInput) {
const onClickKeyframe = useCallback(
(el: TimelineElement, pct: number) => {
usePlayerStore.getState().clearSelectedKeyframes();
const elKey = el.key ?? el.id;
setSelectedElementId(elKey);
onSelectElement?.(el);
toggleSelectedKeyframe(`${elKey}:${pct}`);
onSeek?.(el.start + (pct / 100) * el.duration);
const kfData = keyframeCache.get(elKey);
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.5);
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
},
[keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
);
const onShiftClickKeyframe = useCallback(
(elId: string, pct: number) => {
toggleSelectedKeyframe(`${elId}:${pct}`);
},
[toggleSelectedKeyframe],
);
const onContextMenuKeyframe = useCallback(
(e: ReactMouseEvent, elId: string, pct: number) => {
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
if (el) {
setSelectedElementId(elId);
onSelectElement?.(el);
}
const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
setKfContextMenu({
x: e.clientX + 4,
y: e.clientY + 2,
elementId: elId,
percentage: pct,
tweenPercentage: kf?.tweenPercentage,
currentEase: kf?.ease ?? kfData?.ease,
});
},
[expandedElements, keyframeCache, onSelectElement, setKfContextMenu, setSelectedElementId],
);
return {
onClickKeyframe,
onShiftClickKeyframe,
onContextMenuKeyframe,
};
}
@@ -0,0 +1,226 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { GUTTER, RULER_H, TRACK_H } from "./timelineLayout";
import type { StackingTimelineLayer } from "./timelineTrackOrder";
import { useTimelineMarqueeSelection } from "./useTimelineMarqueeSelection";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function element(id: string, start: number, duration: number, track: number): TimelineElement {
return { id, tag: "div", start, duration, track };
}
function layer(id: string, elements: TimelineElement[]): StackingTimelineLayer {
return {
id,
kind: "visual",
contextKey: "",
zIndex: 0,
placementTrack: elements[0]?.track ?? 0,
elements,
};
}
function pointerEvent(type: string, init: MouseEventInit & { pointerId?: number }) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 1 });
return event;
}
function dispatchPointer(
target: HTMLElement,
type: "pointerdown" | "pointermove" | "pointerup",
point: { x: number; y: number },
) {
target.dispatchEvent(pointerEvent(type, { button: 0, clientX: point.x, clientY: point.y }));
}
function dragMarquee(
harness: ReturnType<typeof renderMarqueeHarness>,
start: { x: number; y: number },
end: { x: number; y: number },
downTarget: HTMLElement = harness.scroll,
) {
dispatchPointer(downTarget, "pointerdown", start);
dispatchPointer(harness.scroll, "pointermove", end);
dispatchPointer(harness.scroll, "pointerup", end);
}
function renderMarqueeHarness(layers: StackingTimelineLayer[]) {
const host = document.createElement("div");
document.body.append(host);
const layerOrder = layers.map((item) => item.id);
const setShowPopover = () => {};
const setRangeSelection = () => {};
function Harness() {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const hook = useTimelineMarqueeSelection({
scrollRef,
ppsRef: { current: 100 },
trackOrderRef: { current: layerOrder },
timelineLayersRef: { current: layers },
setShowPopover,
setRangeSelectionRef: { current: setRangeSelection },
});
return (
<div
ref={scrollRef}
data-scroll="true"
onPointerDown={(event) => {
hook.handlePointerDown(event);
}}
onPointerMove={(event) => {
hook.handlePointerMove(event);
}}
onPointerUp={(event) => {
hook.handlePointerUp(event);
}}
>
<button data-clip="true">clip</button>
{hook.marqueeRect && <span data-marquee="true" />}
</div>
);
}
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
const scroll = host.querySelector<HTMLElement>("[data-scroll]");
if (!scroll) throw new Error("Expected scroll host");
Object.defineProperty(scroll, "clientWidth", { configurable: true, value: 160 });
Object.defineProperty(scroll, "clientHeight", { configurable: true, value: 160 });
Object.defineProperty(scroll, "scrollWidth", { configurable: true, value: 600 });
Object.defineProperty(scroll, "scrollHeight", { configurable: true, value: 260 });
scroll.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 160,
bottom: 160,
width: 160,
height: 160,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
return {
host,
scroll,
root,
clip: host.querySelector<HTMLElement>("[data-clip]")!,
unmount() {
act(() => root.unmount());
},
};
}
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.getState().reset();
});
describe("useTimelineMarqueeSelection", () => {
it("selects clips intersecting an empty-lane drag rectangle", () => {
const layers = [
layer("lane-0", [element("first", 0.5, 0.5, 0)]),
layer("lane-1", [element("second", 2, 0.5, 1)]),
layer("lane-2", [element("third", 0.5, 0.5, 2)]),
];
const harness = renderMarqueeHarness(layers);
const start = { x: GUTTER + 10, y: RULER_H + 4 };
const end = { x: GUTTER + 260, y: RULER_H + TRACK_H * 2 - 2 };
act(() => {
dispatchPointer(harness.scroll, "pointerdown", start);
dispatchPointer(harness.scroll, "pointermove", end);
});
expect(harness.host.querySelector("[data-marquee]")).not.toBeNull();
act(() => {
dispatchPointer(harness.scroll, "pointerup", end);
});
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["first", "second"]);
harness.unmount();
});
it("treats a sub-threshold empty-lane drag as a clear click", () => {
usePlayerStore.getState().setSelection(["selected"]);
const harness = renderMarqueeHarness([layer("lane-0", [element("selected", 0, 1, 0)])]);
act(() => {
dragMarquee(harness, { x: GUTTER + 20, y: RULER_H + 4 }, { x: GUTTER + 21, y: RULER_H + 5 });
});
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
expect(harness.host.querySelector("[data-marquee]")).toBeNull();
harness.unmount();
});
it("does not start from clips or the ruler", () => {
usePlayerStore.getState().setSelection(["kept"]);
const harness = renderMarqueeHarness([layer("lane-0", [element("kept", 0, 1, 0)])]);
act(() => {
dragMarquee(
harness,
{ x: GUTTER + 20, y: RULER_H + 4 },
{ x: GUTTER + 80, y: RULER_H + 40 },
harness.clip,
);
dragMarquee(harness, { x: GUTTER + 20, y: RULER_H - 2 }, { x: GUTTER + 80, y: RULER_H + 40 });
});
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["kept"]);
expect(harness.host.querySelector("[data-marquee]")).toBeNull();
harness.unmount();
});
it("clears selection when the released marquee hits no clips", () => {
usePlayerStore.getState().setSelection(["selected"]);
const harness = renderMarqueeHarness([layer("lane-0", [element("selected", 4, 1, 0)])]);
act(() => {
dragMarquee(harness, { x: GUTTER + 10, y: RULER_H + 4 }, { x: GUTTER + 80, y: RULER_H + 40 });
});
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
harness.unmount();
});
it("uses autoscroll position when resolving the released marquee", () => {
const originalRaf = window.requestAnimationFrame;
const originalCancel = window.cancelAnimationFrame;
const callbacks: FrameRequestCallback[] = [];
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callbacks.push(callback);
return callbacks.length;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
const harness = renderMarqueeHarness([layer("lane-0", [element("reachable", 1.28, 0.2, 0)])]);
try {
act(() => {
dispatchPointer(harness.scroll, "pointerdown", { x: GUTTER + 10, y: RULER_H + 4 });
dispatchPointer(harness.scroll, "pointermove", { x: 155, y: RULER_H + 40 });
callbacks.shift()?.(0);
dispatchPointer(harness.scroll, "pointerup", { x: 155, y: RULER_H + 40 });
});
expect(harness.scroll.scrollLeft).toBeGreaterThan(0);
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["reachable"]);
harness.unmount();
} finally {
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCancel;
}
});
});
@@ -0,0 +1,263 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject,
} from "react";
import { usePlayerStore } from "../store/playerStore";
import {
resolveTimelineAutoScroll,
selectTimelineElementsInMarquee,
type TimelineMarqueeSelectionRect,
} from "./timelineEditing";
import { GUTTER, RULER_H, TRACK_H } from "./timelineLayout";
import { TIMELINE_LAYER_GROUP_HEADER_H } from "./TimelineLayerGroupHeader";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
const MARQUEE_THRESHOLD_PX = 4;
export interface TimelineMarqueeOverlayRect {
left: number;
top: number;
width: number;
height: number;
}
interface ActiveMarqueeGesture {
pointerId: number;
anchorClientX: number;
anchorClientY: number;
anchorX: number;
anchorY: number;
lastClientX: number;
lastClientY: number;
started: boolean;
}
interface UseTimelineMarqueeSelectionInput {
scrollRef: RefObject<HTMLDivElement | null>;
ppsRef: RefObject<number>;
trackOrderRef: RefObject<TimelineLayerId[]>;
timelineLayersRef: RefObject<StackingTimelineLayer[]>;
disabled?: boolean;
setShowPopover: (show: boolean) => void;
setRangeSelectionRef: RefObject<((sel: null) => void) | null>;
}
function getCanvasPoint(scroll: HTMLDivElement, clientX: number, clientY: number) {
const rect = scroll.getBoundingClientRect();
return {
x: clientX - rect.left + scroll.scrollLeft,
y: clientY - rect.top + scroll.scrollTop,
};
}
function getMarqueeStartPoint(
event: ReactPointerEvent<HTMLDivElement>,
scroll: HTMLDivElement | null,
disabled: boolean,
) {
if (disabled || event.button !== 0 || event.shiftKey || !scroll) return null;
const target = event.target as HTMLElement;
if (target.closest("[data-clip]")) return null;
const point = getCanvasPoint(scroll, event.clientX, event.clientY);
if (point.x < GUTTER || point.y < RULER_H) return null;
return point;
}
function capturePointerIfAvailable(event: ReactPointerEvent<HTMLDivElement>) {
const currentTarget = event.currentTarget as HTMLElement;
if (typeof currentTarget.setPointerCapture === "function") {
currentTarget.setPointerCapture(event.pointerId);
}
}
function buildSelectionRect(
active: ActiveMarqueeGesture,
scroll: HTMLDivElement,
pps: number,
): { overlay: TimelineMarqueeOverlayRect; selection: TimelineMarqueeSelectionRect } {
const current = getCanvasPoint(scroll, active.lastClientX, active.lastClientY);
const left = Math.min(active.anchorX, current.x);
const right = Math.max(active.anchorX, current.x);
const top = Math.min(active.anchorY, current.y);
const bottom = Math.max(active.anchorY, current.y);
const overlayLeft = Math.max(GUTTER, left);
const overlayTop = Math.max(RULER_H, top);
const overlayRight = Math.max(overlayLeft, right);
const overlayBottom = Math.max(overlayTop, bottom);
return {
overlay: {
left: overlayLeft,
top: overlayTop,
width: overlayRight - overlayLeft,
height: overlayBottom - overlayTop,
},
selection: {
startTime: Math.max(0, (left - GUTTER) / Math.max(pps, 1)),
endTime: Math.max(0, (right - GUTTER) / Math.max(pps, 1)),
top,
bottom,
},
};
}
export function useTimelineMarqueeSelection({
scrollRef,
ppsRef,
trackOrderRef,
timelineLayersRef,
disabled = false,
setShowPopover,
setRangeSelectionRef,
}: UseTimelineMarqueeSelectionInput) {
const activeRef = useRef<ActiveMarqueeGesture | null>(null);
const pointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
const scrollRafRef = useRef(0);
const [marqueeRect, setMarqueeRect] = useState<TimelineMarqueeOverlayRect | null>(null);
const stopAutoScroll = useCallback(() => {
pointerRef.current = null;
if (scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = 0;
}
}, []);
const updateMarqueeRect = useCallback(() => {
const active = activeRef.current;
const scroll = scrollRef.current;
if (!active || !scroll) return null;
const rects = buildSelectionRect(active, scroll, ppsRef.current);
setMarqueeRect(rects.overlay);
return rects;
}, [ppsRef, scrollRef]);
const stepAutoScroll = useCallback(() => {
scrollRafRef.current = 0;
const pointer = pointerRef.current;
const scroll = scrollRef.current;
if (!pointer || !scroll || !activeRef.current) return;
const delta = resolveTimelineAutoScroll(
scroll.getBoundingClientRect(),
pointer.clientX,
pointer.clientY,
);
if (delta.x === 0 && delta.y === 0) return;
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return;
scroll.scrollLeft = nextScrollLeft;
scroll.scrollTop = nextScrollTop;
updateMarqueeRect();
scrollRafRef.current = requestAnimationFrame(stepAutoScroll);
}, [scrollRef, updateMarqueeRect]);
const syncAutoScroll = useCallback(
(clientX: number, clientY: number) => {
pointerRef.current = { clientX, clientY };
const scroll = scrollRef.current;
if (!scroll) return;
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
if (delta.x === 0 && delta.y === 0) {
if (scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = 0;
}
return;
}
if (!scrollRafRef.current) {
scrollRafRef.current = requestAnimationFrame(stepAutoScroll);
}
},
[scrollRef, stepAutoScroll],
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const point = getMarqueeStartPoint(event, scrollRef.current, disabled);
if (!point) return false;
capturePointerIfAvailable(event);
activeRef.current = {
pointerId: event.pointerId,
anchorClientX: event.clientX,
anchorClientY: event.clientY,
anchorX: point.x,
anchorY: point.y,
lastClientX: event.clientX,
lastClientY: event.clientY,
started: false,
};
setShowPopover(false);
setRangeSelectionRef.current?.(null);
return true;
},
[disabled, scrollRef, setRangeSelectionRef, setShowPopover],
);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const active = activeRef.current;
if (!active || active.pointerId !== event.pointerId) return false;
active.lastClientX = event.clientX;
active.lastClientY = event.clientY;
const distance = Math.hypot(
event.clientX - active.anchorClientX,
event.clientY - active.anchorClientY,
);
if (!active.started && distance < MARQUEE_THRESHOLD_PX) return true;
active.started = true;
updateMarqueeRect();
syncAutoScroll(event.clientX, event.clientY);
return true;
},
[syncAutoScroll, updateMarqueeRect],
);
const handlePointerUp = useCallback(
(event?: ReactPointerEvent<HTMLDivElement>) => {
const active = activeRef.current;
if (!active || (event && active.pointerId !== event.pointerId)) return false;
activeRef.current = null;
stopAutoScroll();
setMarqueeRect(null);
if (!active.started) {
usePlayerStore.getState().setSelection([]);
return true;
}
const scroll = scrollRef.current;
if (!scroll) return true;
const rects = buildSelectionRect(active, scroll, ppsRef.current);
const selectedIds = selectTimelineElementsInMarquee({
rect: rects.selection,
layers: timelineLayersRef.current,
layerOrder: trackOrderRef.current,
rulerHeight: RULER_H,
trackHeight: TRACK_H,
groupHeaderHeight: TIMELINE_LAYER_GROUP_HEADER_H,
});
usePlayerStore.getState().setSelection(selectedIds);
return true;
},
[ppsRef, scrollRef, stopAutoScroll, timelineLayersRef, trackOrderRef],
);
useEffect(() => stopAutoScroll, [stopAutoScroll]);
return {
marqueeRect,
handlePointerDown,
handlePointerMove,
handlePointerUp,
};
}