diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:41:10 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:08:29 +0200 |
| commit | 36efdf66e707c818b81009acf8a00630a0309ca1 (patch) | |
| tree | d3eb1a97429ab1554d05450d5c2242517054ee6e /apps/web/src/components/editor/panels/timeline/index.tsx | |
| parent | 2272598d420cf078aaa3928246bfa3a414f6eddb (diff) | |
| download | opencut-36efdf66e707c818b81009acf8a00630a0309ca1.tar.gz | |
feat: expandable keyframe lanes in timeline
Made-with: Cursor
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/index.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/index.tsx | 193 |
1 files changed, 135 insertions, 58 deletions
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} /> <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> + ); +} |
