summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/index.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-13 04:41:10 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-13 05:08:29 +0200
commit36efdf66e707c818b81009acf8a00630a0309ca1 (patch)
treed3eb1a97429ab1554d05450d5c2242517054ee6e /apps/web/src/components/editor/panels/timeline/index.tsx
parent2272598d420cf078aaa3928246bfa3a414f6eddb (diff)
downloadopencut-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.tsx193
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>
+ );
+}