feat: expandable keyframe lanes in timeline

Made-with: Cursor
This commit is contained in:
Maze Winther
2026-04-13 05:08:29 +02:00
parent 2272598d42
commit 36efdf66e7
10 changed files with 1119 additions and 370 deletions
@@ -0,0 +1,117 @@
import type {
AnimationPath,
ElementAnimations,
} from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/timeline";
import { getElementKeyframes } from "@/lib/animation";
import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
export interface ExpandedRow {
propertyPath: AnimationPath;
label: string;
}
interface PropertyGroupDefinition {
matchesPath: (path: AnimationPath) => boolean;
}
const PROPERTY_GROUPS: PropertyGroupDefinition[] = [
{ matchesPath: (path) => path.startsWith("transform.") || path === "opacity" },
{ matchesPath: (path) => path === "volume" || path === "color" },
{ matchesPath: (path) => path.startsWith("background.") },
{ matchesPath: (path) => path.startsWith("params.") },
{ matchesPath: (path) => path.startsWith("effects.") },
];
const PROPERTY_LABELS: Partial<Record<string, string>> = {
"transform.positionX": "Position X",
"transform.positionY": "Position Y",
"transform.scaleX": "Scale X",
"transform.scaleY": "Scale Y",
"transform.rotate": "Rotation",
opacity: "Opacity",
volume: "Volume",
color: "Color",
"background.color": "BG Color",
"background.paddingX": "BG Pad X",
"background.paddingY": "BG Pad Y",
"background.offsetX": "BG Offset X",
"background.offsetY": "BG Offset Y",
"background.cornerRadius": "Corner Radius",
};
export function getPropertyLabel(path: AnimationPath): string {
if (PROPERTY_LABELS[path]) return PROPERTY_LABELS[path];
if (path.startsWith("params.")) return path.slice("params.".length);
if (path.startsWith("effects.")) {
const parts = path.split(".");
return parts[parts.length - 1];
}
return path;
}
export function getExpandedRows({
animations,
}: {
animations: ElementAnimations | undefined;
}): ExpandedRow[] {
const keyframes = getElementKeyframes({ animations });
const propertyPaths = [...new Set(keyframes.map((kf) => kf.propertyPath))];
if (propertyPaths.length === 0) return [];
const rows: ExpandedRow[] = [];
for (const group of PROPERTY_GROUPS) {
const groupPaths = propertyPaths.filter((path) =>
group.matchesPath(path),
);
for (const path of groupPaths) {
rows.push({ propertyPath: path, label: getPropertyLabel(path) });
}
}
return rows;
}
export function getExpansionHeight({ rows }: { rows: ExpandedRow[] }): number {
return rows.length * KEYFRAME_LANE_HEIGHT_PX;
}
export function computeTrackExpansionHeight({
track,
expandedElementIds,
}: {
track: TimelineTrack;
expandedElementIds: Set<string>;
}): number {
let maxHeight = 0;
for (const element of track.elements) {
if (!expandedElementIds.has(element.id)) continue;
const rows = getExpandedRows({ animations: element.animations });
maxHeight = Math.max(maxHeight, getExpansionHeight({ rows }));
}
return maxHeight;
}
export function getTrackExpandedRows({
track,
expandedElementIds,
}: {
track: TimelineTrack;
expandedElementIds: Set<string>;
}): ExpandedRow[] {
let maxHeight = 0;
let maxRows: ExpandedRow[] = [];
for (const element of track.elements) {
if (!expandedElementIds.has(element.id)) continue;
const rows = getExpandedRows({ animations: element.animations });
const height = getExpansionHeight({ rows });
if (height > maxHeight) {
maxHeight = height;
maxRows = rows;
}
}
return maxRows;
}
@@ -1,6 +1,7 @@
import { import {
getCurveHandlesForNormalizedCubicBezier, getCurveHandlesForNormalizedCubicBezier,
getEditableScalarChannels, getEditableScalarChannels,
getEasingModeForKind,
getNormalizedCubicBezierForScalarSegment, getNormalizedCubicBezierForScalarSegment,
getScalarKeyframeContext, getScalarKeyframeContext,
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
@@ -54,7 +55,14 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
propertyPath: SelectedKeyframeRef["propertyPath"]; propertyPath: SelectedKeyframeRef["propertyPath"];
keyframeId: string; keyframeId: string;
element: TimelineElement; element: TimelineElement;
/** Primary channel context, used for displaying the curve. */
context: ScalarGraphKeyframeContext; context: ScalarGraphKeyframeContext;
/**
* All channel contexts that share this curve. For independent-easing bindings
* this is [context]. For shared-easing bindings (e.g. color) this contains
* all component contexts so patches can be applied to every channel at once.
*/
allContexts: ScalarGraphKeyframeContext[];
cubicBezier: NormalizedCubicBezier; cubicBezier: NormalizedCubicBezier;
} }
@@ -234,16 +242,17 @@ export function resolveGraphEditorSelectionState({
}); });
} }
const scalarChannels = getEditableScalarChannels({ const scalarResult = getEditableScalarChannels({
animations: selectedElement.element.animations, animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath, propertyPath: primaryKeyframe.propertyPath,
}); });
if (scalarChannels.length === 0) { if (!scalarResult || scalarResult.channels.length === 0) {
return createUnavailableState({ return createUnavailableState({
reason: "selected-keyframe-has-no-scalar-channel", reason: "selected-keyframe-has-no-scalar-channel",
message: "The selected keyframe has no editable graph channel.", message: "The selected keyframe has no editable graph channel.",
}); });
} }
const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
// When 2 keyframes are selected, resolve the earlier one as the outgoing-segment // When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
// anchor so the graph editor edits the curve between the two selected keyframes. // anchor so the graph editor edits the curve between the two selected keyframes.
@@ -264,6 +273,8 @@ export function resolveGraphEditorSelectionState({
} }
} }
const easingMode = getEasingModeForKind(resolvedBinding.kind);
const contexts = scalarChannels.flatMap((channel) => { const contexts = scalarChannels.flatMap((channel) => {
const context = getScalarKeyframeContext({ const context = getScalarKeyframeContext({
animations: selectedElement.element.animations, animations: selectedElement.element.animations,
@@ -293,14 +304,20 @@ export function resolveGraphEditorSelectionState({
}); });
} }
const nextSegmentContexts = contexts.filter( // For shared-easing bindings (e.g. color), all components always use the same
// curve. Collapse to a single option so no per-component tabs are shown.
const visibleContexts =
easingMode === "shared" ? [contexts[0]] : contexts;
const allContexts = contexts.map(({ context }) => context);
const nextSegmentContexts = visibleContexts.filter(
({ context }) => context.nextKey !== null, ({ context }) => context.nextKey !== null,
); );
const preferredContext = const preferredContext =
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null; visibleContexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
const activeContext = const activeContext =
preferredContext ?? nextSegmentContexts[0] ?? contexts[0]; preferredContext ?? nextSegmentContexts[0] ?? visibleContexts[0];
const componentOptions = contexts.map(({ option }) => option); const componentOptions = visibleContexts.map(({ option }) => option);
if (!activeContext.context.nextKey) { if (!activeContext.context.nextKey) {
return createUnavailableState({ return createUnavailableState({
@@ -356,6 +373,7 @@ export function resolveGraphEditorSelectionState({
keyframeId: resolvedKeyframeId, keyframeId: resolvedKeyframeId,
element: selectedElement.element, element: selectedElement.element,
context: activeContext.context, context: activeContext.context,
allContexts,
cubicBezier, cubicBezier,
}; };
} }
@@ -96,19 +96,17 @@ export function useGraphEditorController() {
return; return;
} }
const nextAnimations = applyGraphEditorCurvePreview({ const nextAnimations = state.allContexts.reduce(
animations: state.element.animations, (animations, context) =>
context: state.context, applyGraphEditorCurvePreview({ animations, context, cubicBezier: nextValue }),
cubicBezier: nextValue, state.element.animations,
}); );
editor.timeline.previewElements({ editor.timeline.previewElements({
updates: [ updates: [
{ {
trackId: state.trackId, trackId: state.trackId,
elementId: state.elementId, elementId: state.elementId,
updates: { updates: { animations: nextAnimations },
animations: nextAnimations,
},
}, },
], ],
}); });
@@ -123,6 +121,8 @@ export function useGraphEditorController() {
return; return;
} }
// Build patches from the primary context (all shared-easing channels have
// the same keyframe IDs, so the same patches apply to each).
const patches = buildGraphEditorCurvePatches({ const patches = buildGraphEditorCurvePatches({
context: state.context, context: state.context,
cubicBezier: nextValue, cubicBezier: nextValue,
@@ -132,14 +132,16 @@ export function useGraphEditorController() {
} }
editor.timeline.updateKeyframeCurves({ editor.timeline.updateKeyframeCurves({
keyframes: patches.map(({ keyframeId, patch }) => ({ keyframes: state.allContexts.flatMap((context) =>
trackId: state.trackId, patches.map(({ keyframeId, patch }) => ({
elementId: state.elementId, trackId: state.trackId,
propertyPath: state.propertyPath, elementId: state.elementId,
componentKey: state.context.componentKey, propertyPath: state.propertyPath,
keyframeId, componentKey: context.componentKey,
patch, keyframeId,
})), patch,
})),
),
}); });
hasPreviewRef.current = false; hasPreviewRef.current = false;
}, },
@@ -42,6 +42,7 @@ import {
TIMELINE_CONTENT_TOP_PADDING_PX, TIMELINE_CONTENT_TOP_PADDING_PX,
TIMELINE_TRACK_GAP_PX, TIMELINE_TRACK_GAP_PX,
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
KEYFRAME_LANE_HEIGHT_PX,
} from "./layout"; } from "./layout";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import { import {
@@ -57,6 +58,13 @@ import {
getTotalTracksHeight, getTotalTracksHeight,
} from "./track-layout"; } from "./track-layout";
import { SELECTED_TRACK_ROW_CLASS } from "./theme"; import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import {
computeTrackExpansionHeight,
getTrackExpandedRows,
getExpansionHeight,
getPropertyLabel,
type ExpandedRow,
} from "./expanded-layout";
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
import { TimelineToolbar } from "./timeline-toolbar"; import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
@@ -169,6 +177,17 @@ export function Timeline() {
rulerScrollRef, rulerScrollRef,
}); });
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
const getTrackExpansionHeight = useCallback(
(trackIndex: number) => {
const track = tracks[trackIndex];
if (!track) return 0;
return computeTrackExpansionHeight({ track, expandedElementIds });
},
[tracks, expandedElementIds],
);
// Stable refs so the wheel listener never goes stale // Stable refs so the wheel listener never goes stale
const setZoomLevelRef = useRef(setZoomLevel); const setZoomLevelRef = useRef(setZoomLevel);
useEffect(() => { useEffect(() => {
@@ -417,6 +436,7 @@ export function Timeline() {
trackLabelsScrollRef={trackLabelsScrollRef} trackLabelsScrollRef={trackLabelsScrollRef}
timelineHeaderHeight={timelineHeaderHeight} timelineHeaderHeight={timelineHeaderHeight}
hasHorizontalScrollbar={hasHorizontalScrollbar} hasHorizontalScrollbar={hasHorizontalScrollbar}
getTrackExpansionHeight={getTrackExpansionHeight}
/> />
<div <div
@@ -493,7 +513,7 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.min, TRACKS_CONTAINER_HEIGHT.min,
Math.min( Math.min(
TRACKS_CONTAINER_HEIGHT.max, TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({ tracks }), getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
), ),
) + TIMELINE_CONTENT_TOP_PADDING_PX ) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`, }px`,
@@ -513,9 +533,8 @@ export function Timeline() {
}} }}
> >
{tracks.length > 0 && ( {tracks.length > 0 && (
<TimelineTrackRows <TimelineTrackRows
dragElementId={dragState.elementId} mainTrackId={mainTrackId}
mainTrackId={mainTrackId}
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
dragState={dragState} dragState={dragState}
tracksScrollRef={tracksScrollRef} tracksScrollRef={tracksScrollRef}
@@ -575,11 +594,13 @@ function TrackLabelsPanel({
trackLabelsScrollRef, trackLabelsScrollRef,
timelineHeaderHeight, timelineHeaderHeight,
hasHorizontalScrollbar, hasHorizontalScrollbar,
getTrackExpansionHeight,
}: { }: {
trackLabelsRef: React.RefObject<HTMLDivElement | null>; trackLabelsRef: React.RefObject<HTMLDivElement | null>;
trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>; trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
timelineHeaderHeight: number; timelineHeaderHeight: number;
hasHorizontalScrollbar: boolean; hasHorizontalScrollbar: boolean;
getTrackExpansionHeight: (trackIndex: number) => number;
}) { }) {
const editor = useEditor(); const editor = useEditor();
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
@@ -596,6 +617,15 @@ function TrackLabelsPanel({
[selectedElements], [selectedElements],
); );
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
const trackExpandedRowsMap = useMemo(
() =>
tracks.map((track) =>
getTrackExpandedRows({ track, expandedElementIds }),
),
[tracks, expandedElementIds],
);
return ( return (
<div <div
className="flex shrink-0 flex-col border-r" className="flex shrink-0 flex-col border-r"
@@ -612,43 +642,62 @@ function TrackLabelsPanel({
className="flex flex-col" className="flex flex-col"
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }} style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
> >
{tracks.map((track) => ( {tracks.map((track, index) => {
<div const expandedRows = trackExpandedRowsMap[index];
key={track.id} const baseHeight = getTrackHeight({ type: track.type });
className={cn(
"group flex items-center px-3", return (
tracksWithSelection.has(track.id) && <div
SELECTED_TRACK_ROW_CLASS, key={track.id}
)} className={cn(
style={{ "group flex flex-col",
height: `${getTrackHeight({ type: track.type })}px`, tracksWithSelection.has(track.id) &&
}} SELECTED_TRACK_ROW_CLASS,
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{canTrackHaveAudio(track) && (
<TrackToggleIcon
isOff={track.muted}
icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
onClick={() =>
editor.timeline.toggleTrackMute({ trackId: track.id })
}
/>
)} )}
{canTrackBeHidden(track) && ( style={{
<TrackToggleIcon height: `${baseHeight + getTrackExpansionHeight(index)}px`,
isOff={track.hidden} }}
icons={{ on: ViewIcon, off: ViewOffSlashIcon }} >
onClick={() => <div
editor.timeline.toggleTrackVisibility({ className="flex shrink-0 items-center justify-end gap-2 px-3"
trackId: track.id, style={{ height: `${baseHeight}px` }}
}) >
} {canTrackHaveAudio(track) && (
/> <TrackToggleIcon
isOff={track.muted}
icons={{
on: VolumeHighIcon,
off: VolumeOffIcon,
}}
onClick={() =>
editor.timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
)}
{canTrackBeHidden(track) && (
<TrackToggleIcon
isOff={track.hidden}
icons={{
on: ViewIcon,
off: ViewOffSlashIcon,
}}
onClick={() =>
editor.timeline.toggleTrackVisibility({
trackId: track.id,
})
}
/>
)}
<TrackIcon track={track} />
</div>
{expandedRows.length > 0 && (
<PropertyTree rows={expandedRows} />
)} )}
<TrackIcon track={track} />
</div> </div>
</div> );
))} })}
</div> </div>
)} )}
</div> </div>
@@ -664,7 +713,6 @@ function TrackLabelsPanel({
} }
function TimelineTrackRows({ function TimelineTrackRows({
dragElementId,
mainTrackId, mainTrackId,
zoomLevel, zoomLevel,
dragState, dragState,
@@ -680,7 +728,6 @@ function TimelineTrackRows({
isDragOver, isDragOver,
dropTarget, dropTarget,
}: { }: {
dragElementId: string | null;
mainTrackId: string | null; mainTrackId: string | null;
zoomLevel: number; zoomLevel: number;
dragState: ElementDragState; dragState: ElementDragState;
@@ -715,24 +762,34 @@ function TimelineTrackRows({
[selectedElements], [selectedElements],
); );
const sortedTracks = useMemo( const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
() =>
[...tracks] const getTrackExpansionHeight = useCallback(
.map((track, index) => ({ track, index })) (trackIndex: number) => {
.sort((a, b) => { const track = tracks[trackIndex];
const aHasDragged = a.track.elements.some( if (!track) return 0;
(el) => el.id === dragElementId, return computeTrackExpansionHeight({ track, expandedElementIds });
); },
const bHasDragged = b.track.elements.some( [tracks, expandedElementIds],
(el) => el.id === dragElementId,
);
if (aHasDragged) return 1;
if (bHasDragged) return -1;
return 0;
}),
[tracks, dragElementId],
); );
const sortedTracks = useMemo(() => {
const draggingElementIds = new Set(dragState.dragElementIds);
return [...tracks]
.map((track, index) => ({ track, index }))
.sort((a, b) => {
const aHasDragged = a.track.elements.some((element) =>
draggingElementIds.has(element.id),
);
const bHasDragged = b.track.elements.some((element) =>
draggingElementIds.has(element.id),
);
if (aHasDragged) return 1;
if (bHasDragged) return -1;
return 0;
});
}, [tracks, dragState.dragElementIds]);
return ( return (
<> <>
{sortedTracks.map(({ track, index }) => ( {sortedTracks.map(({ track, index }) => (
@@ -745,8 +802,8 @@ function TimelineTrackRows({
SELECTED_TRACK_ROW_CLASS, SELECTED_TRACK_ROW_CLASS,
)} )}
style={{ style={{
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`, top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
height: `${getTrackHeight({ type: track.type })}px`, height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`,
}} }}
> >
<TimelineTrackContent <TimelineTrackContent
@@ -868,3 +925,23 @@ function TrackToggleIcon({
</> </>
); );
} }
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
return (
<div className="flex flex-col overflow-hidden">
{rows.map((row, index) => (
<div
key={row.propertyPath}
className={cn(
"flex shrink-0 items-center px-3 bg-muted/50",
)}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
<span className="text-muted-foreground truncate text-xs leading-none">
{getPropertyLabel(row.propertyPath)}
</span>
</div>
))}
</div>
);
}
@@ -8,6 +8,10 @@ export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
effect: 25, effect: 25,
} as const; } as const;
export const KEYFRAME_LANE_HEIGHT_PX = 20;
export const KEYFRAME_DIAMOND_SIZE_PX = 14;
export const EXPANDED_GROUP_HEADER_HEIGHT_PX = 18;
export const TIMELINE_TRACK_GAP_PX = 6; export const TIMELINE_TRACK_GAP_PX = 6;
export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112; export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
export const TIMELINE_RULER_HEIGHT_PX = 22; export const TIMELINE_RULER_HEIGHT_PX = 22;
@@ -3,13 +3,14 @@
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import { AudioWaveform } from "./audio-waveform"; import { AudioWaveform } from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; import { useElementPreview } from "@/hooks/use-element-preview";
import { import {
useKeyframeDrag, useKeyframeDrag,
type KeyframeDragState, type KeyframeDragState,
} from "@/hooks/timeline/element/use-keyframe-drag"; } from "@/hooks/timeline/element/use-keyframe-drag";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
import { SelectionBox } from "@/lib/selection/selection-box";
import { getElementKeyframes } from "@/lib/animation"; import { getElementKeyframes } from "@/lib/animation";
import { import {
canElementHaveAudio, canElementHaveAudio,
@@ -20,10 +21,7 @@ import {
timelineTimeToSnappedPixels, timelineTimeToSnappedPixels,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { getTrackHeight } from "./track-layout"; import { getTrackHeight } from "./track-layout";
import { import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
getTimelineElementClassName,
TIMELINE_TRACK_THEME,
} from "./theme";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -68,19 +66,25 @@ import {
Search01Icon, Search01Icon,
Exchange01Icon, Exchange01Icon,
KeyframeIcon, KeyframeIcon,
Link02Icon,
MagicWand05Icon, MagicWand05Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string"; import { uppercase } from "@/utils/string";
import type { ComponentProps, ReactNode } from "react"; import { useMemo, type ComponentProps, type ReactNode } from "react";
import type { import type {
SelectedKeyframeRef, SelectedKeyframeRef,
ElementKeyframe, ElementKeyframe,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
import { useTimelineStore } from "@/stores/timeline-store";
import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
import {
getExpandedRows,
getExpansionHeight,
type ExpandedRow,
} from "./expanded-layout";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5; const ELEMENT_RING_WIDTH_PX = 1.5;
@@ -191,8 +195,12 @@ interface TimelineElementProps {
track: TimelineTrack; track: TimelineTrack;
zoomLevel: number; zoomLevel: number;
isSelected: boolean; isSelected: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onResizeStart: (params: {
onResizeStateChange?: (params: { isResizing: boolean }) => void; event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => void;
onElementMouseDown: ( onElementMouseDown: (
event: React.MouseEvent, event: React.MouseEvent,
element: TimelineElementType, element: TimelineElementType,
@@ -210,8 +218,7 @@ export function TimelineElement({
track, track,
zoomLevel, zoomLevel,
isSelected, isSelected,
onSnapPointChange, onResizeStart,
onResizeStateChange,
onElementMouseDown, onElementMouseDown,
onElementClick, onElementClick,
dragState, dragState,
@@ -220,6 +227,11 @@ export function TimelineElement({
const mediaAssets = useEditor((e) => e.media.getAssets()); const mediaAssets = useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection(); const { selectedElements } = useElementSelection();
const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
const { renderElement } = useElementPreview({
trackId: track.id,
elementId: element.id,
fallback: element,
});
let mediaAsset: MediaAsset | null = null; let mediaAsset: MediaAsset | null = null;
@@ -230,31 +242,23 @@ export function TimelineElement({
const hasAudio = mediaSupportsAudio({ media: mediaAsset }); const hasAudio = mediaSupportsAudio({ media: mediaAsset });
const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
useTimelineElementResize({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
});
const isCurrentElementSelected = selectedElements.some( const isCurrentElementSelected = selectedElements.some(
(selected) => (selected) =>
selected.elementId === element.id && selected.trackId === track.id, selected.elementId === element.id && selected.trackId === track.id,
); );
const isBeingDragged = dragState.elementId === element.id; const isBeingDragged = dragState.dragElementIds.includes(element.id);
const dragOffsetY = const dragOffsetY =
isBeingDragged && dragState.isDragging isBeingDragged && dragState.isDragging
? dragState.currentMouseY - dragState.startMouseY ? dragState.currentMouseY - dragState.startMouseY
: 0; : 0;
const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
const elementStartTime = const elementStartTime =
isBeingDragged && dragState.isDragging isBeingDragged && dragState.isDragging
? dragState.currentTime ? dragState.currentTime + dragTimeOffset
: element.startTime; : renderElement.startTime;
const displayedStartTime = isResizing ? currentStartTime : elementStartTime; const displayedStartTime = elementStartTime;
const displayedDuration = isResizing ? currentDuration : element.duration; const displayedDuration = renderElement.duration;
const elementWidth = timelineTimeToPixels({ const elementWidth = timelineTimeToPixels({
time: displayedDuration, time: displayedDuration,
zoomLevel, zoomLevel,
@@ -281,6 +285,41 @@ export function TimelineElement({
handleKeyframeClick, handleKeyframeClick,
getVisualOffsetPx, getVisualOffsetPx,
} = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
const elementKeyframes = getElementKeyframes({
animations: element.animations,
});
const isExpanded = useTimelineStore((s) =>
s.expandedElementIds.has(element.id),
);
const toggleElementExpanded = useTimelineStore(
(s) => s.toggleElementExpanded,
);
const expandedRows = useMemo(
() =>
isExpanded
? getExpandedRows({ animations: element.animations })
: [],
[isExpanded, element.animations],
);
const {
containerRef: expandedLanesRef,
selectionBox: keyframeSelectionBox,
isBoxSelecting: isKeyframeBoxSelecting,
handleExpandedAreaMouseDown,
handleExpandedAreaClick,
} = useKeyframeBoxSelect({
trackId: track.id,
elementId: element.id,
rows: expandedRows,
keyframes: elementKeyframes,
displayedStartTime,
zoomLevel,
elementLeft,
});
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation(); event.stopPropagation();
if (hasMediaId(element)) { if (hasMediaId(element)) {
@@ -299,15 +338,44 @@ export function TimelineElement({
: "Extract audio"; : "Extract audio";
const isElementSourceAudioSeparated = const isElementSourceAudioSeparated =
element.type === "video" && isSourceAudioSeparated({ element }); element.type === "video" && isSourceAudioSeparated({ element });
const hasKeyframes = elementKeyframes.length > 0;
const expansionHeight = getExpansionHeight({ rows: expandedRows });
const baseTrackHeight = getTrackHeight({ type: track.type });
const expandedContent =
isExpanded && expandedRows.length > 0 ? (
<ExpandedKeyframeLanes
rows={expandedRows}
keyframes={elementKeyframes}
trackId={track.id}
elementId={element.id}
displayedStartTime={displayedStartTime}
zoomLevel={zoomLevel}
elementLeft={elementLeft}
keyframeDragState={keyframeDragState}
onKeyframeMouseDown={handleKeyframeMouseDown}
onKeyframeClick={handleKeyframeClick}
getVisualOffsetPx={getVisualOffsetPx}
containerRef={expandedLanesRef}
onLaneMouseDown={handleExpandedAreaMouseDown}
onLaneClick={handleExpandedAreaClick}
selectionBox={keyframeSelectionBox}
isBoxSelecting={isKeyframeBoxSelecting}
/>
) : null;
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <div
className="absolute top-0 h-full select-none" className="absolute top-0 select-none"
style={{ style={{
left: `${elementLeft}px`, left: `${elementLeft}px`,
width: `${elementWidth}px`, width: `${elementWidth}px`,
height:
expandedRows.length > 0
? `${baseTrackHeight + expansionHeight}px`
: "100%",
transform: transform:
isBeingDragged && dragState.isDragging isBeingDragged && dragState.isDragging
? `translate3d(0, ${dragOffsetY}px, 0)` ? `translate3d(0, ${dragOffsetY}px, 0)`
@@ -318,13 +386,19 @@ export function TimelineElement({
element={element} element={element}
track={track} track={track}
isSelected={isSelected} isSelected={isSelected}
isExpanded={expandedRows.length > 0}
baseTrackHeight={baseTrackHeight}
expandedContent={expandedContent}
onElementClick={onElementClick} onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown} onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart} onResizeStart={onResizeStart}
isDropTarget={isDropTarget} isDropTarget={isDropTarget}
/> />
{isSelected && ( {isSelected && (
<div className="pointer-events-none absolute inset-0 overflow-hidden"> <div
className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
style={{ height: `${baseTrackHeight}px` }}
>
<KeyframeIndicators <KeyframeIndicators
indicators={keyframeIndicators} indicators={keyframeIndicators}
dragState={keyframeDragState} dragState={keyframeDragState}
@@ -346,6 +420,14 @@ export function TimelineElement({
Split Split
</ActionMenuItem> </ActionMenuItem>
<CopyMenuItem /> <CopyMenuItem />
{selectedElements.length === 1 && (
<ActionMenuItem
action="duplicate-selected"
icon={<HugeiconsIcon icon={Copy01Icon} />}
>
Duplicate
</ActionMenuItem>
)}
{canElementHaveAudio(element) && hasAudio && ( {canElementHaveAudio(element) && hasAudio && (
<MuteMenuItem <MuteMenuItem
isMultipleSelected={selectedElements.length > 1} isMultipleSelected={selectedElements.length > 1}
@@ -355,11 +437,11 @@ export function TimelineElement({
)} )}
{canToggleCurrentSourceAudio && ( {canToggleCurrentSourceAudio && (
<ContextMenuItem <ContextMenuItem
icon={ icon={
<HugeiconsIcon <HugeiconsIcon
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon} icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
/> />
} }
onClick={(event: React.MouseEvent) => { onClick={(event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
invokeAction("toggle-source-audio"); invokeAction("toggle-source-audio");
@@ -375,13 +457,16 @@ export function TimelineElement({
isCurrentElementSelected={isCurrentElementSelected} isCurrentElementSelected={isCurrentElementSelected}
/> />
)} )}
{selectedElements.length === 1 && ( {hasKeyframes && (
<ActionMenuItem <ContextMenuItem
action="duplicate-selected" icon={<HugeiconsIcon icon={KeyframeIcon} />}
icon={<HugeiconsIcon icon={Copy01Icon} />} onClick={(event: React.MouseEvent) => {
event.stopPropagation();
toggleElementExpanded(element.id);
}}
> >
Duplicate {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
</ActionMenuItem> </ContextMenuItem>
)} )}
{selectedElements.length === 1 && hasMediaId(element) && ( {selectedElements.length === 1 && hasMediaId(element) && (
<> <>
@@ -417,14 +502,20 @@ function ElementInner({
element, element,
track, track,
isSelected, isSelected,
isExpanded,
baseTrackHeight,
expandedContent,
onElementClick, onElementClick,
onElementMouseDown, onElementMouseDown,
handleResizeStart, onResizeStart,
isDropTarget = false, isDropTarget = false,
}: { }: {
element: TimelineElementType; element: TimelineElementType;
track: TimelineTrack; track: TimelineTrack;
isSelected: boolean; isSelected: boolean;
isExpanded: boolean;
baseTrackHeight: number;
expandedContent: React.ReactNode;
onElementClick: ( onElementClick: (
event: React.MouseEvent, event: React.MouseEvent,
element: TimelineElementType, element: TimelineElementType,
@@ -433,9 +524,10 @@ function ElementInner({
event: React.MouseEvent, event: React.MouseEvent,
element: TimelineElementType, element: TimelineElementType,
) => void; ) => void;
handleResizeStart: (params: { onResizeStart: (params: {
event: React.MouseEvent; event: React.MouseEvent;
elementId: string; element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right"; side: "left" | "right";
}) => void; }) => void;
isDropTarget?: boolean; isDropTarget?: boolean;
@@ -463,8 +555,7 @@ function ElementInner({
<div <div
className={cn( className={cn(
"absolute inset-0 overflow-hidden rounded-sm", "absolute inset-0 overflow-hidden rounded-sm",
getTimelineElementClassName({ type: track.type }), isExpanded && "bg-background",
isReducedOpacity && "opacity-50",
)} )}
> >
<button <button
@@ -474,9 +565,23 @@ function ElementInner({
onClick={(event) => onElementClick(event, element)} onClick={(event) => onElementClick(event, element)}
onMouseDown={(event) => onElementMouseDown(event, element)} onMouseDown={(event) => onElementMouseDown(event, element)}
> >
<div className="flex flex-1 min-h-0 items-center overflow-hidden"> <div
<ElementContent element={element} track={track} /> className={cn(
"flex shrink-0 items-center overflow-hidden",
getTimelineElementClassName({
type: getTrackTypeForElementType({
elementType: element.type,
}),
}),
isReducedOpacity && "opacity-50",
)}
style={{ height: `${baseTrackHeight}px` }}
>
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
<ElementContent element={element} track={track} />
</div>
</div> </div>
{expandedContent}
</button> </button>
</div> </div>
</div> </div>
@@ -485,13 +590,15 @@ function ElementInner({
<> <>
<ResizeHandle <ResizeHandle
side="left" side="left"
elementId={element.id} element={element}
handleResizeStart={handleResizeStart} track={track}
onResizeStart={onResizeStart}
/> />
<ResizeHandle <ResizeHandle
side="right" side="right"
elementId={element.id} element={element}
handleResizeStart={handleResizeStart} track={track}
onResizeStart={onResizeStart}
/> />
</> </>
)} )}
@@ -501,14 +608,17 @@ function ElementInner({
function ResizeHandle({ function ResizeHandle({
side, side,
elementId, element,
handleResizeStart, track,
onResizeStart,
}: { }: {
side: "left" | "right"; side: "left" | "right";
elementId: string; element: TimelineElementType;
handleResizeStart: (params: { track: TimelineTrack;
onResizeStart: (params: {
event: React.MouseEvent; event: React.MouseEvent;
elementId: string; element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right"; side: "left" | "right";
}) => void; }) => void;
}) { }) {
@@ -520,7 +630,7 @@ function ResizeHandle({
"absolute top-0 bottom-0 w-2", "absolute top-0 bottom-0 w-2",
isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
)} )}
onMouseDown={(event) => handleResizeStart({ event, elementId, side })} onMouseDown={(event) => onResizeStart({ event, element, track, side })}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`} aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
></button> ></button>
@@ -582,7 +692,7 @@ function KeyframeIndicators({
<button <button
key={indicator.time} key={indicator.time}
type="button" type="button"
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab" className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5"
style={{ left: visualOffsetPx }} style={{ left: visualOffsetPx }}
onMouseDown={(event) => onMouseDown={(event) =>
onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
@@ -610,6 +720,177 @@ function KeyframeIndicators({
}); });
} }
function ExpandedKeyframeLanes({
rows,
keyframes,
trackId,
elementId,
displayedStartTime,
zoomLevel,
elementLeft,
keyframeDragState,
onKeyframeMouseDown,
onKeyframeClick,
getVisualOffsetPx,
containerRef,
onLaneMouseDown,
onLaneClick,
selectionBox,
isBoxSelecting,
}: {
rows: ExpandedRow[];
keyframes: ElementKeyframe[];
trackId: string;
elementId: string;
displayedStartTime: number;
zoomLevel: number;
elementLeft: number;
keyframeDragState: KeyframeDragState;
onKeyframeMouseDown: (params: {
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
}) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
onLaneMouseDown: (event: React.MouseEvent) => void;
onLaneClick: (event: React.MouseEvent) => void;
selectionBox: {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
} | null;
isBoxSelecting: boolean;
onKeyframeClick: (params: {
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
indicatorTime: number;
}) => void;
getVisualOffsetPx: (params: {
indicatorTime: number;
indicatorOffsetPx: number;
isBeingDragged: boolean;
displayedStartTime: number;
elementLeft: number;
}) => number;
}) {
const { isKeyframeSelected } = useKeyframeSelection();
const orderedKeyframes = useMemo(
() =>
[...keyframes]
.sort(
(a, b) =>
a.time - b.time ||
a.propertyPath.localeCompare(b.propertyPath),
)
.map((kf) => ({
trackId,
elementId,
propertyPath: kf.propertyPath,
keyframeId: kf.id,
})),
[keyframes, trackId, elementId],
);
return (
<div
ref={containerRef}
className="relative flex flex-col"
onMouseDown={onLaneMouseDown}
onClick={onLaneClick}
>
{rows.map((row) => {
const laneKeyframes = keyframes.filter(
(kf) => kf.propertyPath === row.propertyPath,
);
return (
<div
key={row.propertyPath}
className={cn(
"relative flex items-center bg-muted/50",
)}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
{laneKeyframes.map((kf) => {
const keyframeRef: SelectedKeyframeRef = {
trackId,
elementId,
propertyPath: row.propertyPath,
keyframeId: kf.id,
};
const isBeingDragged =
keyframeDragState.draggingKeyframeIds.has(kf.id);
const kfLeft = timelineTimeToSnappedPixels({
time: displayedStartTime + kf.time,
zoomLevel,
});
const offsetPx = kfLeft - elementLeft;
const visualOffset = getVisualOffsetPx({
indicatorTime: kf.time,
indicatorOffsetPx: offsetPx,
isBeingDragged,
displayedStartTime,
elementLeft,
});
const isSelected = isKeyframeSelected({
keyframe: keyframeRef,
});
return (
<button
key={kf.id}
type="button"
className={cn(
"pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
isBoxSelecting && "pointer-events-none",
)}
style={{ left: visualOffset }}
onMouseDown={(event) => {
event.stopPropagation();
onKeyframeMouseDown({
event,
keyframes: [keyframeRef],
});
}}
onClick={(event) => {
event.stopPropagation();
onKeyframeClick({
event,
keyframes: [keyframeRef],
orderedKeyframes,
indicatorTime: kf.time,
});
}}
aria-label="Select keyframe"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black mr-1",
isSelected
? "fill-primary"
: "fill-white",
)}
strokeWidth={1.5}
/>
</button>
);
})}
</div>
);
})}
{selectionBox && (
<SelectionBox
startPos={selectionBox.startPos}
currentPos={selectionBox.currentPos}
containerRef={containerRef}
isActive={selectionBox.isActive}
/>
)}
</div>
);
}
interface ElementContentProps { interface ElementContentProps {
element: TimelineElementType; element: TimelineElementType;
track: TimelineTrack; track: TimelineTrack;
@@ -1,5 +1,6 @@
import type { TrackType } from "@/lib/timeline"; import type { TrackType } from "@/lib/timeline";
import { import {
KEYFRAME_LANE_HEIGHT_PX,
TIMELINE_TRACK_GAP_PX, TIMELINE_TRACK_GAP_PX,
TIMELINE_TRACK_HEIGHTS_PX, TIMELINE_TRACK_HEIGHTS_PX,
} from "./layout"; } from "./layout";
@@ -8,28 +9,50 @@ export function getTrackHeight({ type }: { type: TrackType }): number {
return TIMELINE_TRACK_HEIGHTS_PX[type]; return TIMELINE_TRACK_HEIGHTS_PX[type];
} }
export function getExpandedTrackHeight({
type,
expandedLaneCount,
}: {
type: TrackType;
expandedLaneCount: number;
}): number {
return (
TIMELINE_TRACK_HEIGHTS_PX[type] +
expandedLaneCount * KEYFRAME_LANE_HEIGHT_PX
);
}
export function getCumulativeHeightBefore({ export function getCumulativeHeightBefore({
tracks, tracks,
trackIndex, trackIndex,
getExtraHeight,
}: { }: {
tracks: Array<{ type: TrackType }>; tracks: Array<{ type: TrackType }>;
trackIndex: number; trackIndex: number;
getExtraHeight?: (trackIndex: number) => number;
}): number { }): number {
return tracks return tracks
.slice(0, trackIndex) .slice(0, trackIndex)
.reduce( .reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }) + TIMELINE_TRACK_GAP_PX, (sum, track, i) =>
sum +
getTrackHeight({ type: track.type }) +
(getExtraHeight?.(i) ?? 0) +
TIMELINE_TRACK_GAP_PX,
0, 0,
); );
} }
export function getTotalTracksHeight({ export function getTotalTracksHeight({
tracks, tracks,
getExtraHeight,
}: { }: {
tracks: Array<{ type: TrackType }>; tracks: Array<{ type: TrackType }>;
getExtraHeight?: (trackIndex: number) => number;
}): number { }): number {
const tracksHeight = tracks.reduce( const tracksHeight = tracks.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }), (sum, track, i) =>
sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0),
0, 0,
); );
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX; const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
@@ -41,6 +41,8 @@ const MOUSE_BUTTON_RIGHT = 2;
const initialDragState: ElementDragState = { const initialDragState: ElementDragState = {
isDragging: false, isDragging: false,
elementId: null, elementId: null,
dragElementIds: [],
dragTimeOffsets: {},
trackId: null, trackId: null,
startMouseX: 0, startMouseX: 0,
startMouseY: 0, startMouseY: 0,
@@ -202,6 +204,8 @@ export function useElementInteraction({
setDragState({ setDragState({
isDragging: true, isDragging: true,
elementId, elementId,
dragElementIds: elementId ? [elementId] : [],
dragTimeOffsets: {},
trackId, trackId,
startMouseX, startMouseX,
startMouseY, startMouseY,
@@ -521,6 +525,7 @@ export function useElementInteraction({
}, [ }, [
dragState.isDragging, dragState.isDragging,
dragState.elementId, dragState.elementId,
dragState.startElementTime,
dragState.startMouseY, dragState.startMouseY,
dragState.trackId, dragState.trackId,
dragState.currentTime, dragState.currentTime,
@@ -0,0 +1,222 @@
import { useCallback, useRef, useMemo } from "react";
import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
import {
useKeyframeSelection,
getSelectedKeyframeId,
} from "./use-keyframe-selection";
import type {
SelectedKeyframeRef,
ElementKeyframe,
} from "@/lib/animation/types";
import type { ExpandedRow } from "@/components/editor/panels/timeline/expanded-layout";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import {
KEYFRAME_LANE_HEIGHT_PX,
KEYFRAME_DIAMOND_SIZE_PX,
} from "@/components/editor/panels/timeline/layout";
export function useKeyframeBoxSelect({
trackId,
elementId,
rows,
keyframes,
displayedStartTime,
zoomLevel,
elementLeft,
}: {
trackId: string;
elementId: string;
rows: ExpandedRow[];
keyframes: ElementKeyframe[];
displayedStartTime: number;
zoomLevel: number;
elementLeft: number;
}) {
const {
selectedKeyframes,
keyframeSelectionAnchor,
setKeyframeSelection,
clearKeyframeSelection,
} = useKeyframeSelection();
const containerRef = useRef<HTMLDivElement>(null);
const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]);
const keyframeEntries = useMemo(() => {
const entries: Array<{
id: string;
ref: SelectedKeyframeRef;
rowIndex: number;
offsetPx: number;
}> = [];
for (const kf of keyframes) {
const rowIndex = rows.findIndex(
(r) => r.propertyPath === kf.propertyPath,
);
if (rowIndex === -1) continue;
const ref: SelectedKeyframeRef = {
trackId,
elementId,
propertyPath: kf.propertyPath,
keyframeId: kf.id,
};
const kfLeft = timelineTimeToSnappedPixels({
time: displayedStartTime + kf.time,
zoomLevel,
});
entries.push({
id: getSelectedKeyframeId({ keyframe: ref }),
ref,
rowIndex,
offsetPx: kfLeft - elementLeft,
});
}
return entries;
}, [
keyframes,
rows,
trackId,
elementId,
displayedStartTime,
zoomLevel,
elementLeft,
]);
const idToRefMap = useMemo(() => {
const map = new Map<string, SelectedKeyframeRef>();
for (const entry of keyframeEntries) {
map.set(entry.id, entry.ref);
}
return map;
}, [keyframeEntries]);
const selectedIds = useMemo(
() => selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
[selectedKeyframes],
);
const anchorId = useMemo(
() =>
keyframeSelectionAnchor
? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor })
: null,
[keyframeSelectionAnchor],
);
const resolveIntersections = useCallback(
({
startPos,
currentPos,
}: {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
}) => {
const container = containerRef.current;
if (!container) return [];
const containerRect = container.getBoundingClientRect();
const sx = startPos.x - containerRect.left;
const sy = startPos.y - containerRect.top;
const cx = currentPos.x - containerRect.left;
const cy = currentPos.y - containerRect.top;
const selLeft = Math.min(sx, cx);
const selTop = Math.min(sy, cy);
const selRight = Math.max(sx, cx);
const selBottom = Math.max(sy, cy);
const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2;
return keyframeEntries
.filter((entry) => {
const kfX = entry.offsetPx;
const kfY =
entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX +
KEYFRAME_LANE_HEIGHT_PX / 2;
return !(
kfX + halfHit < selLeft ||
kfX - halfHit > selRight ||
kfY + halfHit < selTop ||
kfY - halfHit > selBottom
);
})
.map((entry) => entry.id);
},
[keyframeEntries],
);
const onSelectionChange = useCallback(
({
intersectedIds,
isAdditive,
}: {
intersectedIds: string[];
initialSelectedIds: string[];
initialAnchorId: string | null;
isAdditive: boolean;
}) => {
const intersectedRefs = intersectedIds
.map((id) => idToRefMap.get(id))
.filter((ref): ref is SelectedKeyframeRef => ref != null);
if (isAdditive) {
setKeyframeSelection({
keyframes: [
...initialKeyframesRef.current,
...intersectedRefs,
],
});
} else {
setKeyframeSelection({ keyframes: intersectedRefs });
}
},
[idToRefMap, setKeyframeSelection],
);
const {
selectionBox,
handleMouseDown: boxSelectMouseDown,
isSelecting,
shouldIgnoreClick,
} = useBoxSelect<string>({
containerRef,
resolveIntersections,
selectedIds,
anchorId,
onSelectionChange,
});
const handleExpandedAreaMouseDown = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
initialKeyframesRef.current = selectedKeyframes;
boxSelectMouseDown(event);
},
[boxSelectMouseDown, selectedKeyframes],
);
const handleExpandedAreaClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
if (shouldIgnoreClick()) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
clearKeyframeSelection();
},
[shouldIgnoreClick, clearKeyframeSelection],
);
return {
containerRef,
selectionBox,
isBoxSelecting: isSelecting,
handleExpandedAreaMouseDown,
handleExpandedAreaClick,
};
}
@@ -2,7 +2,7 @@ import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/lib/animation/types"; import type { SelectedKeyframeRef } from "@/lib/animation/types";
function getSelectedKeyframeId({ export function getSelectedKeyframeId({
keyframe, keyframe,
}: { }: {
keyframe: SelectedKeyframeRef; keyframe: SelectedKeyframeRef;