From 36efdf66e707c818b81009acf8a00630a0309ca1 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 13 Apr 2026 04:41:10 +0200 Subject: feat: expandable keyframe lanes in timeline Made-with: Cursor --- .../components/editor/panels/timeline/index.tsx | 193 ++++++++++++++------- 1 file changed, 135 insertions(+), 58 deletions(-) (limited to 'apps/web/src/components/editor/panels/timeline/index.tsx') 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)} + +
+ ))} +
+ ); +} -- cgit v1.3.1