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/timeline-element.tsx | 399 ++++++++++++++++++--- 1 file changed, 340 insertions(+), 59 deletions(-) (limited to 'apps/web/src/components/editor/panels/timeline/timeline-element.tsx') 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; -- cgit v1.3.1