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 | |
| 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')
7 files changed, 663 insertions, 141 deletions
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<Record<string, string>> = { + "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<string>; +}): 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<string>; +}): 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} /> <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> + ); +} 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<TrackType, number> = { 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 ? (
+ <ExpandedKeyframeLanes
+ rows={expandedRows}
+ keyframes={elementKeyframes}
+ trackId={track.id}
+ elementId={element.id}
+ displayedStartTime={displayedStartTime}
+ zoomLevel={zoomLevel}
+ elementLeft={elementLeft}
+ keyframeDragState={keyframeDragState}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ containerRef={expandedLanesRef}
+ onLaneMouseDown={handleExpandedAreaMouseDown}
+ onLaneClick={handleExpandedAreaClick}
+ selectionBox={keyframeSelectionBox}
+ isBoxSelecting={isKeyframeBoxSelecting}
+ />
+ ) : null;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
- className="absolute top-0 h-full select-none"
+ className="absolute top-0 select-none"
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
+ height:
+ expandedRows.length > 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 && (
- <div className="pointer-events-none absolute inset-0 overflow-hidden">
+ <div
+ className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
+ style={{ height: `${baseTrackHeight}px` }}
+ >
<KeyframeIndicators
indicators={keyframeIndicators}
dragState={keyframeDragState}
@@ -346,6 +420,14 @@ export function TimelineElement({ Split
</ActionMenuItem>
<CopyMenuItem />
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
{canElementHaveAudio(element) && hasAudio && (
<MuteMenuItem
isMultipleSelected={selectedElements.length > 1}
@@ -355,11 +437,11 @@ export function TimelineElement({ )}
{canToggleCurrentSourceAudio && (
<ContextMenuItem
- icon={
- <HugeiconsIcon
- icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
- />
- }
+ icon={
+ <HugeiconsIcon
+ icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
+ />
+ }
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction("toggle-source-audio");
@@ -375,13 +457,16 @@ export function TimelineElement({ isCurrentElementSelected={isCurrentElementSelected}
/>
)}
- {selectedElements.length === 1 && (
- <ActionMenuItem
- action="duplicate-selected"
- icon={<HugeiconsIcon icon={Copy01Icon} />}
+ {hasKeyframes && (
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={KeyframeIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ toggleElementExpanded(element.id);
+ }}
>
- Duplicate
- </ActionMenuItem>
+ {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
+ </ContextMenuItem>
)}
{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({ <div
className={cn(
"absolute inset-0 overflow-hidden rounded-sm",
- getTimelineElementClassName({ type: track.type }),
- isReducedOpacity && "opacity-50",
+ isExpanded && "bg-background",
)}
>
<button
@@ -474,9 +565,23 @@ function ElementInner({ onClick={(event) => onElementClick(event, element)}
onMouseDown={(event) => onElementMouseDown(event, element)}
>
- <div className="flex flex-1 min-h-0 items-center overflow-hidden">
- <ElementContent element={element} track={track} />
+ <div
+ className={cn(
+ "flex shrink-0 items-center overflow-hidden",
+ getTimelineElementClassName({
+ type: getTrackTypeForElementType({
+ elementType: element.type,
+ }),
+ }),
+ isReducedOpacity && "opacity-50",
+ )}
+ style={{ height: `${baseTrackHeight}px` }}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent element={element} track={track} />
+ </div>
</div>
+ {expandedContent}
</button>
</div>
</div>
@@ -485,13 +590,15 @@ function ElementInner({ <>
<ResizeHandle
side="left"
- elementId={element.id}
- handleResizeStart={handleResizeStart}
+ element={element}
+ track={track}
+ onResizeStart={onResizeStart}
/>
<ResizeHandle
side="right"
- elementId={element.id}
- handleResizeStart={handleResizeStart}
+ element={element}
+ track={track}
+ onResizeStart={onResizeStart}
/>
</>
)}
@@ -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`}
></button>
@@ -582,7 +692,7 @@ function KeyframeIndicators({ <button
key={indicator.time}
type="button"
- className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
+ className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5"
style={{ left: visualOffsetPx }}
onMouseDown={(event) =>
onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
@@ -610,6 +720,177 @@ function KeyframeIndicators({ });
}
+function ExpandedKeyframeLanes({
+ rows,
+ keyframes,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ keyframeDragState,
+ onKeyframeMouseDown,
+ onKeyframeClick,
+ getVisualOffsetPx,
+ containerRef,
+ onLaneMouseDown,
+ onLaneClick,
+ selectionBox,
+ isBoxSelecting,
+}: {
+ rows: ExpandedRow[];
+ keyframes: ElementKeyframe[];
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+ keyframeDragState: KeyframeDragState;
+ onKeyframeMouseDown: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => void;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+ onLaneMouseDown: (event: React.MouseEvent) => void;
+ onLaneClick: (event: React.MouseEvent) => void;
+ selectionBox: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ isActive: boolean;
+ } | null;
+ isBoxSelecting: boolean;
+ onKeyframeClick: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => void;
+ getVisualOffsetPx: (params: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }) => number;
+}) {
+ const { isKeyframeSelected } = useKeyframeSelection();
+
+ const orderedKeyframes = useMemo(
+ () =>
+ [...keyframes]
+ .sort(
+ (a, b) =>
+ a.time - b.time ||
+ a.propertyPath.localeCompare(b.propertyPath),
+ )
+ .map((kf) => ({
+ trackId,
+ elementId,
+ propertyPath: kf.propertyPath,
+ keyframeId: kf.id,
+ })),
+ [keyframes, trackId, elementId],
+ );
+
+ return (
+ <div
+ ref={containerRef}
+ className="relative flex flex-col"
+ onMouseDown={onLaneMouseDown}
+ onClick={onLaneClick}
+ >
+ {rows.map((row) => {
+ const laneKeyframes = keyframes.filter(
+ (kf) => kf.propertyPath === row.propertyPath,
+ );
+ return (
+ <div
+ key={row.propertyPath}
+ className={cn(
+ "relative flex items-center bg-muted/50",
+ )}
+ style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
+ >
+ {laneKeyframes.map((kf) => {
+ const keyframeRef: SelectedKeyframeRef = {
+ trackId,
+ elementId,
+ propertyPath: row.propertyPath,
+ keyframeId: kf.id,
+ };
+ const isBeingDragged =
+ keyframeDragState.draggingKeyframeIds.has(kf.id);
+ const kfLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime + kf.time,
+ zoomLevel,
+ });
+ const offsetPx = kfLeft - elementLeft;
+ const visualOffset = getVisualOffsetPx({
+ indicatorTime: kf.time,
+ indicatorOffsetPx: offsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ });
+ const isSelected = isKeyframeSelected({
+ keyframe: keyframeRef,
+ });
+
+ return (
+ <button
+ key={kf.id}
+ type="button"
+ className={cn(
+ "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
+ isBoxSelecting && "pointer-events-none",
+ )}
+ style={{ left: visualOffset }}
+ onMouseDown={(event) => {
+ event.stopPropagation();
+ onKeyframeMouseDown({
+ event,
+ keyframes: [keyframeRef],
+ });
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ onKeyframeClick({
+ event,
+ keyframes: [keyframeRef],
+ orderedKeyframes,
+ indicatorTime: kf.time,
+ });
+ }}
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 text-black mr-1",
+ isSelected
+ ? "fill-primary"
+ : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ })}
+ </div>
+ );
+ })}
+ {selectionBox && (
+ <SelectionBox
+ startPos={selectionBox.startPos}
+ currentPos={selectionBox.currentPos}
+ containerRef={containerRef}
+ isActive={selectionBox.isActive}
+ />
+ )}
+ </div>
+ );
+}
+
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; |
