From f385e111f00626fdf4538867984132037680cbbd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 16 Jul 2026 17:48:44 -0400 Subject: [PATCH] feat(studio): wire expanded keyframe timeline lanes --- packages/studio/src/App.tsx | 2 + .../studio/src/components/EditorShell.tsx | 3 + .../src/components/nle/PreviewOverlays.tsx | 4 +- .../studio/src/player/components/Timeline.tsx | 126 +++++---- .../src/player/components/TimelineCanvas.tsx | 49 ++-- .../src/player/components/TimelineClip.tsx | 4 +- .../player/components/TimelineDragGhost.tsx | 59 ----- .../src/player/components/TimelineLanes.tsx | 242 ++++++++++++------ .../player/components/TimelineOverlays.tsx | 6 +- .../components/TimelinePropertyLanes.tsx | 2 +- .../src/player/components/TimelineRuler.tsx | 32 +-- .../src/player/components/timelineDragDrop.ts | 11 +- .../player/components/timelineLayout.test.ts | 13 +- .../src/player/components/timelineLayout.ts | 70 +++-- .../src/player/components/timelineMarquee.ts | 13 +- .../components/useAutoExpandKeyframedClips.ts | 27 ++ .../player/components/useTimelineGeometry.ts | 5 +- .../player/components/useTimelinePlayhead.ts | 29 ++- .../components/useTimelineRangeSelection.ts | 35 ++- .../components/useTimelineTrackLayout.test.ts | 53 ++++ .../components/useTimelineTrackLayout.ts | 161 ++++++++++++ 21 files changed, 651 insertions(+), 295 deletions(-) delete mode 100644 packages/studio/src/player/components/TimelineDragGhost.tsx create mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.ts create mode 100644 packages/studio/src/player/components/useTimelineTrackLayout.test.ts create mode 100644 packages/studio/src/player/components/useTimelineTrackLayout.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 70b94d4bee..3cd9144687 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -400,6 +400,7 @@ export function StudioApp() { designPanelActive, inspectorPanelActive, inspectorButtonActive, + shouldShowMotionPath, shouldShowSelectedDomBounds, } = useInspectorState( panelLayout.rightPanelTab, @@ -554,6 +555,7 @@ export function StudioApp() { handleRazorSplitAll={timelineEditing.handleRazorSplitAll} setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} + shouldShowMotionPath={shouldShowMotionPath} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} isGestureRecording={gestureState === "recording"} recordingState={gestureState} diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index a5d42e732c..e44524f0c0 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -52,6 +52,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps { ) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; + shouldShowMotionPath: boolean; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; isGestureRecording?: boolean; @@ -85,6 +86,7 @@ export function EditorShell({ handleRazorSplitAll, setCompIdToSrc, setCompositionLoading, + shouldShowMotionPath, shouldShowSelectedDomBounds, isGestureRecording, recordingState, @@ -143,6 +145,7 @@ export function EditorShell({ onDeleteElement={handleTimelineElementDelete} previewOverlay={ diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 2e215381f6..583f98e17d 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -9,7 +9,6 @@ import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; -import { getTrackStyle } from "./timelineIcons"; import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; @@ -17,15 +16,28 @@ import { TimelineCanvas } from "./TimelineCanvas"; import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { TimelineOverlays } from "./TimelineOverlays"; +import { animationContributesLane } from "./TimelinePropertyLanes"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; -import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; -import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout"; +import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; +import { + GUTTER, + LABEL_COL_W, + generateTicks, + getTimelineContentXFromClient, +} from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; +import { + getTrackStyle, + useTimelineDisplayLayout, + useTimelineTrackLayout, +} from "./useTimelineTrackLayout"; +import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; @@ -99,6 +111,22 @@ export const Timeline = memo(function Timeline({ const timelineReady = usePlayerStore((s) => s.timelineReady); const selectedElementId = usePlayerStore((s) => s.selectedElementId); const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); + const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); + // Label mode = comp has keyframed clips (not just when expanded): keeps the layer + // disclosure + property column visible and reserves a GUTTER before 0s (Figma). + const hasKeyframedClips = useMemo( + () => + Array.from(gsapAnimations.values()).some((list) => + // Same lane-contribution predicate the layout uses: real keyframes OR a + // synthesizable flat tween. Checking animation.keyframes alone left a + // flat-tween-only comp without its reserved label column. + list.some((animation) => animationContributesLane(animation)), + ), + [gsapAnimations], + ); + const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips; + const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER; + const contentGutter = labelMode ? GUTTER : 0; const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const currentTime = usePlayerStore((s) => s.currentTime); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); @@ -147,9 +175,10 @@ export const Timeline = memo(function Timeline({ return Number.isFinite(result) ? result : safeDur; }, [rawElements, duration]); - const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements); - const trackOrderRef = useRef(trackOrder); - trackOrderRef.current = trackOrder; + const keyframeCache = usePlayerStore((s) => s.keyframeCache); + useAutoExpandKeyframedClips(gsapAnimations); + const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } = + useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds); const expandedElementsRef = useRef(expandedElements); expandedElementsRef.current = expandedElements; @@ -214,6 +243,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -231,25 +261,31 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, + contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, onBlockDrop: pinnedOnBlockDrop, }); - const displayTrackOrder = useMemo(() => { - if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder; - return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b); - }, [draggedClip, trackOrder]); - - const totalH = getTimelineCanvasHeight(displayTrackOrder.length); + const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights); const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ timelineReady, expandedElements.length, - totalH, + displayLayout.totalH, ]); - const keyframeCache = usePlayerStore((s) => s.keyframeCache); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = + useTimelineKeyframeHandlers({ + expandedElements, + keyframeCache, + onSelectElement, + onSeek, + setSelectedElementId, + setKfContextMenu, + toggleSelectedKeyframe, + }); const selectedElement = useMemo( () => @@ -280,6 +316,7 @@ export const Timeline = memo(function Timeline({ isDragging, scrollRef, lastScrollLeftRef, + contentOrigin, }); const laneGapStrips = useTimelineGapHighlights({ @@ -312,6 +349,7 @@ export const Timeline = memo(function Timeline({ setZoomMode, setManualZoomPercent, onSeek, + contentOrigin, }); useTimelineActiveClips({ scrollRef, @@ -341,7 +379,9 @@ export const Timeline = memo(function Timeline({ setShowPopover, elementsRef: expandedElementsRef, trackOrderRef, + rowHeightsRef, onSelectElement, + contentOrigin, }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag @@ -424,10 +464,17 @@ export const Timeline = memo(function Timeline({ onDragLeave={() => clearDropPreview()} onDrop={handleAssetDrop} onPointerDown={(e) => { + // Let interactive controls (keyframe nav/toggle, caret, inputs) handle + // their own clicks — scrubbing here would preventDefault and eat them. + if ((e.target as HTMLElement).closest("button, input, select, a")) return; if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) { const rect = scrollRef.current.getBoundingClientRect(); - const x = - e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD; + const x = getTimelineContentXFromClient({ + clientX: e.clientX, + rectLeft: rect.left, + scrollLeft: scrollRef.current.scrollLeft, + contentOrigin, + }); const splitTime = Math.max(0, x / pps); onRazorSplitAll?.(splitTime); return; @@ -442,18 +489,22 @@ export const Timeline = memo(function Timeline({ major={major} minor={minor} pps={pps} + contentOrigin={contentOrigin} + contentGutter={contentGutter} trackContentWidth={displayContentWidth} - totalH={totalH} + totalH={displayLayout.totalH} effectiveDuration={effectiveDuration} majorTickInterval={majorTickInterval} rangeSelection={rangeSelection} marqueeRect={marqueeRect} laneGapStrips={laneGapStrips} theme={theme} - displayTrackOrder={displayTrackOrder} + displayTrackOrder={displayLayout.displayTrackOrder} + rowHeights={displayLayout.displayRowHeights} trackOrder={trackOrder} tracks={tracks} trackStyles={trackStyles} + laneCounts={laneCounts} selectedElementId={selectedElementId} selectedElementIds={selectedElementIds} hoveredClip={hoveredClip} @@ -479,43 +530,16 @@ export const Timeline = memo(function Timeline({ getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} keyframeCache={keyframeCache} + gsapAnimations={gsapAnimations} selectedKeyframes={selectedKeyframes} currentTime={currentTime} + onSeek={onSeek} beatAnalysis={adjustedBeatAnalysis} - onClickKeyframe={(el, pct) => { - usePlayerStore.getState().clearSelectedKeyframes(); - const elKey = el.key ?? el.id; - setSelectedElementId(elKey); - onSelectElement?.(el); - // Select the clicked diamond (matches shift-click); cleared above so this single-selects. - toggleSelectedKeyframe(`${elKey}:${pct}`); - const absTime = el.start + (pct / 100) * el.duration; - onSeek?.(absTime); - const kfData = keyframeCache?.get(elKey); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5); - usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null); - }} - onShiftClickKeyframe={(elId, pct) => { - toggleSelectedKeyframe(`${elId}:${pct}`); - }} + onSelectSegment={onSelectSegment} + onClickKeyframe={onClickKeyframe} + onShiftClickKeyframe={onShiftClickKeyframe} onMoveKeyframe={onMoveKeyframe} - onContextMenuKeyframe={(e, elId, pct) => { - const el = expandedElements.find((x) => (x.key ?? x.id) === elId); - if (el) { - setSelectedElementId(elId); - onSelectElement?.(el); - } - const kfData = keyframeCache.get(elId); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); - setKfContextMenu({ - x: e.clientX + 4, - y: e.clientY + 2, - elementId: elId, - percentage: pct, - tweenPercentage: kf?.tweenPercentage, - currentEase: kf?.ease ?? kfData?.ease, - }); - }} + onContextMenuKeyframe={onContextMenuKeyframe} onContextMenuClip={(e, el) => { e.preventDefault(); setSelectedElementId(el.key ?? el.id); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 462acc2885..181ca290b8 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -4,16 +4,15 @@ import { PlayheadIndicator } from "./PlayheadIndicator"; import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing"; import { getRenderedTimelineElement } from "./timelineTheme"; import { - GUTTER, - TRACK_H, RULER_H, CLIP_Y, TRACKS_TOP_PAD, TRACKS_BOTTOM_PAD, - TRACKS_LEFT_PAD, + TRACK_H, PLAYHEAD_HEAD_W, getTimelinePlayheadLeft, getTimelineRowTop, + getTimelineRowHeight, } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; import type { ResizingClipState } from "./useTimelineClipDrag"; @@ -45,8 +44,17 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps { export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) { const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props; - const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } = - useTimelineEditContextOptional(); + const draggedRowIndex = + draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; + const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights); + const { + onResizeElement, + onMoveElement, + onToggleTrackHidden, + onTogglePropertyGroupKeyframe, + onRazorSplit, + onRazorSplitAll, + } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); // Scroll a clip into view when the sidebar (asset card) requests a reveal. useTimelineRevealClip(scrollRef); @@ -63,8 +71,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas // The drag ghost follows the cursor freely (both axes) — CapCut-style. The // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. - const draggedRowIndex = - draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; // Live multi-selection drag: while a selected clip is dragged, ALL selected // clips move together as one rigid formation. The GRABBED clip is the free // ghost below; its co-selected "passengers" slide by the SAME group-clamped @@ -101,7 +107,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas return (
{/* Breathing room between the sticky ruler and the first track lane — the @@ -124,6 +131,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas draggedElement={draggedElement} multiDragPreview={multiDragPreview} onToggleTrackHidden={onToggleTrackHidden} + onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} onResizeElement={onResizeElement} onMoveElement={onMoveElement} onRazorSplit={onRazorSplit} @@ -148,8 +156,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas key={`gap-${strip.kind}-${strip.track}-${gap.start}`} className="pointer-events-none absolute" style={{ - top: getTimelineRowTop(rowIndex) + CLIP_Y, - left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps, + top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y, + left: props.contentOrigin + gap.start * props.pps, width: Math.max((gap.end - gap.start) * props.pps, 2), height: TRACK_H - CLIP_Y * 2, background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)", @@ -166,10 +174,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
@@ -280,8 +288,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas className="absolute pointer-events-none" style={{ left: - GUTTER + - TRACKS_LEFT_PAD + + props.contentOrigin + Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps, width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps, top: RULER_H, @@ -297,13 +304,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas {/* Playhead — hidden while dragging a beat so its guideline doesn't track the scrub and clutter the beat being moved. Explicit width + the half-head offset baked into getTimelinePlayheadLeft keep the - inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler + inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler ticks' center), instead of relying on shrink-wrap sizing. */}
- {}} - onHoverEnd={() => {}} - onResizeStart={() => {}} - onClick={() => {}} - onDoubleClick={() => {}} - > - {children} - -
- ); -} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 75069c1a0b..a09090900f 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,12 +1,16 @@ import { type ReactNode } from "react"; -import { Eye, EyeSlash } from "@phosphor-icons/react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; +import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { TimelineTrackHeader } from "./TimelineTrackHeader"; +import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import type { TimelineTheme } from "./timelineTheme"; -import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; +import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout"; import { usePlayerStore, type TimelineElement, @@ -21,9 +25,9 @@ import { import type { TrackVisualStyle } from "./timelineIcons"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; +import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; -import { Music } from "../../icons/SystemIcons"; import { renderClipChildren } from "./timelineClipChildren"; /** @@ -34,12 +38,16 @@ import { renderClipChildren } from "./timelineClipChildren"; */ export interface TimelineLaneBaseProps { pps: number; + contentOrigin: number; + contentGutter: number; trackContentWidth: number; theme: TimelineTheme; displayTrackOrder: number[]; + rowHeights: readonly number[]; trackOrder: number[]; tracks: [number, TimelineElement[]][]; trackStyles: Map; + laneCounts: ReadonlyMap; selectedElementId: string | null; selectedElementIds: Set; hoveredClip: string | null; @@ -69,15 +77,25 @@ export interface TimelineLaneBaseProps { getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; keyframeCache?: Map; + gsapAnimations: Map; selectedKeyframes: Set; currentTime: number; - onClickKeyframe?: (element: TimelineElement, percentage: number) => void; - onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onSeek?: (time: number) => void; + onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void; + onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void; + onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void; + onContextMenuKeyframe?: ( + e: React.MouseEvent, + elementId: string, + target: TimelineKeyframeTarget, + ) => void; onMoveKeyframe?: ( elementId: string, fromClipPercentage: number, toClipPercentage: number, + propertyGroup?: string, + tweenPercentage?: number, + animationId?: string, ) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; /** @@ -94,6 +112,7 @@ interface TimelineLanesProps extends TimelineLaneBaseProps { draggedElement: TimelineElement | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; @@ -102,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps { export function TimelineLanes({ pps, + contentOrigin, + contentGutter, trackContentWidth, theme, displayTrackOrder, + rowHeights, trackOrder, tracks, trackStyles, + laneCounts, selectedElementId, selectedElementIds, hoveredClip, @@ -132,8 +155,11 @@ export function TimelineLanes({ getPreviewElement, getTrackStyle, keyframeCache, + gsapAnimations, selectedKeyframes, currentTime, + onSeek, + onSelectSegment, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, @@ -142,11 +168,19 @@ export function TimelineLanes({ onContextMenuLane, beatAnalysis, onToggleTrackHidden, + onTogglePropertyGroupKeyframe, onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll, }: TimelineLanesProps) { + const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); + const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); + const toggleClipExpandedTracked = (key: string) => { + const willExpand = !expandedClipIds.has(key); + trackStudioKeyframeLaneExpand({ expanded: willExpand }); + toggleClipExpanded(key); + }; return ( <> { @@ -156,7 +190,8 @@ export function TimelineLanes({ // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap // in a virtualizer if editorial workflows ever push very high clip counts. // fallow-ignore-next-line complexity - displayTrackOrder.map((trackNum) => { + displayTrackOrder.map((trackNum, row) => { + const rowHeight = getTimelineRowHeight(row, rowHeights); const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = @@ -173,58 +208,54 @@ export function TimelineLanes({ : els.some(isMusicTrack)); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); + // The one keyframed element this track shows lanes for (selected, else + // most lanes). A track can hold several elements; scoping to one keeps + // their keyframes from cramming into a single row. + const keyframeClip = STUDIO_KEYFRAMES_ENABLED + ? resolveTrackKeyframeClip(els, laneCounts, selectedElementId, selectedElementIds) + : null; + const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; + const keyframeClipExpanded = + keyframeClipKey != null && expandedClipIds.has(keyframeClipKey); return ( -
-
+ { + if (keyframeClipKey) { + toggleClipExpandedTracked(keyframeClipKey); + } }} - > - {isAudioTrack && ( -
- {/* Left breathing pad — empty lane surface before t=0, scrolling - with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE - the time-mapped content div so clip/beat/menu math stays - content-relative (clip left = t·pps). */} -