mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
perf(studio): virtualize timeline marquee selection (#2707)
This commit is contained in:
@@ -398,6 +398,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
handlePointerDown,
|
handlePointerDown,
|
||||||
handlePointerMove,
|
handlePointerMove,
|
||||||
handlePointerUp,
|
handlePointerUp,
|
||||||
|
handlePointerCancel,
|
||||||
} = useTimelineRangeSelection({
|
} = useTimelineRangeSelection({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
ppsRef,
|
ppsRef,
|
||||||
@@ -410,10 +411,11 @@ export const Timeline = memo(function Timeline({
|
|||||||
isDragging,
|
isDragging,
|
||||||
setShowPopover,
|
setShowPopover,
|
||||||
elementsRef: expandedElementsRef,
|
elementsRef: expandedElementsRef,
|
||||||
trackOrderRef,
|
clipIndex,
|
||||||
rowGeometryRef,
|
rowGeometryRef,
|
||||||
onSelectElement,
|
onSelectElement,
|
||||||
contentOrigin,
|
contentOrigin,
|
||||||
|
sessionEpoch,
|
||||||
});
|
});
|
||||||
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
|
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
|
||||||
|
|
||||||
@@ -484,7 +486,8 @@ export const Timeline = memo(function Timeline({
|
|||||||
}}
|
}}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onLostPointerCapture={handlePointerUp}
|
onPointerCancel={handlePointerCancel}
|
||||||
|
onLostPointerCapture={handlePointerCancel}
|
||||||
>
|
>
|
||||||
<TimelineCanvas
|
<TimelineCanvas
|
||||||
major={major}
|
major={major}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
isTimelineRulerPress,
|
isTimelineRulerPress,
|
||||||
getMarqueeRect,
|
getMarqueeRect,
|
||||||
getTimelineClipRect,
|
getTimelineClipRect,
|
||||||
|
getMarqueeClipCandidates,
|
||||||
computeMarqueeSelection,
|
computeMarqueeSelection,
|
||||||
} from "./timelineMarquee";
|
} from "./timelineMarquee";
|
||||||
|
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||||
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import {
|
import {
|
||||||
GUTTER,
|
GUTTER,
|
||||||
LANE_H,
|
LANE_H,
|
||||||
@@ -14,6 +17,7 @@ import {
|
|||||||
RULER_H,
|
RULER_H,
|
||||||
CLIP_Y,
|
CLIP_Y,
|
||||||
TRACKS_LEFT_PAD,
|
TRACKS_LEFT_PAD,
|
||||||
|
createTimelineRowGeometry,
|
||||||
getTimelineRowTop,
|
getTimelineRowTop,
|
||||||
} from "./timelineLayout";
|
} from "./timelineLayout";
|
||||||
|
|
||||||
@@ -95,9 +99,13 @@ describe("getMarqueeRect", () => {
|
|||||||
|
|
||||||
describe("getTimelineClipRect", () => {
|
describe("getTimelineClipRect", () => {
|
||||||
const trackOrder = [0, 2, 5];
|
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", () => {
|
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({
|
expect(rect).toEqual({
|
||||||
left: GUTTER + 200,
|
left: GUTTER + 200,
|
||||||
top: getTimelineRowTop(1) + CLIP_Y,
|
top: getTimelineRowTop(1) + CLIP_Y,
|
||||||
@@ -107,54 +115,48 @@ describe("getTimelineClipRect", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("places the first visible track below the ruler + top breathing pad", () => {
|
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?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
|
||||||
expect(rect?.left).toBe(GUTTER);
|
expect(rect?.left).toBe(GUTTER);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the row index in trackOrder, not the raw track number", () => {
|
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);
|
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses cumulative tops and the resolved height for an expanded row", () => {
|
it("uses cumulative tops and the resolved height for an expanded row", () => {
|
||||||
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
||||||
|
const expandedGeometry = createTimelineRowGeometry(trackOrder, rowHeights);
|
||||||
const rect = getTimelineClipRect(
|
const rect = getTimelineClipRect(
|
||||||
{ start: 0, duration: 1, track: 0 },
|
{ start: 0, duration: 1, track: 0 },
|
||||||
trackOrder,
|
expandedGeometry,
|
||||||
50,
|
50,
|
||||||
GUTTER,
|
GUTTER,
|
||||||
rowHeights,
|
|
||||||
);
|
);
|
||||||
expect(rect).toMatchObject({
|
expect(rect).toMatchObject({
|
||||||
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
|
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
|
||||||
height: rowHeights[0] - CLIP_Y * 2,
|
height: TRACK_H - CLIP_Y * 2,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights)
|
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, expandedGeometry, 50, GUTTER)?.top,
|
||||||
?.top,
|
|
||||||
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
|
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enforces the 4px minimum rendered width", () => {
|
it("enforces the 4px minimum rendered width", () => {
|
||||||
const rect = getTimelineClipRect(
|
const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, geometry, 10, GUTTER);
|
||||||
{ start: 0, duration: 0.01, track: 0 },
|
|
||||||
trackOrder,
|
|
||||||
10,
|
|
||||||
GUTTER,
|
|
||||||
);
|
|
||||||
expect(rect?.width).toBe(4);
|
expect(rect?.width).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for a track that is not displayed or an invalid pps", () => {
|
it("returns null for a track that is not displayed or an invalid pps", () => {
|
||||||
expect(
|
expect(
|
||||||
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER),
|
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, geometry, 100, GUTTER),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER),
|
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 0, GUTTER),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER),
|
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, NaN, GUTTER),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -162,6 +164,10 @@ describe("getTimelineClipRect", () => {
|
|||||||
describe("computeMarqueeSelection", () => {
|
describe("computeMarqueeSelection", () => {
|
||||||
// Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
|
// Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
|
||||||
const trackOrder = [0, 1];
|
const trackOrder = [0, 1];
|
||||||
|
const rowGeometry = createTimelineRowGeometry(
|
||||||
|
trackOrder,
|
||||||
|
trackOrder.map(() => TRACK_H),
|
||||||
|
);
|
||||||
const pps = 100;
|
const pps = 100;
|
||||||
const clips = [
|
const clips = [
|
||||||
{ id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0
|
{ 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 marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
|
||||||
const { ids, primaryId } = computeMarqueeSelection({
|
const { ids, primaryId } = computeMarqueeSelection({
|
||||||
clips,
|
clips,
|
||||||
trackOrder,
|
rowGeometry,
|
||||||
pps,
|
pps,
|
||||||
contentOrigin: ORIGIN,
|
contentOrigin: ORIGIN,
|
||||||
marquee,
|
marquee,
|
||||||
@@ -188,7 +194,7 @@ describe("computeMarqueeSelection", () => {
|
|||||||
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
||||||
const { ids } = computeMarqueeSelection({
|
const { ids } = computeMarqueeSelection({
|
||||||
clips,
|
clips,
|
||||||
trackOrder,
|
rowGeometry,
|
||||||
pps,
|
pps,
|
||||||
contentOrigin: ORIGIN,
|
contentOrigin: ORIGIN,
|
||||||
marquee,
|
marquee,
|
||||||
@@ -200,7 +206,7 @@ describe("computeMarqueeSelection", () => {
|
|||||||
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
||||||
const { ids } = computeMarqueeSelection({
|
const { ids } = computeMarqueeSelection({
|
||||||
clips,
|
clips,
|
||||||
trackOrder,
|
rowGeometry,
|
||||||
pps,
|
pps,
|
||||||
contentOrigin: ORIGIN,
|
contentOrigin: ORIGIN,
|
||||||
marquee,
|
marquee,
|
||||||
@@ -212,7 +218,7 @@ describe("computeMarqueeSelection", () => {
|
|||||||
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
|
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
|
||||||
const { ids, primaryId } = computeMarqueeSelection({
|
const { ids, primaryId } = computeMarqueeSelection({
|
||||||
clips,
|
clips,
|
||||||
trackOrder,
|
rowGeometry,
|
||||||
pps,
|
pps,
|
||||||
contentOrigin: GUTTER,
|
contentOrigin: GUTTER,
|
||||||
marquee,
|
marquee,
|
||||||
@@ -226,7 +232,7 @@ describe("computeMarqueeSelection", () => {
|
|||||||
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
|
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
|
||||||
const { ids, primaryId } = computeMarqueeSelection({
|
const { ids, primaryId } = computeMarqueeSelection({
|
||||||
clips,
|
clips,
|
||||||
trackOrder,
|
rowGeometry,
|
||||||
pps,
|
pps,
|
||||||
contentOrigin: GUTTER,
|
contentOrigin: GUTTER,
|
||||||
marquee,
|
marquee,
|
||||||
@@ -240,10 +246,11 @@ describe("computeMarqueeSelection", () => {
|
|||||||
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
|
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
|
||||||
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
|
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
|
||||||
expect(
|
expect(
|
||||||
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids,
|
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: wide })
|
||||||
|
.ids,
|
||||||
).toEqual(new Set(["a", "b"]));
|
).toEqual(new Set(["a", "b"]));
|
||||||
expect(
|
expect(
|
||||||
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow })
|
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: narrow })
|
||||||
.ids,
|
.ids,
|
||||||
).toEqual(new Set(["a"]));
|
).toEqual(new Set(["a"]));
|
||||||
});
|
});
|
||||||
@@ -252,7 +259,7 @@ describe("computeMarqueeSelection", () => {
|
|||||||
const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
|
const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
|
||||||
const { ids } = computeMarqueeSelection({
|
const { ids } = computeMarqueeSelection({
|
||||||
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
|
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
|
||||||
trackOrder,
|
rowGeometry,
|
||||||
pps,
|
pps,
|
||||||
contentOrigin: GUTTER,
|
contentOrigin: GUTTER,
|
||||||
marquee,
|
marquee,
|
||||||
@@ -260,3 +267,44 @@ describe("computeMarqueeSelection", () => {
|
|||||||
expect(ids).toEqual(new Set());
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { 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
|
/** 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. */
|
* 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
|
* 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
|
* 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.
|
* Returns null when the clip's track is not currently displayed.
|
||||||
*/
|
*/
|
||||||
export function getTimelineClipRect(
|
export function getTimelineClipRect(
|
||||||
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
||||||
trackOrder: number[],
|
rowGeometry: TimelineRowGeometry,
|
||||||
pps: number,
|
pps: number,
|
||||||
contentOrigin: number,
|
contentOrigin: number,
|
||||||
rowHeights: readonly number[] = [],
|
|
||||||
): Rect | null {
|
): Rect | null {
|
||||||
const row = trackOrder.indexOf(clip.track);
|
const row = rowGeometry.getRowIndex(clip.track);
|
||||||
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
||||||
return {
|
return {
|
||||||
left: contentOrigin + clip.start * pps,
|
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),
|
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;
|
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.
|
* Live marquee selection: every clip whose rendered rect intersects the marquee.
|
||||||
* `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId.
|
* `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId.
|
||||||
*/
|
*/
|
||||||
export function computeMarqueeSelection(input: {
|
export function computeMarqueeSelection(input: {
|
||||||
clips: MarqueeClipInput[];
|
clips: MarqueeClipInput[];
|
||||||
trackOrder: number[];
|
rowGeometry: TimelineRowGeometry;
|
||||||
pps: number;
|
pps: number;
|
||||||
contentOrigin: number;
|
contentOrigin: number;
|
||||||
marquee: Rect;
|
marquee: Rect;
|
||||||
baseSelection?: Iterable<string>;
|
baseSelection?: Iterable<string>;
|
||||||
rowHeights?: readonly number[];
|
|
||||||
}): MarqueeSelectionResult {
|
}): MarqueeSelectionResult {
|
||||||
const ids = new Set<string>(input.baseSelection ?? []);
|
const ids = new Set<string>(input.baseSelection ?? []);
|
||||||
let primaryId: string | null = null;
|
let primaryId: string | null = null;
|
||||||
for (const clip of input.clips) {
|
for (const clip of input.clips) {
|
||||||
const rect = getTimelineClipRect(
|
const rect = getTimelineClipRect(clip, input.rowGeometry, input.pps, input.contentOrigin);
|
||||||
clip,
|
|
||||||
input.trackOrder,
|
|
||||||
input.pps,
|
|
||||||
input.contentOrigin,
|
|
||||||
input.rowHeights,
|
|
||||||
);
|
|
||||||
if (rect && rectsOverlap(rect, input.marquee)) {
|
if (rect && rectsOverlap(rect, input.marquee)) {
|
||||||
ids.add(clip.id);
|
ids.add(clip.id);
|
||||||
primaryId = clip.id;
|
primaryId = clip.id;
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// @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";
|
||||||
|
import { configureTimelineTestViewport } from "./timelineTestViewport";
|
||||||
|
|
||||||
|
(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)]),
|
||||||
|
);
|
||||||
|
const FIRST_ROW_Y = getTimelineRowTop(0) + 4;
|
||||||
|
const OFFSCREEN_ROW_Y = getTimelineRowTop(50) + 40;
|
||||||
|
|
||||||
|
function pointer(
|
||||||
|
currentTarget: HTMLElement,
|
||||||
|
pointerId: number,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
init: Partial<React.PointerEvent> = {},
|
||||||
|
): 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();
|
||||||
|
configureTimelineTestViewport(scroll, geometry.canvasHeight);
|
||||||
|
host.append(scroll);
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
let api: ReturnType<typeof useTimelineRangeSelection> | 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(<Probe epoch={sessionEpoch} />));
|
||||||
|
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(<Probe epoch={epoch} />));
|
||||||
|
},
|
||||||
|
seekFromX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragMarquee(
|
||||||
|
view: ReturnType<typeof renderHarness>,
|
||||||
|
options: { secondPointer?: boolean; release?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
act(() => {
|
||||||
|
view.api.handlePointerDown(pointer(view.scroll, 7, 0, FIRST_ROW_Y));
|
||||||
|
if (options.secondPointer) {
|
||||||
|
view.api.handlePointerDown(pointer(view.scroll, 8, 500, FIRST_ROW_Y));
|
||||||
|
}
|
||||||
|
view.api.handlePointerMove(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y));
|
||||||
|
if (options.release) {
|
||||||
|
view.api.handlePointerUp(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSelectedIds(...ids: string[]): void {
|
||||||
|
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmountHarness(view: ReturnType<typeof renderHarness>): void {
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
usePlayerStore.getState().reset();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useTimelineRangeSelection", () => {
|
||||||
|
it("marquee-selects model clips across unmounted virtual rows", () => {
|
||||||
|
const view = renderHarness();
|
||||||
|
dragMarquee(view);
|
||||||
|
|
||||||
|
expect(document.querySelectorAll("[data-clip]")).toHaveLength(0);
|
||||||
|
expectSelectedIds("first", "offscreen");
|
||||||
|
unmountHarness(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores another pointer and restores the pre-drag selection on cancellation", () => {
|
||||||
|
usePlayerStore.getState().setSelectedElementId("base");
|
||||||
|
const view = renderHarness();
|
||||||
|
dragMarquee(view);
|
||||||
|
act(() => view.api.handlePointerUp(pointer(view.scroll, 8, 400, OFFSCREEN_ROW_Y)));
|
||||||
|
expectSelectedIds("first", "offscreen");
|
||||||
|
|
||||||
|
act(() => view.api.handlePointerCancel(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y)));
|
||||||
|
expect(usePlayerStore.getState().selectedElementId).toBe("base");
|
||||||
|
expectSelectedIds("base");
|
||||||
|
unmountHarness(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original pointer owner when a second pointer presses", () => {
|
||||||
|
const view = renderHarness();
|
||||||
|
dragMarquee(view, { secondPointer: true, release: true });
|
||||||
|
|
||||||
|
expectSelectedIds("first", "offscreen");
|
||||||
|
unmountHarness(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
unmountHarness(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
unmountHarness(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a live marquee when the project session changes", () => {
|
||||||
|
usePlayerStore.getState().setSelectedElementId("base");
|
||||||
|
const view = renderHarness(1);
|
||||||
|
dragMarquee(view);
|
||||||
|
usePlayerStore.getState().setSelectedElementId("base");
|
||||||
|
view.rerender(2);
|
||||||
|
|
||||||
|
expect(usePlayerStore.getState().selectedElementId).toBe("base");
|
||||||
|
expectSelectedIds("base");
|
||||||
|
unmountHarness(view);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
|||||||
import { getTimelineScrubTime } from "./timelineLayout";
|
import { getTimelineScrubTime } from "./timelineLayout";
|
||||||
import {
|
import {
|
||||||
computeMarqueeSelection,
|
computeMarqueeSelection,
|
||||||
|
getMarqueeClipCandidates,
|
||||||
getMarqueeRect,
|
getMarqueeRect,
|
||||||
isMarqueeDrag,
|
isMarqueeDrag,
|
||||||
isTimelineRulerPress,
|
isTimelineRulerPress,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "./timelineMarquee";
|
} from "./timelineMarquee";
|
||||||
import type { Rect } from "../../utils/marqueeGeometry";
|
import type { Rect } from "../../utils/marqueeGeometry";
|
||||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||||
|
import type { TimelineClipIndex } from "../lib/timelineClipIndex";
|
||||||
|
|
||||||
interface UseTimelineRangeSelectionInput {
|
interface UseTimelineRangeSelectionInput {
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
@@ -30,10 +32,11 @@ interface UseTimelineRangeSelectionInput {
|
|||||||
isDragging: React.RefObject<boolean>;
|
isDragging: React.RefObject<boolean>;
|
||||||
setShowPopover: (v: boolean) => void;
|
setShowPopover: (v: boolean) => void;
|
||||||
elementsRef: React.RefObject<TimelineElement[]>;
|
elementsRef: React.RefObject<TimelineElement[]>;
|
||||||
trackOrderRef: React.RefObject<number[]>;
|
clipIndex: TimelineClipIndex;
|
||||||
rowGeometryRef: React.RefObject<TimelineRowGeometry>;
|
rowGeometryRef: React.RefObject<TimelineRowGeometry>;
|
||||||
onSelectElement?: (element: TimelineElement | null) => void;
|
onSelectElement?: (element: TimelineElement | null) => void;
|
||||||
contentOrigin: number;
|
contentOrigin: number;
|
||||||
|
sessionEpoch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarqueeDragState {
|
interface MarqueeDragState {
|
||||||
@@ -73,16 +76,21 @@ function commitMarqueeSelection(
|
|||||||
rect: Rect,
|
rect: Rect,
|
||||||
additive: boolean,
|
additive: boolean,
|
||||||
marquee: MarqueeDragState,
|
marquee: MarqueeDragState,
|
||||||
elements: TimelineElement[],
|
clipIndex: TimelineClipIndex,
|
||||||
trackOrder: number[],
|
rowGeometry: TimelineRowGeometry,
|
||||||
rowHeights: readonly number[],
|
|
||||||
pps: number,
|
pps: number,
|
||||||
contentOrigin: number,
|
contentOrigin: number,
|
||||||
): void {
|
): void {
|
||||||
|
const candidates = getMarqueeClipCandidates({
|
||||||
|
clipIndex,
|
||||||
|
rowGeometry,
|
||||||
|
marquee: rect,
|
||||||
|
pps,
|
||||||
|
contentOrigin,
|
||||||
|
});
|
||||||
const { ids, primaryId } = computeMarqueeSelection({
|
const { ids, primaryId } = computeMarqueeSelection({
|
||||||
clips: toMarqueeClips(elements),
|
clips: toMarqueeClips([...candidates]),
|
||||||
trackOrder,
|
rowGeometry,
|
||||||
rowHeights,
|
|
||||||
pps,
|
pps,
|
||||||
contentOrigin,
|
contentOrigin,
|
||||||
marquee: rect,
|
marquee: rect,
|
||||||
@@ -95,6 +103,28 @@ function commitMarqueeSelection(
|
|||||||
store.setSelectedElementIds(ids);
|
store.setSelectedElementIds(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canStartPointerGesture(
|
||||||
|
event: React.PointerEvent,
|
||||||
|
activePointerId: number | null,
|
||||||
|
sessionEpoch: number,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
event.button === 0 &&
|
||||||
|
activePointerId === null &&
|
||||||
|
sessionEpoch === usePlayerStore.getState().timelineSessionEpoch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarqueePress(
|
||||||
|
point: { x: number; y: number } | null,
|
||||||
|
scrollRect: DOMRect | undefined,
|
||||||
|
clientY: number,
|
||||||
|
): point is { x: number; y: number } {
|
||||||
|
return (
|
||||||
|
point !== null && scrollRect !== undefined && !isTimelineRulerPress(clientY, scrollRect.top)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useTimelineRangeSelection({
|
export function useTimelineRangeSelection({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
ppsRef,
|
ppsRef,
|
||||||
@@ -107,10 +137,11 @@ export function useTimelineRangeSelection({
|
|||||||
isDragging,
|
isDragging,
|
||||||
setShowPopover,
|
setShowPopover,
|
||||||
elementsRef,
|
elementsRef,
|
||||||
trackOrderRef,
|
clipIndex,
|
||||||
rowGeometryRef,
|
rowGeometryRef,
|
||||||
onSelectElement,
|
onSelectElement,
|
||||||
contentOrigin,
|
contentOrigin,
|
||||||
|
sessionEpoch,
|
||||||
}: UseTimelineRangeSelectionInput) {
|
}: UseTimelineRangeSelectionInput) {
|
||||||
const isRangeSelecting = useRef(false);
|
const isRangeSelecting = useRef(false);
|
||||||
const rangeAnchorTime = useRef(0);
|
const rangeAnchorTime = useRef(0);
|
||||||
@@ -126,6 +157,17 @@ export function useTimelineRangeSelection({
|
|||||||
|
|
||||||
const seekRafRef = useRef(0);
|
const seekRafRef = useRef(0);
|
||||||
const pendingClientXRef = useRef(0);
|
const pendingClientXRef = useRef(0);
|
||||||
|
const activePointerIdRef = useRef<number | null>(null);
|
||||||
|
const gestureEpochRef = useRef<number | null>(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.
|
// Marquee (rubber-band) multi-select on the empty timeline body.
|
||||||
const marqueeRef = useRef<MarqueeDragState | null>(null);
|
const marqueeRef = useRef<MarqueeDragState | null>(null);
|
||||||
@@ -159,7 +201,7 @@ export function useTimelineRangeSelection({
|
|||||||
const applyMarqueeAtClient = useCallback(
|
const applyMarqueeAtClient = useCallback(
|
||||||
(clientX: number, clientY: number, shiftKey: boolean) => {
|
(clientX: number, clientY: number, shiftKey: boolean) => {
|
||||||
const marquee = marqueeRef.current;
|
const marquee = marqueeRef.current;
|
||||||
if (!marquee) return;
|
if (!marquee || !isGestureSessionCurrent()) return;
|
||||||
const point = toContentPoint(clientX, clientY);
|
const point = toContentPoint(clientX, clientY);
|
||||||
if (!point) return;
|
if (!point) return;
|
||||||
if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) {
|
if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) {
|
||||||
@@ -175,14 +217,13 @@ export function useTimelineRangeSelection({
|
|||||||
rect,
|
rect,
|
||||||
additive,
|
additive,
|
||||||
marquee,
|
marquee,
|
||||||
elementsRef.current ?? [],
|
clipIndex,
|
||||||
trackOrderRef.current ?? [],
|
rowGeometryRef.current,
|
||||||
rowGeometryRef.current.rowHeights,
|
|
||||||
ppsRef.current,
|
ppsRef.current,
|
||||||
contentOrigin,
|
contentOrigin,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin],
|
[toContentPoint, isGestureSessionCurrent, clipIndex, rowGeometryRef, ppsRef, contentOrigin],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopMarqueeAutoScroll = useCallback(() => {
|
const stopMarqueeAutoScroll = useCallback(() => {
|
||||||
@@ -203,14 +244,16 @@ export function useTimelineRangeSelection({
|
|||||||
const marquee = marqueeRef.current;
|
const marquee = marqueeRef.current;
|
||||||
const pointer = marqueePointerRef.current;
|
const pointer = marqueePointerRef.current;
|
||||||
const scroll = scrollRef.current;
|
const scroll = scrollRef.current;
|
||||||
if (!marquee || !pointer || !scroll) return;
|
if (!marquee || !pointer || !scroll || !isGestureSessionCurrent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
|
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
|
||||||
|
|
||||||
// Re-run at the SAME client point: toContentPoint folds in the new scroll, so
|
// Re-run at the SAME client point: toContentPoint folds in the new scroll, so
|
||||||
// the marquee's moving corner tracks the revealed content.
|
// the marquee's moving corner tracks the revealed content.
|
||||||
applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey);
|
applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey);
|
||||||
marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
|
marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
|
||||||
}, [scrollRef, applyMarqueeAtClient]);
|
}, [scrollRef, applyMarqueeAtClient, isGestureSessionCurrent]);
|
||||||
|
|
||||||
const syncMarqueeAutoScroll = useCallback(
|
const syncMarqueeAutoScroll = useCallback(
|
||||||
(clientX: number, clientY: number, shiftKey: boolean) => {
|
(clientX: number, clientY: number, shiftKey: boolean) => {
|
||||||
@@ -235,6 +278,8 @@ export function useTimelineRangeSelection({
|
|||||||
const beginRangeSelection = useCallback(
|
const beginRangeSelection = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
activePointerIdRef.current = e.pointerId;
|
||||||
|
gestureEpochRef.current = sessionEpochRef.current;
|
||||||
isRangeSelecting.current = true;
|
isRangeSelecting.current = true;
|
||||||
setShowPopover(false);
|
setShowPopover(false);
|
||||||
const rect = scrollRef.current?.getBoundingClientRect();
|
const rect = scrollRef.current?.getBoundingClientRect();
|
||||||
@@ -248,9 +293,31 @@ export function useTimelineRangeSelection({
|
|||||||
[scrollRef, pps, setShowPopover, contentOrigin],
|
[scrollRef, pps, setShowPopover, contentOrigin],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const beginScrub = useCallback(
|
||||||
|
(clientX: number) => {
|
||||||
|
isDragging.current = true;
|
||||||
|
setIsScrubbing(true);
|
||||||
|
pendingClientXRef.current = clientX;
|
||||||
|
seekFromX(clientX);
|
||||||
|
},
|
||||||
|
[isDragging, seekFromX],
|
||||||
|
);
|
||||||
|
|
||||||
|
const beginMarquee = useCallback((point: { x: number; y: number }, additive: boolean) => {
|
||||||
|
const base = snapshotSelection();
|
||||||
|
marqueeRef.current = {
|
||||||
|
originX: point.x,
|
||||||
|
originY: point.y,
|
||||||
|
baseIds: base.ids,
|
||||||
|
basePrimary: base.primary,
|
||||||
|
additive,
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (e.button !== 0) return;
|
if (!canStartPointerGesture(e, activePointerIdRef.current, sessionEpochRef.current)) return;
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
beginRangeSelection(e);
|
beginRangeSelection(e);
|
||||||
return;
|
return;
|
||||||
@@ -258,6 +325,8 @@ export function useTimelineRangeSelection({
|
|||||||
shiftClickClipRef.current = null;
|
shiftClickClipRef.current = null;
|
||||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
activePointerIdRef.current = e.pointerId;
|
||||||
|
gestureEpochRef.current = sessionEpochRef.current;
|
||||||
setRangeSelection(null);
|
setRangeSelection(null);
|
||||||
setShowPopover(false);
|
setShowPopover(false);
|
||||||
const point = toContentPoint(e.clientX, e.clientY);
|
const point = toContentPoint(e.clientX, e.clientY);
|
||||||
@@ -266,30 +335,15 @@ export function useTimelineRangeSelection({
|
|||||||
// y (which folds in scrollTop) breaks once the body is scrolled down and
|
// y (which folds in scrollTop) breaks once the body is scrolled down and
|
||||||
// the stuck ruler visually overlays scrolled-away track rows.
|
// the stuck ruler visually overlays scrolled-away track rows.
|
||||||
const scrollRect = scrollRef.current?.getBoundingClientRect();
|
const scrollRect = scrollRef.current?.getBoundingClientRect();
|
||||||
if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
|
if (!isMarqueePress(point, scrollRect, e.clientY)) {
|
||||||
isDragging.current = true;
|
beginScrub(e.clientX);
|
||||||
setIsScrubbing(true);
|
|
||||||
// Seed the pending coordinate so a press with no pointermove still
|
|
||||||
// replays THIS x on pointerup. `updateScrubDrag` is the only other
|
|
||||||
// writer, so without this a plain click settles on the ref's initial
|
|
||||||
// 0 and clamps the playhead back to t=0.
|
|
||||||
pendingClientXRef.current = e.clientX;
|
|
||||||
seekFromX(e.clientX);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Empty body press → pending marquee. A plain click (no drag past the
|
// Empty body press → pending marquee. A plain click (no drag past the
|
||||||
// threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
|
// threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
|
||||||
const base = snapshotSelection();
|
beginMarquee(point, e.metaKey || e.ctrlKey);
|
||||||
marqueeRef.current = {
|
|
||||||
originX: point.x,
|
|
||||||
originY: point.y,
|
|
||||||
baseIds: base.ids,
|
|
||||||
basePrimary: base.primary,
|
|
||||||
additive: e.metaKey || e.ctrlKey,
|
|
||||||
active: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
[beginRangeSelection, seekFromX, scrollRef, isDragging, setShowPopover, toContentPoint],
|
[beginRangeSelection, beginScrub, beginMarquee, scrollRef, setShowPopover, toContentPoint],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
|
// Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
|
||||||
@@ -325,18 +379,27 @@ export function useTimelineRangeSelection({
|
|||||||
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
|
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
const updateRangeSelection = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (isRangeSelecting.current) {
|
const scroll = scrollRef.current;
|
||||||
const rect = scrollRef.current?.getBoundingClientRect();
|
const rect = scroll?.getBoundingClientRect();
|
||||||
if (rect) {
|
if (!scroll || !rect) return;
|
||||||
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
|
const x = e.clientX - rect.left + scroll.scrollLeft - contentOrigin;
|
||||||
setRangeSelection((prev) =>
|
setRangeSelection((previous) =>
|
||||||
prev
|
previous
|
||||||
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
? { ...previous, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
[contentOrigin, pps, scrollRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (!isGestureSessionCurrent()) return;
|
||||||
|
if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) return;
|
||||||
|
if (isRangeSelecting.current) {
|
||||||
|
updateRangeSelection(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const marquee = marqueeRef.current;
|
const marquee = marqueeRef.current;
|
||||||
@@ -351,13 +414,12 @@ export function useTimelineRangeSelection({
|
|||||||
updateScrubDrag(e.clientX);
|
updateScrubDrag(e.clientX);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
pps,
|
|
||||||
scrollRef,
|
|
||||||
isDragging,
|
isDragging,
|
||||||
applyMarqueeAtClient,
|
applyMarqueeAtClient,
|
||||||
syncMarqueeAutoScroll,
|
syncMarqueeAutoScroll,
|
||||||
updateScrubDrag,
|
updateScrubDrag,
|
||||||
contentOrigin,
|
updateRangeSelection,
|
||||||
|
isGestureSessionCurrent,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -403,7 +465,30 @@ export function useTimelineRangeSelection({
|
|||||||
[stopMarqueeAutoScroll, elementsRef, onSelectElement],
|
[stopMarqueeAutoScroll, elementsRef, onSelectElement],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerUp = useCallback(() => {
|
const canFinishPointerGesture = useCallback(
|
||||||
|
(e?: React.PointerEvent) => {
|
||||||
|
const pointerId = activePointerIdRef.current;
|
||||||
|
return pointerId !== null && (!e || e.pointerId === pointerId) && isGestureSessionCurrent();
|
||||||
|
},
|
||||||
|
[isGestureSessionCurrent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishScrub = useCallback(() => {
|
||||||
|
if (seekRafRef.current) {
|
||||||
|
cancelAnimationFrame(seekRafRef.current);
|
||||||
|
seekRafRef.current = 0;
|
||||||
|
}
|
||||||
|
seekFromX(pendingClientXRef.current);
|
||||||
|
isDragging.current = false;
|
||||||
|
setIsScrubbing(false);
|
||||||
|
cancelAnimationFrame(dragScrollRaf.current);
|
||||||
|
}, [dragScrollRaf, isDragging, seekFromX]);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(
|
||||||
|
(e?: React.PointerEvent) => {
|
||||||
|
if (!canFinishPointerGesture(e)) return;
|
||||||
|
activePointerIdRef.current = null;
|
||||||
|
gestureEpochRef.current = null;
|
||||||
if (isRangeSelecting.current) {
|
if (isRangeSelecting.current) {
|
||||||
finishRangeSelection();
|
finishRangeSelection();
|
||||||
return;
|
return;
|
||||||
@@ -413,16 +498,53 @@ export function useTimelineRangeSelection({
|
|||||||
finishMarquee(marquee);
|
finishMarquee(marquee);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isDragging.current) return;
|
if (isDragging.current) finishScrub();
|
||||||
|
},
|
||||||
|
[canFinishPointerGesture, finishRangeSelection, finishMarquee, finishScrub, isDragging],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelActiveGesture = useCallback(
|
||||||
|
(updateUi: boolean, restoreSelection: boolean) => {
|
||||||
|
activePointerIdRef.current = null;
|
||||||
|
gestureEpochRef.current = null;
|
||||||
|
isRangeSelecting.current = false;
|
||||||
|
isDragging.current = false;
|
||||||
|
stopMarqueeAutoScroll();
|
||||||
if (seekRafRef.current) {
|
if (seekRafRef.current) {
|
||||||
cancelAnimationFrame(seekRafRef.current);
|
cancelAnimationFrame(seekRafRef.current);
|
||||||
seekRafRef.current = 0;
|
seekRafRef.current = 0;
|
||||||
}
|
}
|
||||||
seekFromX(pendingClientXRef.current);
|
|
||||||
isDragging.current = false;
|
|
||||||
setIsScrubbing(false);
|
|
||||||
cancelAnimationFrame(dragScrollRaf.current);
|
cancelAnimationFrame(dragScrollRaf.current);
|
||||||
}, [isDragging, dragScrollRaf, seekFromX, finishRangeSelection, finishMarquee]);
|
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);
|
// Escape: cancel an in-flight marquee (restores the pre-drag selection);
|
||||||
// otherwise clear any lingering multi-selection.
|
// otherwise clear any lingering multi-selection.
|
||||||
@@ -432,15 +554,11 @@ export function useTimelineRangeSelection({
|
|||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
const marquee = marqueeRef.current;
|
const marquee = marqueeRef.current;
|
||||||
if (marquee) {
|
if (marquee) {
|
||||||
marqueeRef.current = null;
|
cancelActiveGesture(true, true);
|
||||||
stopMarqueeAutoScroll();
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
if (isRangeSelecting.current || isDragging.current) {
|
||||||
|
cancelActiveGesture(true, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Escape with no marquee clears the whole selection — primary AND set.
|
// Escape with no marquee clears the whole selection — primary AND set.
|
||||||
@@ -451,7 +569,21 @@ export function useTimelineRangeSelection({
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("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 {
|
return {
|
||||||
rangeSelection,
|
rangeSelection,
|
||||||
@@ -462,5 +594,6 @@ export function useTimelineRangeSelection({
|
|||||||
handlePointerDown,
|
handlePointerDown,
|
||||||
handlePointerMove,
|
handlePointerMove,
|
||||||
handlePointerUp,
|
handlePointerUp,
|
||||||
|
handlePointerCancel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||||
import { getTimelineRowGeometry } from "./timelineLayout";
|
import { getTimelineRowGeometry } from "./timelineLayout";
|
||||||
|
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
@@ -60,7 +61,6 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
|||||||
const dragScrollRaf = { current: 0 };
|
const dragScrollRaf = { current: 0 };
|
||||||
const isDragging = { current: false };
|
const isDragging = { current: false };
|
||||||
const elementsRef = { current: [] };
|
const elementsRef = { current: [] };
|
||||||
const trackOrderRef = { current: [] };
|
|
||||||
const rowGeometryRef = { current: getTimelineRowGeometry([]) };
|
const rowGeometryRef = { current: getTimelineRowGeometry([]) };
|
||||||
|
|
||||||
function Probe(): null {
|
function Probe(): null {
|
||||||
@@ -75,7 +75,8 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
|||||||
isDragging,
|
isDragging,
|
||||||
setShowPopover: vi.fn(),
|
setShowPopover: vi.fn(),
|
||||||
elementsRef,
|
elementsRef,
|
||||||
trackOrderRef,
|
clipIndex: createTimelineClipIndex([]),
|
||||||
|
sessionEpoch: 0,
|
||||||
rowGeometryRef,
|
rowGeometryRef,
|
||||||
contentOrigin: 0,
|
contentOrigin: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user