mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
perf(studio): virtualize timeline marquee selection (#2707)
This commit is contained in:
@@ -10,6 +10,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
import { getTimelineScrubTime } from "./timelineLayout";
|
||||
import {
|
||||
computeMarqueeSelection,
|
||||
getMarqueeClipCandidates,
|
||||
getMarqueeRect,
|
||||
isMarqueeDrag,
|
||||
isTimelineRulerPress,
|
||||
@@ -17,6 +18,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<HTMLDivElement | null>;
|
||||
@@ -30,10 +32,11 @@ interface UseTimelineRangeSelectionInput {
|
||||
isDragging: React.RefObject<boolean>;
|
||||
setShowPopover: (v: boolean) => void;
|
||||
elementsRef: React.RefObject<TimelineElement[]>;
|
||||
trackOrderRef: React.RefObject<number[]>;
|
||||
clipIndex: TimelineClipIndex;
|
||||
rowGeometryRef: React.RefObject<TimelineRowGeometry>;
|
||||
onSelectElement?: (element: TimelineElement | null) => void;
|
||||
contentOrigin: number;
|
||||
sessionEpoch: number;
|
||||
}
|
||||
|
||||
interface MarqueeDragState {
|
||||
@@ -73,16 +76,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,
|
||||
@@ -95,6 +103,28 @@ function commitMarqueeSelection(
|
||||
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({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
@@ -107,10 +137,11 @@ export function useTimelineRangeSelection({
|
||||
isDragging,
|
||||
setShowPopover,
|
||||
elementsRef,
|
||||
trackOrderRef,
|
||||
clipIndex,
|
||||
rowGeometryRef,
|
||||
onSelectElement,
|
||||
contentOrigin,
|
||||
sessionEpoch,
|
||||
}: UseTimelineRangeSelectionInput) {
|
||||
const isRangeSelecting = useRef(false);
|
||||
const rangeAnchorTime = useRef(0);
|
||||
@@ -126,6 +157,17 @@ export function useTimelineRangeSelection({
|
||||
|
||||
const seekRafRef = 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.
|
||||
const marqueeRef = useRef<MarqueeDragState | null>(null);
|
||||
@@ -159,7 +201,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)) {
|
||||
@@ -175,14 +217,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(() => {
|
||||
@@ -203,14 +244,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) => {
|
||||
@@ -235,6 +278,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();
|
||||
@@ -248,9 +293,31 @@ export function useTimelineRangeSelection({
|
||||
[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(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!canStartPointerGesture(e, activePointerIdRef.current, sessionEpochRef.current)) return;
|
||||
if (e.shiftKey) {
|
||||
beginRangeSelection(e);
|
||||
return;
|
||||
@@ -258,6 +325,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);
|
||||
@@ -266,30 +335,15 @@ export function useTimelineRangeSelection({
|
||||
// y (which folds in scrollTop) breaks once the body is scrolled down and
|
||||
// the stuck ruler visually overlays scrolled-away track rows.
|
||||
const scrollRect = scrollRef.current?.getBoundingClientRect();
|
||||
if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
|
||||
isDragging.current = true;
|
||||
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);
|
||||
if (!isMarqueePress(point, scrollRect, e.clientY)) {
|
||||
beginScrub(e.clientX);
|
||||
return;
|
||||
}
|
||||
// Empty body press → pending marquee. A plain click (no drag past the
|
||||
// threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
|
||||
const base = snapshotSelection();
|
||||
marqueeRef.current = {
|
||||
originX: point.x,
|
||||
originY: point.y,
|
||||
baseIds: base.ids,
|
||||
basePrimary: base.primary,
|
||||
additive: e.metaKey || e.ctrlKey,
|
||||
active: false,
|
||||
};
|
||||
beginMarquee(point, e.metaKey || e.ctrlKey);
|
||||
},
|
||||
[beginRangeSelection, seekFromX, scrollRef, isDragging, setShowPopover, toContentPoint],
|
||||
[beginRangeSelection, beginScrub, beginMarquee, scrollRef, setShowPopover, toContentPoint],
|
||||
);
|
||||
|
||||
// Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
|
||||
@@ -325,18 +379,27 @@ export function useTimelineRangeSelection({
|
||||
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
|
||||
);
|
||||
|
||||
const updateRangeSelection = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
if (!scroll || !rect) return;
|
||||
const x = e.clientX - rect.left + scroll.scrollLeft - contentOrigin;
|
||||
setRangeSelection((previous) =>
|
||||
previous
|
||||
? { ...previous, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
||||
: 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) {
|
||||
const rect = scrollRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
|
||||
setRangeSelection((prev) =>
|
||||
prev
|
||||
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
updateRangeSelection(e);
|
||||
return;
|
||||
}
|
||||
const marquee = marqueeRef.current;
|
||||
@@ -351,13 +414,12 @@ export function useTimelineRangeSelection({
|
||||
updateScrubDrag(e.clientX);
|
||||
},
|
||||
[
|
||||
pps,
|
||||
scrollRef,
|
||||
isDragging,
|
||||
applyMarqueeAtClient,
|
||||
syncMarqueeAutoScroll,
|
||||
updateScrubDrag,
|
||||
contentOrigin,
|
||||
updateRangeSelection,
|
||||
isGestureSessionCurrent,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -403,17 +465,15 @@ 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;
|
||||
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;
|
||||
@@ -422,7 +482,69 @@ export function useTimelineRangeSelection({
|
||||
isDragging.current = false;
|
||||
setIsScrubbing(false);
|
||||
cancelAnimationFrame(dragScrollRaf.current);
|
||||
}, [isDragging, dragScrollRaf, seekFromX, finishRangeSelection, finishMarquee]);
|
||||
}, [dragScrollRaf, isDragging, seekFromX]);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e?: React.PointerEvent) => {
|
||||
if (!canFinishPointerGesture(e)) return;
|
||||
activePointerIdRef.current = null;
|
||||
gestureEpochRef.current = null;
|
||||
if (isRangeSelecting.current) {
|
||||
finishRangeSelection();
|
||||
return;
|
||||
}
|
||||
const marquee = marqueeRef.current;
|
||||
if (marquee) {
|
||||
finishMarquee(marquee);
|
||||
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) {
|
||||
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.
|
||||
@@ -432,15 +554,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.
|
||||
@@ -451,7 +569,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,
|
||||
@@ -462,5 +594,6 @@ export function useTimelineRangeSelection({
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerCancel,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user