diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 92040c67b..937554bcc 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -8,6 +8,7 @@ import { useMountEffect } from "../../hooks/useMountEffect"; import { EditPopover } from "./EditModal"; import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; +import { useTimelineMarqueeSelection } from "./useTimelineMarqueeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons"; @@ -21,6 +22,7 @@ import { type KeyframeDiamondContextMenuState, } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; +import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; import { ClipContextMenu } from "./ClipContextMenu"; import { TimelineShortcutHint } from "./TimelineShortcutHint"; import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder"; @@ -36,7 +38,6 @@ import { import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; -// Re-export pure utilities so existing imports from "./Timeline" still resolve. export { generateTicks, formatTimelineTickLabel, @@ -206,7 +207,6 @@ export const Timeline = memo(function Timeline({ const ppsRef = useRef(100); 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 { @@ -230,7 +230,6 @@ export const Timeline = memo(function Timeline({ setRangeSelectionRef, }); - // basis drives the zoom (committed); effective adds the live preview (see timelineLayout). const basisDuration = useMemo( () => computeTimelineBasisDuration( @@ -269,6 +268,15 @@ export const Timeline = memo(function Timeline({ const keyframeCache = usePlayerStore((s) => s.keyframeCache); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const keyframeHandlers = useTimelineKeyframeHandlers({ + expandedElements, + keyframeCache, + onSelectElement, + onSeek, + setSelectedElementId, + setKfContextMenu, + toggleSelectedKeyframe, + }); const selectedElement = useMemo( () => @@ -278,7 +286,6 @@ export const Timeline = memo(function Timeline({ const selectedElementRef = useRef(selectedElement); selectedElementRef.current = selectedElement; - // Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom. const fitPps = viewportWidth > GUTTER && basisDuration > 0 ? (viewportWidth - GUTTER - 2) / basisDuration @@ -346,7 +353,20 @@ export const Timeline = memo(function Timeline({ isDragging, 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; const prevSelectedRef = useRef(selectedElementRef.current); @@ -445,11 +465,21 @@ export const Timeline = memo(function Timeline({ onRazorSplitAll?.(splitTime); return; } + if (handleMarqueePointerDown(e)) return; handlePointerDown(e); }} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onLostPointerCapture={handlePointerUp} + onPointerMove={(e) => { + if (handleMarqueePointerMove(e)) return; + handlePointerMove(e); + }} + onPointerUp={(e) => { + if (handleMarqueePointerUp(e)) return; + handlePointerUp(); + }} + onLostPointerCapture={(e) => { + if (handleMarqueePointerUp(e)) return; + handlePointerUp(); + }} > { - usePlayerStore.getState().clearSelectedKeyframes(); - 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}`); - }} + onClickKeyframe={keyframeHandlers.onClickKeyframe} + onShiftClickKeyframe={keyframeHandlers.onShiftClickKeyframe} onMoveKeyframe={onMoveKeyframe} - onContextMenuKeyframe={(e, elId, pct) => { - 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, - }); - }} + onContextMenuKeyframe={keyframeHandlers.onContextMenuKeyframe} onContextMenuClip={(e, el) => { e.preventDefault(); setSelectedElementId(el.key ?? el.id); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index ba378ef52..7c206072d 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -11,7 +11,7 @@ import { type TimelineRangeSelection, } from "./timelineEditing"; 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 { usePlayerStore, type TimelineElement, @@ -32,6 +32,8 @@ import { import { resolveTimelineDropIndicator } from "./timelineDropIndicator"; import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine"; import { TimelineDragGhost } from "./TimelineDragGhost"; +import { TimelineSelectionOverlays } from "./TimelineSelectionOverlays"; +import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection"; function ClipLintDot({ element }: { element: TimelineElement }) { const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id)); @@ -54,6 +56,7 @@ interface TimelineCanvasProps { effectiveDuration: number; majorTickInterval: number; rangeSelection: TimelineRangeSelection | null; + marqueeRect: TimelineMarqueeOverlayRect | null; theme: TimelineTheme; displayTrackOrder: TimelineLayerId[]; trackOrder: TimelineLayerId[]; @@ -112,6 +115,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ effectiveDuration, majorTickInterval, rangeSelection, + marqueeRect, theme, displayTrackOrder, trackOrder, @@ -561,22 +565,11 @@ export const TimelineCanvas = memo(function TimelineCanvas({ )} - {/* Range highlight */} - {rangeSelection && ( -
- )} + {/* Playhead — hidden while dragging a beat so its guideline doesn't track the scrub and clutter the beat being moved. */} diff --git a/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx index 569b15720..c8fed4a8c 100644 --- a/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx @@ -2,7 +2,7 @@ import type { TimelineTheme } from "./timelineTheme"; import { GUTTER } from "./timelineLayout"; import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; -const TIMELINE_LAYER_GROUP_HEADER_H = 18; +export const TIMELINE_LAYER_GROUP_HEADER_H = 18; export function shouldShowTimelineLayerGroupHeader( contextKey: string, diff --git a/packages/studio/src/player/components/TimelineSelectionOverlays.tsx b/packages/studio/src/player/components/TimelineSelectionOverlays.tsx new file mode 100644 index 000000000..edcb03bd0 --- /dev/null +++ b/packages/studio/src/player/components/TimelineSelectionOverlays.tsx @@ -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 && ( +
+ )} + {marqueeRect && ( +