mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
The diamond context menu still declared `onChangeEase` and `onCopyProperties` props, and `TimelineOverlays` still threaded `onChangeKeyframeEase` plus a `keyframeCache` it never read. Nothing on any timeline branch calls them, so they are removed along with the `onChangeKeyframeEase` callback implementation. Also from review: - `deleteSelectedKeyframes` only falls back to the sole keyframed animation when there is exactly one. A collapsed selection key carries no animation id, so taking the first of several deleted an arbitrary tween's keyframe. - The duration-less retime test asserts the real 87.601% instead of `expect.any(Number)`, so a wrong timing basis fails it. - `Timeline` wires the keyframe handlers' `onSelectSegment` through to the diamonds; it was built and then dropped, so segment ease selection never fired. - The flat text section arms auto-focus in state rather than reading and clearing a ref during render, which Strict Mode's double render swallowed.
521 lines
24 KiB
TypeScript
521 lines
24 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
import { TimelineClip } from "./TimelineClip";
|
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
|
import type { TimelineTheme } from "./timelineTheme";
|
|
import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
|
import {
|
|
usePlayerStore,
|
|
type TimelineElement,
|
|
type KeyframeCacheEntry,
|
|
} from "../store/playerStore";
|
|
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
import {
|
|
isMultiDragPassenger,
|
|
multiDragPassengerOffsetPx,
|
|
type MultiDragPreviewInput,
|
|
} from "./timelineMultiDragPreview";
|
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
import { Music } from "../../icons/SystemIcons";
|
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
|
|
/**
|
|
* Props shared by the scroll container ({@link TimelineCanvas}) and the lane
|
|
* renderer below. TimelineCanvas passes these straight through via spread, so
|
|
* they are declared once here and both prop types compose from this base — no
|
|
* duplicated prop list.
|
|
*/
|
|
export interface TimelineLaneBaseProps {
|
|
pps: number;
|
|
trackContentWidth: number;
|
|
theme: TimelineTheme;
|
|
displayTrackOrder: number[];
|
|
trackOrder: number[];
|
|
tracks: [number, TimelineElement[]][];
|
|
trackStyles: Map<number, TrackVisualStyle>;
|
|
selectedElementId: string | null;
|
|
selectedElementIds: Set<string>;
|
|
hoveredClip: string | null;
|
|
draggedClip: DraggedClipState | null;
|
|
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
suppressClickRef: React.RefObject<boolean>;
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
renderClipContent?: (
|
|
element: TimelineElement,
|
|
style: { clip: string; label: string },
|
|
) => ReactNode;
|
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
onDrillDown?: (element: TimelineElement) => void;
|
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
setHoveredClip: (key: string | null) => void;
|
|
setShowPopover: (v: boolean) => void;
|
|
setRangeSelection: (v: null) => void;
|
|
setResizingClip: (v: ResizingClipState | null) => void;
|
|
setDraggedClip: (v: DraggedClipState | null) => void;
|
|
setSelectedElementId: (id: string | null) => void;
|
|
syncClipDragAutoScroll: (x: number, y: number) => void;
|
|
shiftClickClipRef: React.RefObject<{
|
|
element: TimelineElement;
|
|
anchorX: number;
|
|
anchorY: number;
|
|
} | null>;
|
|
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
selectedKeyframes: Set<string>;
|
|
currentTime: number;
|
|
onClickKeyframe?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
/** Click on the segment BETWEEN two diamonds: selects it to edit its ease. */
|
|
onSelectSegment?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
onContextMenuKeyframe?: (
|
|
e: React.MouseEvent,
|
|
elementId: string,
|
|
keyframe: TimelineKeyframeTarget,
|
|
) => void;
|
|
onMoveKeyframe?: (
|
|
elementId: string,
|
|
keyframe: TimelineKeyframeTarget,
|
|
toClipPercentage: number,
|
|
) => Promise<boolean>;
|
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
/**
|
|
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
|
* before this fires — not the gutter/ruler, not below the lanes). `time` is
|
|
* the timeline time (seconds) under the pointer on that lane.
|
|
*/
|
|
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
}
|
|
|
|
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
draggedElement: TimelineElement | null;
|
|
multiDragPreview: MultiDragPreviewInput | null;
|
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
|
onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
|
|
}
|
|
|
|
export function TimelineLanes({
|
|
pps,
|
|
trackContentWidth,
|
|
theme,
|
|
displayTrackOrder,
|
|
trackOrder,
|
|
tracks,
|
|
trackStyles,
|
|
selectedElementId,
|
|
selectedElementIds,
|
|
hoveredClip,
|
|
draggedClip,
|
|
draggedElement,
|
|
multiDragPreview,
|
|
blockedClipRef,
|
|
suppressClickRef,
|
|
scrollRef,
|
|
renderClipContent,
|
|
renderClipOverlay,
|
|
onDrillDown,
|
|
onSelectElement,
|
|
setHoveredClip,
|
|
setShowPopover,
|
|
setRangeSelection,
|
|
setResizingClip,
|
|
setDraggedClip,
|
|
setSelectedElementId,
|
|
syncClipDragAutoScroll,
|
|
shiftClickClipRef,
|
|
getPreviewElement,
|
|
getTrackStyle,
|
|
keyframeCache,
|
|
selectedKeyframes,
|
|
currentTime,
|
|
onClickKeyframe,
|
|
onShiftClickKeyframe,
|
|
onSelectSegment,
|
|
onContextMenuKeyframe,
|
|
onMoveKeyframe,
|
|
onContextMenuClip,
|
|
onContextMenuLane,
|
|
beatAnalysis,
|
|
onToggleTrackHidden,
|
|
onResizeElement,
|
|
onMoveElement,
|
|
onRazorSplit,
|
|
onRazorSplitAll,
|
|
}: TimelineLanesProps) {
|
|
return (
|
|
<>
|
|
{
|
|
// NOTE (deliberate no-virtualization): lanes and their clips render via a
|
|
// plain `.map()` inside the scroll container rather than a windowing/virtualized
|
|
// list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is
|
|
// bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
|
|
// in a virtualizer if editorial workflows ever push very high clip counts.
|
|
// fallow-ignore-next-line complexity
|
|
displayTrackOrder.map((trackNum) => {
|
|
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
const isPendingTrack =
|
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
// All lanes use the same uniform color — no alternating stripes.
|
|
const rowBackground = theme.rowBackground;
|
|
// The beat-dot strip occupies the top of this track's lane (active track,
|
|
// or the music track when nothing is selected). When shown, keyframe
|
|
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
|
const beatStripOnTrack =
|
|
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
|
|
(selectedElementId
|
|
? els.some((e) => (e.key ?? e.id) === selectedElementId)
|
|
: els.some(isMusicTrack));
|
|
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
|
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
|
return (
|
|
<div key={trackNum} className="relative flex" style={{ height: TRACK_H }}>
|
|
<div
|
|
className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
|
|
style={{
|
|
width: GUTTER,
|
|
background: theme.gutterBackground,
|
|
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
borderBottom: `1px solid ${theme.rowBorder}`,
|
|
}}
|
|
>
|
|
{isAudioTrack && (
|
|
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
|
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
|
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
isTrackHidden
|
|
? "text-[#3CE6AC] hover:text-white"
|
|
: "text-white/35 hover:text-white/75"
|
|
}`}
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void onToggleTrackHidden?.(trackNum, !isTrackHidden);
|
|
}}
|
|
>
|
|
{isTrackHidden ? (
|
|
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
|
) : (
|
|
<Eye size={14} weight="bold" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
{/* Left breathing pad — empty lane surface before t=0, scrolling
|
|
with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE
|
|
the time-mapped content div so clip/beat/menu math stays
|
|
content-relative (clip left = t·pps). */}
|
|
<div
|
|
aria-hidden="true"
|
|
className="flex-shrink-0"
|
|
style={{ width: TRACKS_LEFT_PAD }}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: trackContentWidth,
|
|
background: rowBackground,
|
|
borderBottom: `1px solid ${theme.rowBorder}`,
|
|
opacity: isTrackHidden ? 0.35 : 1,
|
|
transition: "opacity 120ms ease",
|
|
}}
|
|
className="relative"
|
|
onContextMenu={(e: React.MouseEvent) => {
|
|
// Clip / keyframe-diamond context menus preventDefault at the
|
|
// target before this bubble handler runs — respect them so a
|
|
// right-click on a clip never also opens the gap menu.
|
|
if (e.defaultPrevented || !onContextMenuLane) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const time = (e.clientX - rect.left) / pps;
|
|
if (time < 0) return;
|
|
e.preventDefault();
|
|
onContextMenuLane(e, trackNum, time);
|
|
}}
|
|
>
|
|
{/* Faint beat lines in every track's background (behind the clips);
|
|
the active move-snap target is highlighted. */}
|
|
<BeatBackgroundLines
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
pps={pps}
|
|
highlightTime={
|
|
draggedClip?.started && draggedClip.snapType === "beat"
|
|
? draggedClip.snapTime
|
|
: null
|
|
}
|
|
/>
|
|
{/* Beat dots on the active track (the one holding the selection),
|
|
falling back to the music track when nothing is selected. */}
|
|
{beatStripOnTrack && (
|
|
<BeatStrip
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
pps={pps}
|
|
/>
|
|
)}
|
|
{isPendingTrack && (
|
|
<div
|
|
className="absolute inset-0 flex items-center"
|
|
style={{
|
|
paddingLeft: 16,
|
|
color: ts.label,
|
|
fontSize: 11,
|
|
letterSpacing: "0.06em",
|
|
textTransform: "uppercase",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
New track
|
|
</div>
|
|
)}
|
|
{
|
|
// fallow-ignore-next-line complexity
|
|
els.map((el) => {
|
|
const clipStyle = getTrackStyle(el.tag);
|
|
const elementKey = el.key ?? el.id;
|
|
const capabilities = getTimelineEditCapabilities(el);
|
|
const isSelected =
|
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
const isComposition = !!el.compositionSrc;
|
|
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
|
|
// fold in the map index, or a splice/reorder remounts every clip
|
|
// at/after the change (DOM flash, drag interruption).
|
|
const clipKey = elementKey;
|
|
const isDraggingClip =
|
|
draggedClip?.started === true &&
|
|
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
|
if (isDraggingClip) return null;
|
|
const previewElement = getPreviewElement(el);
|
|
// Passenger of a live multi-drag: slide by the SAME formation
|
|
// delta (the grabbed clip's group-clamped delta) via a
|
|
// compositor transform on a same-geometry wrapper (absolute
|
|
// inset-0 → identical offset parent, so the clip's own
|
|
// left/top are preserved), plus the ghost's elevated z/opacity.
|
|
const isPassenger =
|
|
multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
|
|
const passengerOffsetPx = isPassenger
|
|
? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
|
|
: 0;
|
|
const clip = (
|
|
<TimelineClip
|
|
key={clipKey}
|
|
onContextMenu={(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
onContextMenuClip?.(e, el);
|
|
}}
|
|
el={previewElement}
|
|
pps={pps}
|
|
clipY={CLIP_Y}
|
|
isSelected={isSelected}
|
|
isHovered={hoveredClip === clipKey}
|
|
isDragging={false}
|
|
hasCustomContent={!!renderClipContent}
|
|
capabilities={capabilities}
|
|
theme={theme}
|
|
isComposition={isComposition}
|
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
onHoverEnd={() => setHoveredClip(null)}
|
|
onResizeStart={
|
|
// fallow-ignore-next-line complexity
|
|
(edge, e) => {
|
|
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
if (edge === "end" && !capabilities.canTrimEnd) return;
|
|
e.stopPropagation();
|
|
blockedClipRef.current = null;
|
|
setShowPopover(false);
|
|
setRangeSelection(null);
|
|
setResizingClip({
|
|
element: el,
|
|
edge,
|
|
originClientX: e.clientX,
|
|
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
previewStart: el.start,
|
|
previewDuration: el.duration,
|
|
previewPlaybackStart: el.playbackStart,
|
|
started: false,
|
|
});
|
|
}
|
|
}
|
|
onPointerDown={
|
|
// fallow-ignore-next-line complexity
|
|
(e) => {
|
|
if (e.button !== 0) return;
|
|
if (usePlayerStore.getState().activeTool === "razor") return;
|
|
if (e.shiftKey) {
|
|
shiftClickClipRef.current = {
|
|
element: el,
|
|
anchorX: e.clientX,
|
|
anchorY: e.clientY,
|
|
};
|
|
return;
|
|
}
|
|
const target = e.currentTarget as HTMLElement;
|
|
const rect = target.getBoundingClientRect();
|
|
const blockedIntent = resolveBlockedTimelineEditIntent({
|
|
width: rect.width,
|
|
offsetX: e.clientX - rect.left,
|
|
handleWidth: CLIP_HANDLE_W,
|
|
capabilities,
|
|
});
|
|
if (
|
|
blockedIntent &&
|
|
((blockedIntent === "move" && onMoveElement) ||
|
|
(blockedIntent !== "move" && onResizeElement))
|
|
) {
|
|
blockedClipRef.current = {
|
|
element: el,
|
|
intent: blockedIntent,
|
|
originClientX: e.clientX,
|
|
originClientY: e.clientY,
|
|
started: false,
|
|
};
|
|
return;
|
|
}
|
|
if (!onMoveElement || !capabilities.canMove) return;
|
|
blockedClipRef.current = null;
|
|
setShowPopover(false);
|
|
setRangeSelection(null);
|
|
setDraggedClip({
|
|
element: el,
|
|
originClientX: e.clientX,
|
|
originClientY: e.clientY,
|
|
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
|
pointerClientX: e.clientX,
|
|
pointerClientY: e.clientY,
|
|
pointerOffsetX: e.clientX - rect.left,
|
|
pointerOffsetY: e.clientY - rect.top,
|
|
previewStart: el.start,
|
|
previewTrack: el.track,
|
|
desiredTrack: el.track,
|
|
insertRow: null,
|
|
snapTime: null,
|
|
snapType: null,
|
|
started: false,
|
|
});
|
|
syncClipDragAutoScroll(e.clientX, e.clientY);
|
|
}
|
|
}
|
|
onClick={
|
|
// fallow-ignore-next-line complexity
|
|
(e) => {
|
|
e.stopPropagation();
|
|
if (suppressClickRef.current) return;
|
|
const { activeTool } = usePlayerStore.getState();
|
|
if (activeTool === "razor" && onRazorSplit) {
|
|
const clipRect = (
|
|
e.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
const clickOffsetX = e.clientX - clipRect.left;
|
|
const splitTime = previewElement.start + clickOffsetX / pps;
|
|
const clampedTime = Math.max(
|
|
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
|
Math.min(
|
|
previewElement.start +
|
|
previewElement.duration -
|
|
SPLIT_BOUNDARY_EPSILON_S,
|
|
splitTime,
|
|
),
|
|
);
|
|
if (e.shiftKey && onRazorSplitAll) {
|
|
onRazorSplitAll(clampedTime);
|
|
} else {
|
|
onRazorSplit(el, clampedTime);
|
|
}
|
|
return;
|
|
}
|
|
// Plain click single-selects: drop any marquee multi-selection.
|
|
// Only a click on the PRIMARY selection toggles it off — a click
|
|
// on a marquee-selected clip narrows the selection to that clip.
|
|
const hadMultiSelection = selectedElementIds.size > 0;
|
|
usePlayerStore.getState().clearSelectedElementIds();
|
|
const nextElement =
|
|
selectedElementId === elementKey && !hadMultiSelection ? null : el;
|
|
setSelectedElementId(nextElement ? elementKey : null);
|
|
onSelectElement?.(nextElement);
|
|
}
|
|
}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
if (suppressClickRef.current) return;
|
|
if (isComposition && onDrillDown) onDrillDown(el);
|
|
}}
|
|
>
|
|
{renderClipChildren(
|
|
previewElement,
|
|
clipStyle,
|
|
renderClipContent,
|
|
renderClipOverlay,
|
|
)}
|
|
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
|
|
<TimelineClipDiamonds
|
|
keyframesData={keyframeCache.get(elementKey)!}
|
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
|
beatsActive={beatStripOnTrack}
|
|
accentColor={clipStyle.accent}
|
|
isSelected={isSelected}
|
|
currentPercentage={
|
|
previewElement.duration > 0
|
|
? ((currentTime - previewElement.start) / previewElement.duration) *
|
|
100
|
|
: 0
|
|
}
|
|
elementId={elementKey}
|
|
selectedKeyframes={selectedKeyframes}
|
|
onClickKeyframe={(_elId, keyframe) =>
|
|
onClickKeyframe?.(previewElement, keyframe)
|
|
}
|
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
onSelectSegment={onSelectSegment}
|
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
suppressClickRef={suppressClickRef}
|
|
/>
|
|
)}
|
|
</TimelineClip>
|
|
);
|
|
if (!isPassenger) return clip;
|
|
return (
|
|
<div
|
|
key={clipKey}
|
|
className="absolute inset-0"
|
|
style={{
|
|
transform: `translateX(${passengerOffsetPx}px)`,
|
|
opacity: 0.85,
|
|
zIndex: 20,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{clip}
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
</>
|
|
);
|
|
}
|