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