diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 9be263b663..e52d18a56a 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -382,6 +382,7 @@ export const Timeline = memo(function Timeline({ handlePointerDown, handlePointerMove, handlePointerUp, + handlePointerCancel, } = useTimelineRangeSelection({ scrollRef, ppsRef, @@ -394,10 +395,11 @@ export const Timeline = memo(function Timeline({ isDragging, setShowPopover, elementsRef: expandedElementsRef, - trackOrderRef, + clipIndex, rowGeometryRef, onSelectElement, contentOrigin, + sessionEpoch, }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag @@ -480,7 +482,8 @@ export const Timeline = memo(function Timeline({ }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} - onLostPointerCapture={handlePointerUp} + onPointerCancel={handlePointerCancel} + onLostPointerCapture={handlePointerCancel} > { describe("getTimelineClipRect", () => { const trackOrder = [0, 2, 5]; + const geometry = createTimelineRowGeometry( + trackOrder, + trackOrder.map(() => TRACK_H), + ); 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, GUTTER); + const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, geometry, 100, GUTTER); expect(rect).toEqual({ left: GUTTER + 200, top: getTimelineRowTop(1) + CLIP_Y, @@ -107,54 +115,48 @@ 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, GUTTER); + const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 50, GUTTER); expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y); 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, GUTTER); + const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, geometry, 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 expandedGeometry = createTimelineRowGeometry(trackOrder, rowHeights); const rect = getTimelineClipRect( { start: 0, duration: 1, track: 0 }, - trackOrder, + expandedGeometry, 50, GUTTER, - rowHeights, ); expect(rect).toMatchObject({ top: getTimelineRowTop(0, rowHeights) + CLIP_Y, - height: rowHeights[0] - CLIP_Y * 2, + height: TRACK_H - CLIP_Y * 2, }); expect( - getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights) - ?.top, + getTimelineClipRect({ start: 0, duration: 1, track: 2 }, expandedGeometry, 50, GUTTER)?.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, - GUTTER, - ); + const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, geometry, 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, GUTTER), + getTimelineClipRect({ start: 0, duration: 1, track: 9 }, geometry, 100, GUTTER), ).toBeNull(); expect( - getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER), + getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 0, GUTTER), ).toBeNull(); expect( - getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER), + getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, NaN, GUTTER), ).toBeNull(); }); }); @@ -162,6 +164,10 @@ describe("getTimelineClipRect", () => { describe("computeMarqueeSelection", () => { // Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100. const trackOrder = [0, 1]; + const rowGeometry = createTimelineRowGeometry( + trackOrder, + trackOrder.map(() => TRACK_H), + ); const pps = 100; const clips = [ { id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0 @@ -175,7 +181,7 @@ describe("computeMarqueeSelection", () => { const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 }; const { ids, primaryId } = computeMarqueeSelection({ clips, - trackOrder, + rowGeometry, pps, contentOrigin: ORIGIN, marquee, @@ -188,7 +194,7 @@ describe("computeMarqueeSelection", () => { const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 }; const { ids } = computeMarqueeSelection({ clips, - trackOrder, + rowGeometry, pps, contentOrigin: ORIGIN, marquee, @@ -200,7 +206,7 @@ describe("computeMarqueeSelection", () => { const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 }; const { ids } = computeMarqueeSelection({ clips, - trackOrder, + rowGeometry, pps, contentOrigin: ORIGIN, marquee, @@ -212,7 +218,7 @@ describe("computeMarqueeSelection", () => { const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 }; const { ids, primaryId } = computeMarqueeSelection({ clips, - trackOrder, + rowGeometry, pps, contentOrigin: GUTTER, marquee, @@ -226,7 +232,7 @@ describe("computeMarqueeSelection", () => { const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 }; const { ids, primaryId } = computeMarqueeSelection({ clips, - trackOrder, + rowGeometry, pps, contentOrigin: GUTTER, marquee, @@ -240,10 +246,11 @@ describe("computeMarqueeSelection", () => { 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, contentOrigin: ORIGIN, marquee: wide }).ids, + computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: wide }) + .ids, ).toEqual(new Set(["a", "b"])); expect( - computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow }) + computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: narrow }) .ids, ).toEqual(new Set(["a"])); }); @@ -252,7 +259,7 @@ describe("computeMarqueeSelection", () => { const marquee = { left: 0, top: 0, width: 10000, height: 10000 }; const { ids } = computeMarqueeSelection({ clips: [{ id: "x", start: 0, duration: 1, track: 7 }], - trackOrder, + rowGeometry, pps, contentOrigin: GUTTER, marquee, @@ -260,3 +267,44 @@ describe("computeMarqueeSelection", () => { expect(ids).toEqual(new Set()); }); }); + +describe("getMarqueeClipCandidates", () => { + it("queries only the intersecting rows and time span", () => { + const rowGeometry = createTimelineRowGeometry([0, 1, 2], [TRACK_H, TRACK_H, TRACK_H]); + const near: TimelineElement = { id: "near", tag: "div", start: 1, duration: 1, track: 1 }; + const wrongTime: TimelineElement = { + id: "wrong-time", + tag: "div", + start: 20, + duration: 1, + track: 1, + }; + const wrongRow: TimelineElement = { + id: "wrong-row", + tag: "div", + start: 1, + duration: 1, + track: 2, + }; + const clipIndex = createTimelineClipIndex([ + [0, []], + [1, [near, wrongTime]], + [2, [wrongRow]], + ]); + + expect( + getMarqueeClipCandidates({ + clipIndex, + rowGeometry, + marquee: { + left: ORIGIN + 100, + top: getTimelineRowTop(1), + width: 100, + height: TRACK_H - 1, + }, + pps: 100, + contentOrigin: ORIGIN, + }), + ).toEqual([near]); + }); +}); diff --git a/packages/studio/src/player/components/timelineMarquee.ts b/packages/studio/src/player/components/timelineMarquee.ts index 23bffaf317..1b792a4211 100644 --- a/packages/studio/src/player/components/timelineMarquee.ts +++ b/packages/studio/src/player/components/timelineMarquee.ts @@ -1,5 +1,7 @@ -import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout"; +import { RULER_H, CLIP_Y, TRACK_H, type TimelineRowGeometry } from "./timelineLayout"; import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry"; +import { queryTimelineClipIndex, type TimelineClipIndex } from "../lib/timelineClipIndex"; +import type { TimelineElement } from "../store/playerStore"; /** Pointer must travel at least this far (either axis) before a pointerdown on * the empty timeline body becomes a marquee drag instead of a plain click. */ @@ -62,23 +64,22 @@ export function getMarqueeRect( /** * A clip's rendered rect in canvas/content coordinates (the same space the * 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). + * index within the canonical row geometry (cumulative row top + CLIP_Y). * Returns null when the clip's track is not currently displayed. */ export function getTimelineClipRect( clip: Pick, - trackOrder: number[], + rowGeometry: TimelineRowGeometry, pps: number, contentOrigin: number, - rowHeights: readonly number[] = [], ): Rect | null { - const row = trackOrder.indexOf(clip.track); + const row = rowGeometry.getRowIndex(clip.track); if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null; return { left: contentOrigin + clip.start * pps, - top: getTimelineRowTop(row, rowHeights) + CLIP_Y, + top: rowGeometry.getRowTop(row) + CLIP_Y, width: Math.max(clip.duration * pps, MIN_CLIP_W), - height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2, + height: TRACK_H - CLIP_Y * 2, }; } @@ -90,29 +91,56 @@ export interface MarqueeSelectionResult { primaryId: string | null; } +/** Narrow a marquee hit test to the intersecting logical rows and time span. */ +export function getMarqueeClipCandidates(input: { + clipIndex: TimelineClipIndex; + rowGeometry: TimelineRowGeometry; + marquee: Rect; + pps: number; + contentOrigin: number; +}): readonly TimelineElement[] { + if (!(input.pps > 0) || input.marquee.width <= 0 || input.marquee.height <= 0) return []; + const lastRow = input.rowGeometry.rowKeys.length - 1; + const first = Math.max(0, Math.floor(input.rowGeometry.getRowFromY(input.marquee.top))); + const last = Math.min( + lastRow, + Math.floor(input.rowGeometry.getRowFromY(input.marquee.top + input.marquee.height)), + ); + if (first > last) return []; + const paddingSeconds = MIN_CLIP_W / input.pps; + const start = Math.max( + 0, + (input.marquee.left - input.contentOrigin) / input.pps - paddingSeconds, + ); + const end = + (input.marquee.left + input.marquee.width - input.contentOrigin) / input.pps + paddingSeconds; + if (end <= start) return []; + + const candidates: TimelineElement[] = []; + for (let row = first; row <= last; row += 1) { + const rowKey = input.rowGeometry.rowKeys[row]; + if (rowKey === undefined) continue; + candidates.push(...queryTimelineClipIndex(input.clipIndex, rowKey, { start, end })); + } + return candidates; +} + /** * Live marquee selection: every clip whose rendered rect intersects the marquee. * `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId. */ export function computeMarqueeSelection(input: { clips: MarqueeClipInput[]; - trackOrder: number[]; + rowGeometry: TimelineRowGeometry; pps: number; contentOrigin: number; marquee: Rect; baseSelection?: Iterable; - rowHeights?: readonly number[]; }): MarqueeSelectionResult { const ids = new Set(input.baseSelection ?? []); let primaryId: string | null = null; for (const clip of input.clips) { - const rect = getTimelineClipRect( - clip, - input.trackOrder, - input.pps, - input.contentOrigin, - input.rowHeights, - ); + const rect = getTimelineClipRect(clip, input.rowGeometry, input.pps, input.contentOrigin); if (rect && rectsOverlap(rect, input.marquee)) { ids.add(clip.id); primaryId = clip.id; diff --git a/packages/studio/src/player/components/useTimelineRangeSelection.test.tsx b/packages/studio/src/player/components/useTimelineRangeSelection.test.tsx new file mode 100644 index 0000000000..5ac3145c1a --- /dev/null +++ b/packages/studio/src/player/components/useTimelineRangeSelection.test.tsx @@ -0,0 +1,212 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { createTimelineRowGeometry, getTimelineRowTop } from "./timelineLayout"; +import { createTimelineClipIndex } from "../lib/timelineClipIndex"; +import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const elements: TimelineElement[] = [ + { id: "first", tag: "div", start: 1, duration: 1, track: 0 }, + { id: "offscreen", tag: "div", start: 2, duration: 1, track: 50 }, + { id: "base", tag: "div", start: 8, duration: 1, track: 99 }, +]; +const tracks = Array.from({ length: 100 }, (_, index) => index); +const geometry = createTimelineRowGeometry( + tracks, + tracks.map(() => 48), +); +const clipIndex = createTimelineClipIndex( + tracks.map((track) => [track, elements.filter((element) => element.track === track)]), +); + +function pointer( + currentTarget: HTMLElement, + pointerId: number, + clientX: number, + clientY: number, + init: Partial = {}, +): React.PointerEvent { + return { + button: 0, + clientX, + clientY, + ctrlKey: false, + metaKey: false, + shiftKey: false, + pointerId, + currentTarget, + target: currentTarget, + ...init, + } as React.PointerEvent; +} + +function renderHarness(sessionEpoch = 1) { + usePlayerStore.setState({ timelineSessionEpoch: sessionEpoch }); + const host = document.createElement("div"); + const scroll = document.createElement("div"); + scroll.setPointerCapture = vi.fn(); + scroll.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 800, bottom: 240, width: 800, height: 240 }) as DOMRect; + Object.defineProperties(scroll, { + scrollLeft: { configurable: true, writable: true, value: 0 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + scrollWidth: { configurable: true, value: 10_000 }, + scrollHeight: { configurable: true, value: geometry.canvasHeight }, + clientWidth: { configurable: true, value: 800 }, + clientHeight: { configurable: true, value: 240 }, + }); + host.append(scroll); + document.body.append(host); + const root = createRoot(host); + let api: ReturnType | null = null; + const ppsRef = { current: 100 }; + const dragScrollRaf = { current: 0 }; + const isDragging = { current: false }; + const elementsRef = { current: elements }; + const rowGeometryRef = { current: geometry }; + const seekFromX = vi.fn(); + + function Probe({ epoch }: { epoch: number }) { + api = useTimelineRangeSelection({ + scrollRef: { current: scroll }, + ppsRef, + effectiveDuration: 60, + pps: 100, + seekFromX, + autoScrollDuringDrag: vi.fn(), + dragScrollRaf, + isDragging, + setShowPopover: vi.fn(), + elementsRef, + clipIndex, + rowGeometryRef, + contentOrigin: 0, + sessionEpoch: epoch, + }); + return null; + } + + act(() => root.render()); + return { + scroll, + root, + get api() { + if (!api) throw new Error("selection harness did not render"); + return api; + }, + rerender(epoch: number) { + usePlayerStore.setState({ timelineSessionEpoch: epoch }); + act(() => root.render()); + }, + seekFromX, + }; +} + +afterEach(() => { + usePlayerStore.getState().reset(); + document.body.innerHTML = ""; +}); + +describe("useTimelineRangeSelection", () => { + it("marquee-selects model clips across unmounted virtual rows", () => { + const view = renderHarness(); + const y0 = getTimelineRowTop(0) + 4; + const y50 = getTimelineRowTop(50) + 40; + + act(() => { + view.api.handlePointerDown(pointer(view.scroll, 7, 0, y0)); + view.api.handlePointerMove(pointer(view.scroll, 7, 400, y50)); + }); + + expect(document.querySelectorAll("[data-clip]")).toHaveLength(0); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["first", "offscreen"])); + act(() => view.root.unmount()); + }); + + it("ignores another pointer and restores the pre-drag selection on cancellation", () => { + usePlayerStore.getState().setSelectedElementId("base"); + const view = renderHarness(); + const y0 = getTimelineRowTop(0) + 4; + const y50 = getTimelineRowTop(50) + 40; + + act(() => { + view.api.handlePointerDown(pointer(view.scroll, 7, 0, y0)); + view.api.handlePointerMove(pointer(view.scroll, 7, 400, y50)); + view.api.handlePointerUp(pointer(view.scroll, 8, 400, y50)); + }); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["first", "offscreen"])); + + act(() => view.api.handlePointerCancel(pointer(view.scroll, 7, 400, y50))); + expect(usePlayerStore.getState().selectedElementId).toBe("base"); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["base"])); + act(() => view.root.unmount()); + }); + + it("keeps the original pointer owner when a second pointer presses", () => { + const view = renderHarness(); + const y0 = getTimelineRowTop(0) + 4; + const y50 = getTimelineRowTop(50) + 40; + + act(() => { + view.api.handlePointerDown(pointer(view.scroll, 7, 0, y0)); + view.api.handlePointerDown(pointer(view.scroll, 8, 500, y0)); + view.api.handlePointerMove(pointer(view.scroll, 7, 400, y50)); + view.api.handlePointerUp(pointer(view.scroll, 7, 400, y50)); + }); + + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["first", "offscreen"])); + act(() => view.root.unmount()); + }); + + it("commits a ruler click at its pointerdown position without requiring pointer movement", () => { + const view = renderHarness(); + + act(() => { + view.api.handlePointerDown(pointer(view.scroll, 7, 375, 5)); + view.api.handlePointerUp(pointer(view.scroll, 7, 375, 5)); + }); + + expect(view.seekFromX).toHaveBeenNthCalledWith(1, 375); + expect(view.seekFromX).toHaveBeenNthCalledWith(2, 375); + act(() => view.root.unmount()); + }); + + it("does not clear a finalized range when capture is lost after pointerup", () => { + const view = renderHarness(); + + act(() => { + view.api.handlePointerDown(pointer(view.scroll, 7, 100, 80, { shiftKey: true })); + view.api.handlePointerMove(pointer(view.scroll, 7, 300, 80, { shiftKey: true })); + view.api.handlePointerUp(pointer(view.scroll, 7, 300, 80, { shiftKey: true })); + }); + const finalized = view.api.rangeSelection; + expect(finalized).toMatchObject({ start: 1, end: 3 }); + + act(() => view.api.handlePointerCancel(pointer(view.scroll, 7, 300, 80))); + expect(view.api.rangeSelection).toEqual(finalized); + act(() => view.root.unmount()); + }); + + it("cancels a live marquee when the project session changes", () => { + usePlayerStore.getState().setSelectedElementId("base"); + const view = renderHarness(1); + const y0 = getTimelineRowTop(0) + 4; + const y50 = getTimelineRowTop(50) + 40; + + act(() => { + view.api.handlePointerDown(pointer(view.scroll, 7, 0, y0)); + view.api.handlePointerMove(pointer(view.scroll, 7, 400, y50)); + }); + usePlayerStore.getState().setSelectedElementId("base"); + view.rerender(2); + + expect(usePlayerStore.getState().selectedElementId).toBe("base"); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["base"])); + act(() => view.root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineRangeSelection.ts b/packages/studio/src/player/components/useTimelineRangeSelection.ts index 3016495f70..61a72b2e5a 100644 --- a/packages/studio/src/player/components/useTimelineRangeSelection.ts +++ b/packages/studio/src/player/components/useTimelineRangeSelection.ts @@ -9,6 +9,7 @@ import type { TimelineElement } from "../store/playerStore"; import { liveTime, usePlayerStore } from "../store/playerStore"; import { computeMarqueeSelection, + getMarqueeClipCandidates, getMarqueeRect, isMarqueeDrag, isTimelineRulerPress, @@ -16,6 +17,7 @@ import { } from "./timelineMarquee"; import type { Rect } from "../../utils/marqueeGeometry"; import type { TimelineRowGeometry } from "./timelineLayout"; +import type { TimelineClipIndex } from "../lib/timelineClipIndex"; interface UseTimelineRangeSelectionInput { scrollRef: React.RefObject; @@ -29,10 +31,11 @@ interface UseTimelineRangeSelectionInput { isDragging: React.RefObject; setShowPopover: (v: boolean) => void; elementsRef: React.RefObject; - trackOrderRef: React.RefObject; + clipIndex: TimelineClipIndex; rowGeometryRef: React.RefObject; onSelectElement?: (element: TimelineElement | null) => void; contentOrigin: number; + sessionEpoch: number; } interface MarqueeDragState { @@ -72,16 +75,21 @@ function commitMarqueeSelection( rect: Rect, additive: boolean, marquee: MarqueeDragState, - elements: TimelineElement[], - trackOrder: number[], - rowHeights: readonly number[], + clipIndex: TimelineClipIndex, + rowGeometry: TimelineRowGeometry, pps: number, contentOrigin: number, ): void { + const candidates = getMarqueeClipCandidates({ + clipIndex, + rowGeometry, + marquee: rect, + pps, + contentOrigin, + }); const { ids, primaryId } = computeMarqueeSelection({ - clips: toMarqueeClips(elements), - trackOrder, - rowHeights, + clips: toMarqueeClips([...candidates]), + rowGeometry, pps, contentOrigin, marquee: rect, @@ -106,10 +114,11 @@ export function useTimelineRangeSelection({ isDragging, setShowPopover, elementsRef, - trackOrderRef, + clipIndex, rowGeometryRef, onSelectElement, contentOrigin, + sessionEpoch, }: UseTimelineRangeSelectionInput) { const isRangeSelecting = useRef(false); const rangeAnchorTime = useRef(0); @@ -125,6 +134,17 @@ export function useTimelineRangeSelection({ const seekRafRef = useRef(0); const pendingClientXRef = useRef(0); + const activePointerIdRef = useRef(null); + const gestureEpochRef = useRef(null); + const sessionEpochRef = useRef(sessionEpoch); + sessionEpochRef.current = sessionEpoch; + + const isGestureSessionCurrent = useCallback( + () => + gestureEpochRef.current === sessionEpochRef.current && + gestureEpochRef.current === usePlayerStore.getState().timelineSessionEpoch, + [], + ); // Marquee (rubber-band) multi-select on the empty timeline body. const marqueeRef = useRef(null); @@ -158,7 +178,7 @@ export function useTimelineRangeSelection({ const applyMarqueeAtClient = useCallback( (clientX: number, clientY: number, shiftKey: boolean) => { const marquee = marqueeRef.current; - if (!marquee) return; + if (!marquee || !isGestureSessionCurrent()) return; const point = toContentPoint(clientX, clientY); if (!point) return; if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) { @@ -174,14 +194,13 @@ export function useTimelineRangeSelection({ rect, additive, marquee, - elementsRef.current ?? [], - trackOrderRef.current ?? [], - rowGeometryRef.current.rowHeights, + clipIndex, + rowGeometryRef.current, ppsRef.current, contentOrigin, ); }, - [toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin], + [toContentPoint, isGestureSessionCurrent, clipIndex, rowGeometryRef, ppsRef, contentOrigin], ); const stopMarqueeAutoScroll = useCallback(() => { @@ -202,14 +221,16 @@ export function useTimelineRangeSelection({ const marquee = marqueeRef.current; const pointer = marqueePointerRef.current; const scroll = scrollRef.current; - if (!marquee || !pointer || !scroll) return; + if (!marquee || !pointer || !scroll || !isGestureSessionCurrent()) { + return; + } if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return; // Re-run at the SAME client point: toContentPoint folds in the new scroll, so // the marquee's moving corner tracks the revealed content. applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey); marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll); - }, [scrollRef, applyMarqueeAtClient]); + }, [scrollRef, applyMarqueeAtClient, isGestureSessionCurrent]); const syncMarqueeAutoScroll = useCallback( (clientX: number, clientY: number, shiftKey: boolean) => { @@ -234,6 +255,8 @@ export function useTimelineRangeSelection({ const beginRangeSelection = useCallback( (e: React.PointerEvent) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + activePointerIdRef.current = e.pointerId; + gestureEpochRef.current = sessionEpochRef.current; isRangeSelecting.current = true; setShowPopover(false); const rect = scrollRef.current?.getBoundingClientRect(); @@ -249,7 +272,8 @@ export function useTimelineRangeSelection({ const handlePointerDown = useCallback( (e: React.PointerEvent) => { - if (e.button !== 0) return; + if (e.button !== 0 || activePointerIdRef.current !== null) return; + if (sessionEpochRef.current !== usePlayerStore.getState().timelineSessionEpoch) return; if (e.shiftKey) { beginRangeSelection(e); return; @@ -257,6 +281,8 @@ export function useTimelineRangeSelection({ shiftClickClipRef.current = null; if ((e.target as HTMLElement).closest("[data-clip]")) return; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + activePointerIdRef.current = e.pointerId; + gestureEpochRef.current = sessionEpochRef.current; setRangeSelection(null); setShowPopover(false); const point = toContentPoint(e.clientX, e.clientY); @@ -268,6 +294,7 @@ export function useTimelineRangeSelection({ if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) { isDragging.current = true; setIsScrubbing(true); + pendingClientXRef.current = e.clientX; seekFromX(e.clientX); return; } @@ -316,6 +343,8 @@ export function useTimelineRangeSelection({ const handlePointerMove = useCallback( (e: React.PointerEvent) => { + if (!isGestureSessionCurrent()) return; + if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) return; if (isRangeSelecting.current) { const rect = scrollRef.current?.getBoundingClientRect(); if (rect) { @@ -346,6 +375,7 @@ export function useTimelineRangeSelection({ applyMarqueeAtClient, syncMarqueeAutoScroll, updateScrubDrag, + isGestureSessionCurrent, contentOrigin, ], ); @@ -392,26 +422,86 @@ export function useTimelineRangeSelection({ [stopMarqueeAutoScroll, elementsRef, onSelectElement], ); - const handlePointerUp = useCallback(() => { - if (isRangeSelecting.current) { - finishRangeSelection(); - return; - } - const marquee = marqueeRef.current; - if (marquee) { - finishMarquee(marquee); - return; - } - if (!isDragging.current) return; - if (seekRafRef.current) { - cancelAnimationFrame(seekRafRef.current); - seekRafRef.current = 0; - } - seekFromX(pendingClientXRef.current); - isDragging.current = false; - setIsScrubbing(false); - cancelAnimationFrame(dragScrollRaf.current); - }, [isDragging, dragScrollRaf, seekFromX, finishRangeSelection, finishMarquee]); + const handlePointerUp = useCallback( + (e?: React.PointerEvent) => { + if (activePointerIdRef.current === null) return; + if (e && activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) { + return; + } + if (!isGestureSessionCurrent()) return; + activePointerIdRef.current = null; + gestureEpochRef.current = null; + if (isRangeSelecting.current) { + finishRangeSelection(); + return; + } + const marquee = marqueeRef.current; + if (marquee) { + finishMarquee(marquee); + return; + } + if (!isDragging.current) return; + if (seekRafRef.current) { + cancelAnimationFrame(seekRafRef.current); + seekRafRef.current = 0; + } + seekFromX(pendingClientXRef.current); + isDragging.current = false; + setIsScrubbing(false); + cancelAnimationFrame(dragScrollRaf.current); + }, + [ + isDragging, + dragScrollRaf, + seekFromX, + finishRangeSelection, + finishMarquee, + isGestureSessionCurrent, + ], + ); + + const cancelActiveGesture = useCallback( + (updateUi: boolean, restoreSelection: boolean) => { + activePointerIdRef.current = null; + gestureEpochRef.current = null; + isRangeSelecting.current = false; + isDragging.current = false; + stopMarqueeAutoScroll(); + if (seekRafRef.current) { + cancelAnimationFrame(seekRafRef.current); + seekRafRef.current = 0; + } + cancelAnimationFrame(dragScrollRaf.current); + dragScrollRaf.current = 0; + + const marquee = marqueeRef.current; + marqueeRef.current = null; + if (restoreSelection && marquee?.active) { + const store = usePlayerStore.getState(); + store.setSelectedElementId(marquee.basePrimary); + store.setSelectedElementIds(marquee.baseIds); + } + if (updateUi) { + setMarqueeRect(null); + setRangeSelection(null); + setIsScrubbing(false); + } + }, + [dragScrollRaf, isDragging, stopMarqueeAutoScroll], + ); + + const handlePointerCancel = useCallback( + (e?: React.PointerEvent) => { + if ( + activePointerIdRef.current === null || + (e && activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) + ) { + return; + } + cancelActiveGesture(true, isGestureSessionCurrent()); + }, + [cancelActiveGesture, isGestureSessionCurrent], + ); // Escape: cancel an in-flight marquee (restores the pre-drag selection); // otherwise clear any lingering multi-selection. @@ -421,15 +511,11 @@ export function useTimelineRangeSelection({ const store = usePlayerStore.getState(); const marquee = marqueeRef.current; if (marquee) { - marqueeRef.current = null; - stopMarqueeAutoScroll(); - setMarqueeRect(null); - if (marquee.active) { - // Primary FIRST (see commitMarqueeSelection): it collapses the set, so - // restore the pre-drag primary before repopulating the base ids. - store.setSelectedElementId(marquee.basePrimary); - store.setSelectedElementIds(marquee.baseIds); - } + cancelActiveGesture(true, true); + return; + } + if (isRangeSelecting.current || isDragging.current) { + cancelActiveGesture(true, true); return; } // Escape with no marquee clears the whole selection — primary AND set. @@ -440,7 +526,21 @@ export function useTimelineRangeSelection({ }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [stopMarqueeAutoScroll]); + }, [cancelActiveGesture, isDragging]); + + const previousSessionEpochRef = useRef(sessionEpoch); + useEffect(() => { + if (previousSessionEpochRef.current === sessionEpoch) return; + previousSessionEpochRef.current = sessionEpoch; + cancelActiveGesture(true, false); + }, [cancelActiveGesture, sessionEpoch]); + + useEffect( + () => () => { + cancelActiveGesture(false, isGestureSessionCurrent()); + }, + [cancelActiveGesture, isGestureSessionCurrent], + ); return { rangeSelection, @@ -451,5 +551,6 @@ export function useTimelineRangeSelection({ handlePointerDown, handlePointerMove, handlePointerUp, + handlePointerCancel, }; }