diff --git a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts b/apps/web/src/components/editor/panels/timeline/expanded-layout.ts new file mode 100644 index 00000000..6b9734a7 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/expanded-layout.ts @@ -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> = { + "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; +}): 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; +}): 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; +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts index 15657bfb..663b4b5a 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -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, }; } diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts index d0303b6f..8baa3c43 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -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; }, diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 883011d1..ae9e19c9 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -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} />
{tracks.length > 0 && ( - ; trackLabelsScrollRef: React.RefObject; 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 (
- {tracks.map((track) => ( -
-
- {canTrackHaveAudio(track) && ( - - editor.timeline.toggleTrackMute({ trackId: track.id }) - } - /> + {tracks.map((track, index) => { + const expandedRows = trackExpandedRowsMap[index]; + const baseHeight = getTrackHeight({ type: track.type }); + + return ( +
- editor.timeline.toggleTrackVisibility({ - trackId: track.id, - }) - } - /> + style={{ + height: `${baseHeight + getTrackExpansionHeight(index)}px`, + }} + > +
+ {canTrackHaveAudio(track) && ( + + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + +
+ {expandedRows.length > 0 && ( + )} -
-
- ))} + ); + })}
)}
@@ -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`, }} > ); } + +function PropertyTree({ rows }: { rows: ExpandedRow[] }) { + return ( +
+ {rows.map((row, index) => ( +
+ + {getPropertyLabel(row.propertyPath)} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/timeline/layout.ts b/apps/web/src/components/editor/panels/timeline/layout.ts index 88443797..c30b7877 100644 --- a/apps/web/src/components/editor/panels/timeline/layout.ts +++ b/apps/web/src/components/editor/panels/timeline/layout.ts @@ -8,6 +8,10 @@ export const TIMELINE_TRACK_HEIGHTS_PX: Record = { 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; diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 1d3606e6..e47bcba0 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -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 ? ( + + ) : null; return (
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 && ( -
+
+ {selectedElements.length === 1 && ( + } + > + Duplicate + + )} {canElementHaveAudio(element) && hasAudio && ( 1} @@ -355,11 +437,11 @@ export function TimelineElement({ )} {canToggleCurrentSourceAudio && ( - } + icon={ + + } onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction("toggle-source-audio"); @@ -375,13 +457,16 @@ export function TimelineElement({ isCurrentElementSelected={isCurrentElementSelected} /> )} - {selectedElements.length === 1 && ( - } + {hasKeyframes && ( + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + toggleElementExpanded(element.id); + }} > - Duplicate - + {isExpanded ? "Collapse keyframes" : "Expand keyframes"} + )} {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({
@@ -485,13 +590,15 @@ function ElementInner({ <> )} @@ -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`} > @@ -582,7 +692,7 @@ function KeyframeIndicators({ + ); + })} +
+ ); + })} + {selectionBox && ( + + )} +
+ ); +} + interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; diff --git a/apps/web/src/components/editor/panels/timeline/track-layout.ts b/apps/web/src/components/editor/panels/timeline/track-layout.ts index 89cc4d05..8f833a57 100644 --- a/apps/web/src/components/editor/panels/timeline/track-layout.ts +++ b/apps/web/src/components/editor/panels/timeline/track-layout.ts @@ -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; diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index efaacb6a..beac1879 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -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, diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts new file mode 100644 index 00000000..0fc46372 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts @@ -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(null); + const initialKeyframesRef = useRef([]); + + 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(); + 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({ + 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, + }; +} diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts index 1a219d1f..2b5a0566 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts @@ -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(); - 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(); + 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, + }; +}