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 --- .../editor/panels/timeline/expanded-layout.ts | 117 ++++++ .../editor/panels/timeline/graph-editor/session.ts | 30 +- .../panels/timeline/graph-editor/use-controller.ts | 34 +- .../components/editor/panels/timeline/index.tsx | 193 +++++++--- .../components/editor/panels/timeline/layout.ts | 4 + .../editor/panels/timeline/timeline-element.tsx | 399 ++++++++++++++++++--- .../editor/panels/timeline/track-layout.ts | 27 +- 7 files changed, 663 insertions(+), 141 deletions(-) create mode 100644 apps/web/src/components/editor/panels/timeline/expanded-layout.ts (limited to 'apps/web/src/components') 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; -- cgit v1.3.1