feat: expandable keyframe lanes in timeline

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-13 04:41:10 +02:00
parent 2272598d42
commit 36efdf66e7
10 changed files with 1119 additions and 370 deletions

View File

@ -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;
}

View File

@ -1,6 +1,7 @@
import {
getCurveHandlesForNormalizedCubicBezier,
getEditableScalarChannels,
getEasingModeForKind,
getNormalizedCubicBezierForScalarSegment,
getScalarKeyframeContext,
updateScalarKeyframeCurve,
@ -54,7 +55,14 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
propertyPath: SelectedKeyframeRef["propertyPath"];
keyframeId: string;
element: TimelineElement;
/** Primary channel context, used for displaying the curve. */
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;
}
@ -234,16 +242,17 @@ export function resolveGraphEditorSelectionState({
});
}
const scalarChannels = getEditableScalarChannels({
const scalarResult = getEditableScalarChannels({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
});
if (scalarChannels.length === 0) {
if (!scalarResult || scalarResult.channels.length === 0) {
return createUnavailableState({
reason: "selected-keyframe-has-no-scalar-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
// 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 context = getScalarKeyframeContext({
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,
);
const preferredContext =
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
visibleContexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
const activeContext =
preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
const componentOptions = contexts.map(({ option }) => option);
preferredContext ?? nextSegmentContexts[0] ?? visibleContexts[0];
const componentOptions = visibleContexts.map(({ option }) => option);
if (!activeContext.context.nextKey) {
return createUnavailableState({
@ -356,6 +373,7 @@ export function resolveGraphEditorSelectionState({
keyframeId: resolvedKeyframeId,
element: selectedElement.element,
context: activeContext.context,
allContexts,
cubicBezier,
};
}

View File

@ -96,19 +96,17 @@ export function useGraphEditorController() {
return;
}
const nextAnimations = applyGraphEditorCurvePreview({
animations: state.element.animations,
context: state.context,
cubicBezier: nextValue,
});
const nextAnimations = state.allContexts.reduce(
(animations, context) =>
applyGraphEditorCurvePreview({ animations, context, cubicBezier: nextValue }),
state.element.animations,
);
editor.timeline.previewElements({
updates: [
{
trackId: state.trackId,
elementId: state.elementId,
updates: {
animations: nextAnimations,
},
updates: { animations: nextAnimations },
},
],
});
@ -123,6 +121,8 @@ export function useGraphEditorController() {
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({
context: state.context,
cubicBezier: nextValue,
@ -132,14 +132,16 @@ export function useGraphEditorController() {
}
editor.timeline.updateKeyframeCurves({
keyframes: patches.map(({ keyframeId, patch }) => ({
trackId: state.trackId,
elementId: state.elementId,
propertyPath: state.propertyPath,
componentKey: state.context.componentKey,
keyframeId,
patch,
})),
keyframes: state.allContexts.flatMap((context) =>
patches.map(({ keyframeId, patch }) => ({
trackId: state.trackId,
elementId: state.elementId,
propertyPath: state.propertyPath,
componentKey: context.componentKey,
keyframeId,
patch,
})),
),
});
hasPreviewRef.current = false;
},

View File

@ -42,6 +42,7 @@ import {
TIMELINE_CONTENT_TOP_PADDING_PX,
TIMELINE_TRACK_GAP_PX,
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
KEYFRAME_LANE_HEIGHT_PX,
} from "./layout";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@ -57,6 +58,13 @@ import {
getTotalTracksHeight,
} from "./track-layout";
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 { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
@ -169,6 +177,17 @@ export function Timeline() {
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
const setZoomLevelRef = useRef(setZoomLevel);
useEffect(() => {
@ -417,6 +436,7 @@ export function Timeline() {
trackLabelsScrollRef={trackLabelsScrollRef}
timelineHeaderHeight={timelineHeaderHeight}
hasHorizontalScrollbar={hasHorizontalScrollbar}
getTrackExpansionHeight={getTrackExpansionHeight}
/>
<div
@ -493,7 +513,7 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.min,
Math.min(
TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({ tracks }),
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
),
) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`,
@ -513,9 +533,8 @@ export function Timeline() {
}}
>
{tracks.length > 0 && (
<TimelineTrackRows
dragElementId={dragState.elementId}
mainTrackId={mainTrackId}
<TimelineTrackRows
mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
dragState={dragState}
tracksScrollRef={tracksScrollRef}
@ -575,11 +594,13 @@ function TrackLabelsPanel({
trackLabelsScrollRef,
timelineHeaderHeight,
hasHorizontalScrollbar,
getTrackExpansionHeight,
}: {
trackLabelsRef: React.RefObject<HTMLDivElement | null>;
trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
timelineHeaderHeight: number;
hasHorizontalScrollbar: boolean;
getTrackExpansionHeight: (trackIndex: number) => number;
}) {
const editor = useEditor();
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
@ -596,6 +617,15 @@ function TrackLabelsPanel({
[selectedElements],
);
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
const trackExpandedRowsMap = useMemo(
() =>
tracks.map((track) =>
getTrackExpandedRows({ track, expandedElementIds }),
),
[tracks, expandedElementIds],
);
return (
<div
className="flex shrink-0 flex-col border-r"
@ -612,43 +642,62 @@ function TrackLabelsPanel({
className="flex flex-col"
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
>
{tracks.map((track) => (
<div
key={track.id}
className={cn(
"group flex items-center px-3",
tracksWithSelection.has(track.id) &&
SELECTED_TRACK_ROW_CLASS,
)}
style={{
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
<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 })
}
/>
{tracks.map((track, index) => {
const expandedRows = trackExpandedRowsMap[index];
const baseHeight = getTrackHeight({ type: track.type });
return (
<div
key={track.id}
className={cn(
"group flex flex-col",
tracksWithSelection.has(track.id) &&
SELECTED_TRACK_ROW_CLASS,
)}
{canTrackBeHidden(track) && (
<TrackToggleIcon
isOff={track.hidden}
icons={{ on: ViewIcon, off: ViewOffSlashIcon }}
onClick={() =>
editor.timeline.toggleTrackVisibility({
trackId: track.id,
})
}
/>
style={{
height: `${baseHeight + getTrackExpansionHeight(index)}px`,
}}
>
<div
className="flex shrink-0 items-center justify-end gap-2 px-3"
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>
@ -664,7 +713,6 @@ function TrackLabelsPanel({
}
function TimelineTrackRows({
dragElementId,
mainTrackId,
zoomLevel,
dragState,
@ -680,7 +728,6 @@ function TimelineTrackRows({
isDragOver,
dropTarget,
}: {
dragElementId: string | null;
mainTrackId: string | null;
zoomLevel: number;
dragState: ElementDragState;
@ -715,24 +762,34 @@ function TimelineTrackRows({
[selectedElements],
);
const sortedTracks = useMemo(
() =>
[...tracks]
.map((track, index) => ({ track, index }))
.sort((a, b) => {
const aHasDragged = a.track.elements.some(
(el) => el.id === dragElementId,
);
const bHasDragged = b.track.elements.some(
(el) => el.id === dragElementId,
);
if (aHasDragged) return 1;
if (bHasDragged) return -1;
return 0;
}),
[tracks, dragElementId],
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],
);
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 (
<>
{sortedTracks.map(({ track, index }) => (
@ -745,8 +802,8 @@ function TimelineTrackRows({
SELECTED_TRACK_ROW_CLASS,
)}
style={{
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
height: `${getTrackHeight({ type: track.type })}px`,
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`,
}}
>
<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>
);
}

View File

@ -8,6 +8,10 @@ export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
effect: 25,
} 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_LABELS_COLUMN_WIDTH_PX = 112;
export const TIMELINE_RULER_HEIGHT_PX = 22;

View File

@ -3,13 +3,14 @@
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import { AudioWaveform } from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import { useElementPreview } from "@/hooks/use-element-preview";
import {
useKeyframeDrag,
type KeyframeDragState,
} from "@/hooks/timeline/element/use-keyframe-drag";
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 {
canElementHaveAudio,
@ -20,10 +21,7 @@ import {
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { getTrackHeight } from "./track-layout";
import {
getTimelineElementClassName,
TIMELINE_TRACK_THEME,
} from "./theme";
import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
import {
ContextMenu,
ContextMenuContent,
@ -68,19 +66,25 @@ import {
Search01Icon,
Exchange01Icon,
KeyframeIcon,
Link02Icon,
MagicWand05Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
import type { ComponentProps, ReactNode } from "react";
import { useMemo, type ComponentProps, type ReactNode } from "react";
import type {
SelectedKeyframeRef,
ElementKeyframe,
} from "@/lib/animation/types";
import { cn } from "@/utils/ui";
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 ELEMENT_RING_WIDTH_PX = 1.5;
@ -191,8 +195,12 @@ interface TimelineElementProps {
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onResizeStart: (params: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => void;
onElementMouseDown: (
event: React.MouseEvent,
element: TimelineElementType,
@ -210,8 +218,7 @@ export function TimelineElement({
track,
zoomLevel,
isSelected,
onSnapPointChange,
onResizeStateChange,
onResizeStart,
onElementMouseDown,
onElementClick,
dragState,
@ -220,6 +227,11 @@ export function TimelineElement({
const mediaAssets = useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
const { renderElement } = useElementPreview({
trackId: track.id,
elementId: element.id,
fallback: element,
});
let mediaAsset: MediaAsset | null = null;
@ -230,31 +242,23 @@ export function TimelineElement({
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
useTimelineElementResize({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
});
const isCurrentElementSelected = selectedElements.some(
(selected) =>
selected.elementId === element.id && selected.trackId === track.id,
);
const isBeingDragged = dragState.elementId === element.id;
const isBeingDragged = dragState.dragElementIds.includes(element.id);
const dragOffsetY =
isBeingDragged && dragState.isDragging
? dragState.currentMouseY - dragState.startMouseY
: 0;
const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
const displayedDuration = isResizing ? currentDuration : element.duration;
? dragState.currentTime + dragTimeOffset
: renderElement.startTime;
const displayedStartTime = elementStartTime;
const displayedDuration = renderElement.duration;
const elementWidth = timelineTimeToPixels({
time: displayedDuration,
zoomLevel,
@ -281,6 +285,41 @@ export function TimelineElement({
handleKeyframeClick,
getVisualOffsetPx,
} = 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 }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@ -299,15 +338,44 @@ export function TimelineElement({
: "Extract audio";
const isElementSourceAudioSeparated =
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 (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="absolute top-0 h-full select-none"
className="absolute top-0 select-none"
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
height:
expandedRows.length > 0
? `${baseTrackHeight + expansionHeight}px`
: "100%",
transform:
isBeingDragged && dragState.isDragging
? `translate3d(0, ${dragOffsetY}px, 0)`
@ -318,13 +386,19 @@ export function TimelineElement({
element={element}
track={track}
isSelected={isSelected}
isExpanded={expandedRows.length > 0}
baseTrackHeight={baseTrackHeight}
expandedContent={expandedContent}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
onResizeStart={onResizeStart}
isDropTarget={isDropTarget}
/>
{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
indicators={keyframeIndicators}
dragState={keyframeDragState}
@ -346,6 +420,14 @@ export function TimelineElement({
Split
</ActionMenuItem>
<CopyMenuItem />
{selectedElements.length === 1 && (
<ActionMenuItem
action="duplicate-selected"
icon={<HugeiconsIcon icon={Copy01Icon} />}
>
Duplicate
</ActionMenuItem>
)}
{canElementHaveAudio(element) && hasAudio && (
<MuteMenuItem
isMultipleSelected={selectedElements.length > 1}
@ -355,11 +437,11 @@ export function TimelineElement({
)}
{canToggleCurrentSourceAudio && (
<ContextMenuItem
icon={
<HugeiconsIcon
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
icon={
<HugeiconsIcon
icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
/>
}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction("toggle-source-audio");
@ -375,13 +457,16 @@ export function TimelineElement({
isCurrentElementSelected={isCurrentElementSelected}
/>
)}
{selectedElements.length === 1 && (
<ActionMenuItem
action="duplicate-selected"
icon={<HugeiconsIcon icon={Copy01Icon} />}
{hasKeyframes && (
<ContextMenuItem
icon={<HugeiconsIcon icon={KeyframeIcon} />}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
toggleElementExpanded(element.id);
}}
>
Duplicate
</ActionMenuItem>
{isExpanded ? "Collapse keyframes" : "Expand keyframes"}
</ContextMenuItem>
)}
{selectedElements.length === 1 && hasMediaId(element) && (
<>
@ -417,14 +502,20 @@ function ElementInner({
element,
track,
isSelected,
isExpanded,
baseTrackHeight,
expandedContent,
onElementClick,
onElementMouseDown,
handleResizeStart,
onResizeStart,
isDropTarget = false,
}: {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
isExpanded: boolean;
baseTrackHeight: number;
expandedContent: React.ReactNode;
onElementClick: (
event: React.MouseEvent,
element: TimelineElementType,
@ -433,9 +524,10 @@ function ElementInner({
event: React.MouseEvent,
element: TimelineElementType,
) => void;
handleResizeStart: (params: {
onResizeStart: (params: {
event: React.MouseEvent;
elementId: string;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => void;
isDropTarget?: boolean;
@ -463,8 +555,7 @@ function ElementInner({
<div
className={cn(
"absolute inset-0 overflow-hidden rounded-sm",
getTimelineElementClassName({ type: track.type }),
isReducedOpacity && "opacity-50",
isExpanded && "bg-background",
)}
>
<button
@ -474,9 +565,23 @@ function ElementInner({
onClick={(event) => onElementClick(event, element)}
onMouseDown={(event) => onElementMouseDown(event, element)}
>
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
<ElementContent element={element} track={track} />
<div
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>
{expandedContent}
</button>
</div>
</div>
@ -485,13 +590,15 @@ function ElementInner({
<>
<ResizeHandle
side="left"
elementId={element.id}
handleResizeStart={handleResizeStart}
element={element}
track={track}
onResizeStart={onResizeStart}
/>
<ResizeHandle
side="right"
elementId={element.id}
handleResizeStart={handleResizeStart}
element={element}
track={track}
onResizeStart={onResizeStart}
/>
</>
)}
@ -501,14 +608,17 @@ function ElementInner({
function ResizeHandle({
side,
elementId,
handleResizeStart,
element,
track,
onResizeStart,
}: {
side: "left" | "right";
elementId: string;
handleResizeStart: (params: {
element: TimelineElementType;
track: TimelineTrack;
onResizeStart: (params: {
event: React.MouseEvent;
elementId: string;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => void;
}) {
@ -520,7 +630,7 @@ function ResizeHandle({
"absolute top-0 bottom-0 w-2",
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()}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
></button>
@ -582,7 +692,7 @@ function KeyframeIndicators({
<button
key={indicator.time}
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 }}
onMouseDown={(event) =>
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 {
element: TimelineElementType;
track: TimelineTrack;

View File

@ -1,5 +1,6 @@
import type { TrackType } from "@/lib/timeline";
import {
KEYFRAME_LANE_HEIGHT_PX,
TIMELINE_TRACK_GAP_PX,
TIMELINE_TRACK_HEIGHTS_PX,
} from "./layout";
@ -8,28 +9,50 @@ export function getTrackHeight({ type }: { type: TrackType }): number {
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({
tracks,
trackIndex,
getExtraHeight,
}: {
tracks: Array<{ type: TrackType }>;
trackIndex: number;
getExtraHeight?: (trackIndex: number) => number;
}): number {
return tracks
.slice(0, trackIndex)
.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,
);
}
export function getTotalTracksHeight({
tracks,
getExtraHeight,
}: {
tracks: Array<{ type: TrackType }>;
getExtraHeight?: (trackIndex: number) => number;
}): number {
const tracksHeight = tracks.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }),
(sum, track, i) =>
sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0),
0,
);
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;

View File

@ -41,6 +41,8 @@ const MOUSE_BUTTON_RIGHT = 2;
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
dragElementIds: [],
dragTimeOffsets: {},
trackId: null,
startMouseX: 0,
startMouseY: 0,
@ -202,6 +204,8 @@ export function useElementInteraction({
setDragState({
isDragging: true,
elementId,
dragElementIds: elementId ? [elementId] : [],
dragTimeOffsets: {},
trackId,
startMouseX,
startMouseY,
@ -521,6 +525,7 @@ export function useElementInteraction({
}, [
dragState.isDragging,
dragState.elementId,
dragState.startElementTime,
dragState.startMouseY,
dragState.trackId,
dragState.currentTime,

View File

@ -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,
};
}

View File

@ -1,229 +1,229 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/lib/animation/types";
function getSelectedKeyframeId({
keyframe,
}: {
keyframe: SelectedKeyframeRef;
}): string {
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
}
function mergeUniqueKeyframes({
keyframes,
}: {
keyframes: SelectedKeyframeRef[];
}): SelectedKeyframeRef[] {
const keyframesById = new Map<string, SelectedKeyframeRef>();
for (const keyframe of keyframes) {
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
}
return [...keyframesById.values()];
}
export function useKeyframeSelection() {
const editor = useEditor();
const selectedKeyframes = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedKeyframes(),
);
const keyframeSelectionAnchor = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getKeyframeSelectionAnchor(),
);
const isKeyframeSelected = useCallback(
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
const keyframeId = getSelectedKeyframeId({ keyframe });
return selectedKeyframes.some(
(selectedKeyframe) =>
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
);
},
[selectedKeyframes],
);
const setKeyframeSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
editor.selection.setSelectedKeyframes({
keyframes: uniqueKeyframes,
anchorKeyframe:
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
});
},
[editor],
);
const addKeyframesToSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const mergedKeyframes = mergeUniqueKeyframes({
keyframes: [...selectedKeyframes, ...keyframes],
});
editor.selection.setSelectedKeyframes({
keyframes: mergedKeyframes,
anchorKeyframe:
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const removeKeyframesFromSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const keyframeIdsToRemove = new Set(
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const nextKeyframes = selectedKeyframes.filter(
(selectedKeyframe) =>
!keyframeIdsToRemove.has(
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
),
);
editor.selection.setSelectedKeyframes({
keyframes: nextKeyframes,
anchorKeyframe:
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const clearKeyframeSelection = useCallback(() => {
editor.selection.clearKeyframeSelection();
}, [editor]);
const toggleKeyframeSelection = useCallback(
({
keyframes,
isMultiKey,
}: {
keyframes: SelectedKeyframeRef[];
isMultiKey: boolean;
}) => {
const anchorKeyframe = keyframes[0];
const areAllKeyframesSelected = keyframes.every((keyframe) =>
isKeyframeSelected({ keyframe }),
);
if (!isMultiKey) {
setKeyframeSelection({ keyframes, anchorKeyframe });
return;
}
if (areAllKeyframesSelected) {
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
return;
}
addKeyframesToSelection({ keyframes, anchorKeyframe });
},
[
setKeyframeSelection,
isKeyframeSelected,
removeKeyframesFromSelection,
addKeyframesToSelection,
],
);
const selectKeyframeRange = useCallback(
({
orderedKeyframes,
targetKeyframes,
isAdditive,
}: {
orderedKeyframes: SelectedKeyframeRef[];
targetKeyframes: SelectedKeyframeRef[];
isAdditive: boolean;
}) => {
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
return;
}
const anchorKeyframe =
keyframeSelectionAnchor ??
selectedKeyframes[selectedKeyframes.length - 1] ??
targetKeyframes[0];
if (!anchorKeyframe) {
return;
}
const targetKeyframeIds = new Set(
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
const anchorIndex = orderedKeyframes.findIndex(
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
);
if (anchorIndex === -1) {
if (isAdditive) {
addKeyframesToSelection({
keyframes: targetKeyframes,
anchorKeyframe,
});
return;
}
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
return;
}
const targetIndexes = orderedKeyframes
.map((keyframe, index) => ({
keyframeId: getSelectedKeyframeId({ keyframe }),
index,
}))
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
.map(({ index }) => index);
if (targetIndexes.length === 0) {
return;
}
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
if (isAdditive) {
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
return;
}
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
},
[
keyframeSelectionAnchor,
selectedKeyframes,
addKeyframesToSelection,
setKeyframeSelection,
],
);
return {
selectedKeyframes,
keyframeSelectionAnchor,
isKeyframeSelected,
setKeyframeSelection,
addKeyframesToSelection,
removeKeyframesFromSelection,
clearKeyframeSelection,
toggleKeyframeSelection,
selectKeyframeRange,
};
}
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/lib/animation/types";
export function getSelectedKeyframeId({
keyframe,
}: {
keyframe: SelectedKeyframeRef;
}): string {
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
}
function mergeUniqueKeyframes({
keyframes,
}: {
keyframes: SelectedKeyframeRef[];
}): SelectedKeyframeRef[] {
const keyframesById = new Map<string, SelectedKeyframeRef>();
for (const keyframe of keyframes) {
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
}
return [...keyframesById.values()];
}
export function useKeyframeSelection() {
const editor = useEditor();
const selectedKeyframes = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedKeyframes(),
);
const keyframeSelectionAnchor = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getKeyframeSelectionAnchor(),
);
const isKeyframeSelected = useCallback(
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
const keyframeId = getSelectedKeyframeId({ keyframe });
return selectedKeyframes.some(
(selectedKeyframe) =>
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
);
},
[selectedKeyframes],
);
const setKeyframeSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
editor.selection.setSelectedKeyframes({
keyframes: uniqueKeyframes,
anchorKeyframe:
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
});
},
[editor],
);
const addKeyframesToSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const mergedKeyframes = mergeUniqueKeyframes({
keyframes: [...selectedKeyframes, ...keyframes],
});
editor.selection.setSelectedKeyframes({
keyframes: mergedKeyframes,
anchorKeyframe:
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const removeKeyframesFromSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const keyframeIdsToRemove = new Set(
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const nextKeyframes = selectedKeyframes.filter(
(selectedKeyframe) =>
!keyframeIdsToRemove.has(
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
),
);
editor.selection.setSelectedKeyframes({
keyframes: nextKeyframes,
anchorKeyframe:
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const clearKeyframeSelection = useCallback(() => {
editor.selection.clearKeyframeSelection();
}, [editor]);
const toggleKeyframeSelection = useCallback(
({
keyframes,
isMultiKey,
}: {
keyframes: SelectedKeyframeRef[];
isMultiKey: boolean;
}) => {
const anchorKeyframe = keyframes[0];
const areAllKeyframesSelected = keyframes.every((keyframe) =>
isKeyframeSelected({ keyframe }),
);
if (!isMultiKey) {
setKeyframeSelection({ keyframes, anchorKeyframe });
return;
}
if (areAllKeyframesSelected) {
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
return;
}
addKeyframesToSelection({ keyframes, anchorKeyframe });
},
[
setKeyframeSelection,
isKeyframeSelected,
removeKeyframesFromSelection,
addKeyframesToSelection,
],
);
const selectKeyframeRange = useCallback(
({
orderedKeyframes,
targetKeyframes,
isAdditive,
}: {
orderedKeyframes: SelectedKeyframeRef[];
targetKeyframes: SelectedKeyframeRef[];
isAdditive: boolean;
}) => {
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
return;
}
const anchorKeyframe =
keyframeSelectionAnchor ??
selectedKeyframes[selectedKeyframes.length - 1] ??
targetKeyframes[0];
if (!anchorKeyframe) {
return;
}
const targetKeyframeIds = new Set(
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
const anchorIndex = orderedKeyframes.findIndex(
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
);
if (anchorIndex === -1) {
if (isAdditive) {
addKeyframesToSelection({
keyframes: targetKeyframes,
anchorKeyframe,
});
return;
}
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
return;
}
const targetIndexes = orderedKeyframes
.map((keyframe, index) => ({
keyframeId: getSelectedKeyframeId({ keyframe }),
index,
}))
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
.map(({ index }) => index);
if (targetIndexes.length === 0) {
return;
}
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
if (isAdditive) {
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
return;
}
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
},
[
keyframeSelectionAnchor,
selectedKeyframes,
addKeyframesToSelection,
setKeyframeSelection,
],
);
return {
selectedKeyframes,
keyframeSelectionAnchor,
isKeyframeSelected,
setKeyframeSelection,
addKeyframesToSelection,
removeKeyframesFromSelection,
clearKeyframeSelection,
toggleKeyframeSelection,
selectKeyframeRange,
};
}