Files
hyperframes/packages/studio/src/player/components/TimelineCanvas.tsx
T

324 lines
13 KiB
TypeScript

import { memo } from "react";
import { TimelineRuler } from "./TimelineRuler";
import { PlayheadIndicator } from "./PlayheadIndicator";
import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
import { getRenderedTimelineElement } from "./timelineTheme";
import {
RULER_H,
CLIP_Y,
TRACKS_TOP_PAD,
TRACKS_BOTTOM_PAD,
TRACK_H,
PLAYHEAD_HEAD_W,
getTimelinePlayheadLeft,
getTimelineRowTop,
getTimelineRowHeight,
} from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import type { ResizingClipState } from "./useTimelineClipDrag";
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import type { Rect } from "../../utils/marqueeGeometry";
import { TimelineClip } from "./TimelineClip";
import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
import { renderClipChildren } from "./timelineClipChildren";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
minor: number[];
totalH: number;
effectiveDuration: number;
majorTickInterval: number;
rangeSelection: TimelineRangeSelection | null;
/** Live rubber-band multi-select rectangle (canvas coordinates), or null. */
marqueeRect: Rect | null;
resizingClip: ResizingClipState | null;
/** Playhead is being actively scrubbed — fills the grab-handle head. */
isScrubbing: boolean;
playheadRef: React.RefObject<HTMLDivElement | null>;
/** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
laneGapStrips: TimelineLaneGapStrips[];
}
export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
const draggedRowIndex =
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
const {
onResizeElement,
onMoveElement,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onRazorSplit,
onRazorSplitAll,
} = useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging);
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
useTimelineRevealClip(scrollRef);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
? getRenderedTimelineElement({
element: draggedElement,
draggedElementId: draggedElement.key ?? draggedElement.id,
previewStart: draggedClip.previewStart,
previewTrack: draggedClip.previewTrack,
})
: null;
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
// Live multi-selection drag: while a selected clip is dragged, ALL selected
// clips move together as one rigid formation. The GRABBED clip is the free
// ghost below; its co-selected "passengers" slide by the SAME group-clamped
// delta (cheap translateX, no re-layout) — the delta is derived from the
// grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at
// the wall together and never deforms. Matches what the commit will do — see
// timelineMultiDragPreview + commit.
const multiDragPreview: MultiDragPreviewInput | null =
draggedClip?.started === true && draggedElement
? {
dragStarted: true,
draggedKey: draggedElement.key ?? draggedElement.id,
draggedOriginStart: draggedElement.start,
draggedPreviewStart: draggedClip.previewStart,
selectedKeys: selectedElementIds,
}
: null;
const activeDraggedPosition =
draggedClip?.started === true && activeDraggedElement && scrollRef.current
? {
left:
draggedClip.pointerClientX -
scrollRef.current.getBoundingClientRect().left +
scrollRef.current.scrollLeft -
draggedClip.pointerOffsetX,
top:
draggedClip.pointerClientY -
scrollRef.current.getBoundingClientRect().top +
scrollRef.current.scrollTop -
draggedClip.pointerOffsetY,
}
: null;
return (
<div
className="relative"
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
>
<TimelineRuler
major={props.major}
minor={props.minor}
pps={props.pps}
trackContentWidth={props.trackContentWidth}
totalH={props.totalH}
effectiveDuration={props.effectiveDuration}
majorTickInterval={props.majorTickInterval}
theme={props.theme}
beatAnalysis={props.beatAnalysis}
contentOrigin={props.contentOrigin}
/>
{/* Breathing room between the sticky ruler and the first track lane — the
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
<div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
<TimelineLanes
{...props}
draggedElement={draggedElement}
multiDragPreview={multiDragPreview}
onToggleTrackHidden={onToggleTrackHidden}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onResizeElement={onResizeElement}
onMoveElement={onMoveElement}
onRazorSplit={onRazorSplit}
onRazorSplitAll={onRazorSplitAll}
/>
{/* Breathing room below the last track lane (~1.5 track heights) — a real
scrollable surface, so a clip can be dragged into the void to create a
new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
<div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
menu row would collapse; a quiet tint for every gap on the selected
clip's lane. Geometry mirrors the drop placeholder (row top + clip
inset) so strips sit exactly where a clip body would. */}
{props.laneGapStrips.map((strip) => {
const rowIndex = displayTrackOrder.indexOf(strip.track);
if (rowIndex < 0) return null;
const loud = strip.kind === "hover";
return strip.intervals.map((gap) => (
<div
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
className="pointer-events-none absolute"
style={{
top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y,
left: props.contentOrigin + gap.start * props.pps,
width: Math.max((gap.end - gap.start) * props.pps, 2),
height: TRACK_H - CLIP_Y * 2,
background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
borderRadius: 4,
zIndex: 25,
}}
/>
));
})}
{/* Drop placeholder — a clip-sized slot at the exact landing spot (target
lane + snapped start), parallel to the ghost. Hidden in insert mode. */}
{draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
<div
className="absolute pointer-events-none"
style={{
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
left: props.contentOrigin + draggedClip.previewStart * props.pps,
width: Math.max(draggedClip.element.duration * props.pps, 4),
height: draggedRowHeight - CLIP_Y * 2,
border: "1px solid rgba(60,230,172,0.55)",
background: "rgba(60,230,172,0.12)",
borderRadius: 4,
zIndex: 30,
}}
/>
)}
{/* Insertion line — a new track will be inserted at this boundary on drop.
Shown while the pointer is near a lane boundary (insert mode). */}
{draggedClip?.started && draggedClip.insertRow != null && (
<div
className="absolute pointer-events-none"
style={{
top: getTimelineRowTop(draggedClip.insertRow, props.rowHeights) - 0.5,
left: props.contentOrigin,
width: props.trackContentWidth,
height: 1,
background: "#3CE6AC",
boxShadow: "0 0 3px rgba(60,230,172,0.5)",
zIndex: 55,
}}
/>
)}
{/* Snap guide for non-beat targets during clip drag */}
{draggedClip?.started && draggedClip.snapTime != null && draggedClip.snapType !== "beat" && (
<div
className="absolute pointer-events-none"
style={{
left: props.contentOrigin + draggedClip.snapTime * props.pps,
top: RULER_H,
bottom: 0,
width: 1,
background: draggedClip.snapType === "playhead" ? "#3CE6AC" : "rgba(255,255,255,0.6)",
boxShadow:
draggedClip.snapType === "playhead"
? "0 0 6px rgba(60,230,172,0.5)"
: "0 0 6px rgba(255,255,255,0.4)",
zIndex: 60,
}}
/>
)}
{/* Drag ghost */}
{activeDraggedElement && activeDraggedPosition && (
<div
className="absolute pointer-events-none"
style={{
top: activeDraggedPosition.top,
left: activeDraggedPosition.left,
width: Math.max(activeDraggedElement.duration * props.pps, 4),
height: draggedRowHeight - CLIP_Y * 2,
zIndex: 40,
}}
>
<TimelineClip
el={{ ...activeDraggedElement, start: 0 }}
pps={props.pps}
clipY={0}
isSelected={
props.selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
}
isHovered={false}
isDragging={true}
hasCustomContent={!!props.renderClipContent}
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
theme={props.theme}
isComposition={!!activeDraggedElement.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onResizeStart={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
{renderClipChildren(
activeDraggedElement,
props.getTrackStyle(activeDraggedElement.tag),
props.renderClipContent,
props.renderClipOverlay,
)}
</TimelineClip>
</div>
)}
{/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas
MarqueeOverlay look: semi-transparent accent fill + dashed border. */}
{props.marqueeRect && (
<div
aria-hidden="true"
className="absolute pointer-events-none"
style={{
left: props.marqueeRect.left,
top: props.marqueeRect.top,
width: props.marqueeRect.width,
height: props.marqueeRect.height,
background: "rgba(60,230,172,0.10)",
border: "1px dashed rgba(60,230,172,0.7)",
borderRadius: 2,
zIndex: 70,
}}
/>
)}
{/* Range highlight */}
{props.rangeSelection && (
<div
className="absolute pointer-events-none"
style={{
left:
props.contentOrigin +
Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.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
track the scrub and clutter the beat being moved. Explicit width +
the half-head offset baked into getTimelinePlayheadLeft keep the
inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
ticks' center), instead of relying on shrink-wrap sizing. */}
<div
ref={props.playheadRef}
className="absolute top-0 bottom-0 pointer-events-none"
style={{
left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
width: PLAYHEAD_HEAD_W,
zIndex: 100,
display: beatDragging ? "none" : undefined,
}}
>
<PlayheadIndicator scrubbing={props.isScrubbing} />
</div>
</div>
);
});