From 6b45baf15a2af1066a73935bd46afac8afb9787e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 17:07:30 +0200 Subject: [PATCH 01/10] feat(studio): wire expanded keyframe timeline lanes --- packages/studio/src/App.tsx | 2 + .../studio/src/components/EditorShell.tsx | 3 + .../components/editor/MotionPathOverlay.tsx | 8 +- .../src/components/nle/PreviewOverlays.tsx | 4 +- .../components/KeyframeDiamondContextMenu.tsx | 41 +- .../src/player/components/Timeline.test.ts | 504 +++++++++++++++--- .../studio/src/player/components/Timeline.tsx | 107 ++-- .../src/player/components/TimelineCanvas.tsx | 49 +- .../src/player/components/TimelineClip.tsx | 4 +- .../player/components/TimelineDragGhost.tsx | 59 -- .../src/player/components/TimelineLanes.tsx | 230 +++++--- .../player/components/TimelineOverlays.tsx | 8 +- .../components/TimelineRazorInteraction.tsx | 60 +++ .../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 +- .../player/components/useTimelineGeometry.ts | 5 +- .../player/components/useTimelinePlayhead.ts | 29 +- .../components/useTimelineRangeSelection.ts | 35 +- .../components/useTimelineTrackLayout.test.ts | 53 ++ .../components/useTimelineTrackLayout.ts | 161 ++++++ 23 files changed, 1092 insertions(+), 409 deletions(-) delete mode 100644 packages/studio/src/player/components/TimelineDragGhost.tsx create mode 100644 packages/studio/src/player/components/TimelineRazorInteraction.tsx 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 b661872d67..7bdaafd5b1 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -395,6 +395,7 @@ export function StudioApp() { designPanelActive, inspectorPanelActive, inspectorButtonActive, + shouldShowMotionPath, shouldShowSelectedDomBounds, } = useInspectorState( panelLayout.rightPanelTab, @@ -552,6 +553,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 a5724c12e0..fbf0688713 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -56,6 +56,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; @@ -90,6 +91,7 @@ export function EditorShell({ handleRazorSplitAll, setCompIdToSrc, setCompositionLoading, + shouldShowMotionPath, shouldShowSelectedDomBounds, isGestureRecording, recordingState, @@ -149,6 +151,7 @@ export function EditorShell({ onDeleteElement={handleTimelineElementDelete} previewOverlay={ setKfMenu(null)} - onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)} + onDelete={(_elId, keyframe) => + animId && handleGsapRemoveKeyframe(animId, keyframe.percentage) + } onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)} - onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)} + onMoveToPlayhead={(_element, keyframe) => + animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage) + } /> )} diff --git a/packages/studio/src/components/nle/PreviewOverlays.tsx b/packages/studio/src/components/nle/PreviewOverlays.tsx index 2eeb4f6294..77781d9939 100644 --- a/packages/studio/src/components/nle/PreviewOverlays.tsx +++ b/packages/studio/src/components/nle/PreviewOverlays.tsx @@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl"; import type { ReactNode } from "react"; export interface PreviewOverlaysProps { + shouldShowMotionPath: boolean; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; isGestureRecording?: boolean; @@ -132,6 +133,7 @@ export function resolveZIndexEntries( // fallow-ignore-next-line complexity export function PreviewOverlays({ + shouldShowMotionPath, shouldShowSelectedDomBounds, blockPreview, isGestureRecording, @@ -274,7 +276,7 @@ export function PreviewOverlays({ {STUDIO_KEYFRAMES_ENABLED && ( diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 1e8275511b..8bf5dce5c2 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -2,6 +2,7 @@ import { memo } from "react"; import { createPortal } from "react-dom"; import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; import type { TimelineElement } from "../store/playerStore"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; export interface KeyframeDiamondContextMenuState { x: number; @@ -18,24 +19,12 @@ export interface KeyframeDiamondContextMenuState { interface KeyframeDiamondContextMenuProps { state: KeyframeDiamondContextMenuState; onClose: () => void; - onDelete: ( - elementId: string, - percentage: number, - propertyGroup?: string, - tweenPercentage?: number, - animationId?: string, - ) => void; + onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void; onDeleteAll: (element: TimelineElement) => void; onChangeEase?: (elementId: string, percentage: number, ease: string) => void; onCopyProperties?: (elementId: string, percentage: number) => void; /** Retime the keyframe to the current playhead, preserving its value + ease. */ - onMoveToPlayhead?: ( - element: TimelineElement, - fromPercentage: number, - propertyGroup?: string, - tweenPercentage?: number, - animationId?: string, - ) => void; + onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void; } export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({ @@ -46,6 +35,14 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe onMoveToPlayhead, }: KeyframeDiamondContextMenuProps) { const menuRef = useContextMenuDismiss(onClose); + // The clicked diamond's identity, built once: the menu's two mutating entries + // both act on it, and they must not disagree about which keyframe was clicked. + const keyframe: TimelineKeyframeTarget = { + percentage: state.percentage, + tweenPercentage: state.tweenPercentage, + propertyGroup: state.propertyGroup, + animationId: state.animationId, + }; const menuWidth = 200; const menuHeight = onMoveToPlayhead ? 100 : 70; @@ -67,13 +64,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe // Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-% // and returns the tween-% for the mutation. Passing tween-% here would // miss the lookup on any tween whose window is shorter than the clip. - onMoveToPlayhead( - state.element, - state.percentage, - state.propertyGroup, - state.tweenPercentage, - state.animationId, - ); + onMoveToPlayhead(state.element, keyframe); onClose(); }} > @@ -86,13 +77,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe type="button" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" onClick={() => { - onDelete( - state.elementId, - state.percentage, - state.propertyGroup, - state.tweenPercentage, - state.animationId, - ); + onDelete(state.elementId, keyframe); onClose(); }} > diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index e3db29f4a3..ed0310814e 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -19,8 +19,11 @@ import { shouldAutoScrollTimeline, } from "./Timeline"; import { + CLIP_Y, FIT_ZOOM_HEADROOM, GUTTER, + LABEL_COL_W, + LANE_H, MIN_TIMELINE_EXTENT_S, PLAYHEAD_HEAD_W, RULER_H, @@ -28,6 +31,7 @@ import { TRACKS_LEFT_PAD, getTimelineDisplayContentWidth, getTimelineFitPps, + getTimelineLaneTop, } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; @@ -40,52 +44,216 @@ afterEach(() => { usePlayerStore.getState().reset(); }); -describe("Timeline provider boundary", () => { - // fallow-ignore-next-line code-duplication - it("renders the public Timeline export without TimelineEditProvider", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 640, - }); +function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { + const clip = host.querySelector(`[data-el-id="${clipId}"]`); + if (!clip) throw new Error(`Missing timeline clip ${clipId}`); + const trackContent = clip.parentElement; + if (!trackContent) throw new Error(`Missing content row for ${clipId}`); + const trackHeader = trackContent.previousElementSibling; + if (!(trackHeader instanceof HTMLElement)) throw new Error(`Missing track header for ${clipId}`); + const rulerTickLabel = Array.from(host.querySelectorAll("span")).find( + (node) => node.textContent === tickLabel, + ); + const rulerTick = rulerTickLabel?.parentElement; + if (!rulerTick) throw new Error(`Missing ruler tick ${tickLabel}`); + const ruler = rulerTick.parentElement; + if (!ruler) throw new Error("Missing timeline ruler"); + const rulerOrigin = ruler.previousElementSibling; + if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin"); + const playhead = Array.from(host.querySelectorAll("div")).find( + (node) => node.style.zIndex === "100", + ); + if (!playhead) throw new Error("Missing timeline playhead"); + return { clip, trackHeader, rulerTick, rulerOrigin, playhead }; +} + +function renderTimelineGeometry(clipId: string) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + return { host, root, ...getHorizontalGeometry(host, clipId, "00:10") }; +} + +function createSizedTimelineHost(width: number): HTMLDivElement { + const host = document.createElement("div"); + document.body.append(host); + Object.defineProperty(host, "clientWidth", { configurable: true, value: width }); + return host; +} + +function expectTrackExpansion( + row: HTMLElement | null | undefined, + expandedClipIds: string[], + height: number, +) { + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(expandedClipIds)); + expect(row?.style.height).toBe(`${height}px`); +} + +function renderBasicTimeline() { + const host = createSizedTimelineHost(640); + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + }); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + return { host, root }; +} +describe("Timeline provider boundary", () => { + it("keeps all-collapsed horizontal positions at the 32px gutter", () => { usePlayerStore.setState({ - duration: 4, + duration: 11, timelineReady: true, - elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + currentTime: 10, + zoomMode: "manual", + manualZoomPercent: 100, + elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }], }); - const root = createRoot(host); - - expect(() => { - act(() => { - root.render(React.createElement(Timeline)); - }); - }).not.toThrow(); + const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = + renderTimelineGeometry("clip-1"); + + expect(trackHeader.style.width).toBe("32px"); + expect(clip.style.left).toBe("1000px"); + expect(clip.style.height).toBe(""); + expect(clip.style.bottom).toBe(`${CLIP_Y}px`); + expect(rulerOrigin.style.width).toBe("32px"); + expect(rulerTick.style.left).toBe("999.5px"); + expect(playhead.style.left).toBe(`${1032 - PLAYHEAD_HEAD_W / 2}px`); + expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`); + expect( + resolveTimelineAssetDrop( + { + rectLeft: 100, + rectTop: 0, + scrollLeft: 0, + scrollTop: 0, + contentOrigin: GUTTER, + pixelsPerSecond: 100, + duration: 60, + trackOrder: [0], + }, + 1132, + 100, + ).start, + ).toBe(10); + expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1); act(() => root.unmount()); }); - // fallow-ignore-next-line code-duplication - it("renders the gutter without legacy icons or hue dots", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 640, - }); - + it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => { usePlayerStore.setState({ - duration: 4, + duration: 20, timelineReady: true, - elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + currentTime: 10, + zoomMode: "manual", + manualZoomPercent: 100, + selectedElementId: "clip-1", + expandedClipIds: new Set(["clip-1"]), + elements: [ + { id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 }, + { id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 }, + ], + gsapAnimations: new Map([ + [ + "clip-1", + [ + { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 20, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [{ percentage: 50, properties: { x: 100 } }], + }, + }, + ], + ], + ]), }); - const root = createRoot(host); - act(() => { - root.render(React.createElement(Timeline)); - }); + const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = + renderTimelineGeometry("clip-1"); + const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10"); + const diamond = host.querySelector( + '[data-keyframe-group="position"][data-keyframe-percentage="50"]', + ); + if (!diamond) throw new Error("Missing expanded position keyframe"); + const propertyLane = diamond.closest("[data-timeline-property-lane]"); + if (!propertyLane) throw new Error("Missing flat position property lane"); + const headerLane = trackHeader.querySelector('[data-property-group="position"]'); + if (!headerLane) throw new Error("Missing position property header"); + // Absolute x rebuilds from the content origin (the ruler-origin spacer), + // which now insets a GUTTER past the LABEL_COL_W label column so a 0% + // diamond has room to its left. The content row reaches that same origin via + // header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still + // coincide on the shared time x. + const contentOrigin = Number.parseFloat(rulerOrigin.style.width); + const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5; + const diamondX = + contentOrigin + + Number.parseFloat(propertyLane.style.left) + + Number.parseFloat(diamond.style.left) + + Number.parseFloat(diamond.style.width) / 2; + + expect(clip.contains(propertyLane)).toBe(false); + expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`); + expect(clip.style.bottom).toBe(""); + expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`); + expect(propertyLane.style.top).toBe(headerLane.style.top); + expect(propertyLane.style.background).toBe(""); + expect(propertyLane.style.border).toBe(""); + expect(propertyLane.style.borderRadius).toBe(""); + expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); + expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); + expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); + expect(diamondX).toBe(rulerX); + expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000); + expect(collapsedHeader.textContent).toContain("Outro"); + expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo( + (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S, + ); + expect( + resolveTimelineAssetDrop( + { + rectLeft: 100, + rectTop: 0, + scrollLeft: 0, + scrollTop: 0, + contentOrigin: LABEL_COL_W + GUTTER, + pixelsPerSecond: 100, + duration: 60, + trackOrder: [0], + }, + 100 + LABEL_COL_W + GUTTER + 1000, + 100, + ).start, + ).toBe(10); + + act(() => root.unmount()); + }); + + it("renders the public Timeline export without TimelineEditProvider", () => { + const { root } = renderBasicTimeline(); + + act(() => root.unmount()); + }); + + it("renders the gutter without legacy icons or hue dots", () => { + const { host, root } = renderBasicTimeline(); const hueDot = Array.from(host.querySelectorAll("div")).find( (node) => @@ -99,14 +267,8 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - // fallow-ignore-next-line code-duplication it("requests persisted track visibility from the gutter without seeking", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 640, - }); + const host = createSizedTimelineHost(640); usePlayerStore.setState({ duration: 4, @@ -153,8 +315,8 @@ describe("Timeline provider boundary", () => { }); const row = button.parentElement?.parentElement; - // Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content]. - const trackContent = row?.children.item(2); + // Row children: [TimelineTrackHeader (sticky column), time-mapped content]. + const trackContent = row?.children.item(1); expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false); expect(trackContent).toBeInstanceOf(HTMLElement); if (!(trackContent instanceof HTMLElement)) { @@ -165,14 +327,49 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - it("opens the keyframe context menu without seeking to that keyframe", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 720, + it("splits all tracks once when shift-clicking the timeline with the razor", () => { + const host = createSizedTimelineHost(640); + usePlayerStore.setState({ + activeTool: "razor", + duration: 4, + timelineReady: true, + elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + }); + + const onRazorSplitAll = vi.fn(); + const root = createRoot(host); + act(() => { + root.render( + React.createElement( + TimelineEditProvider, + { value: { onRazorSplitAll } }, + React.createElement(Timeline), + ), + ); + }); + + const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild; + expect(viewport).toBeInstanceOf(HTMLElement); + act(() => { + viewport?.dispatchEvent( + new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 240, + shiftKey: true, + }), + ); }); + expect(onRazorSplitAll).toHaveBeenCalledTimes(1); + expect(onRazorSplitAll).toHaveBeenCalledWith(expect.any(Number)); + act(() => root.unmount()); + }); + + it("opens the keyframe context menu without seeking to that keyframe", () => { + const host = createSizedTimelineHost(720); + usePlayerStore.setState({ duration: 4, timelineReady: true, @@ -215,14 +412,101 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - it("marks every clip in selectedElementIds as selected", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 720, + it("shows a disclosure only for grouped keyframes and toggles the track height", () => { + const host = createSizedTimelineHost(720); + + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [ + { id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }, + { id: "clip-2", tag: "div", start: 2, duration: 2, track: 1 }, + ], + keyframeCache: new Map([ + [ + "clip-1", + { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 }, propertyGroup: "position" }, + { percentage: 50, properties: { x: 100 }, propertyGroup: "position" }, + { percentage: 100, properties: { opacity: 0 }, propertyGroup: "visual" }, + ], + }, + ], + ]), + gsapAnimations: new Map([ + [ + "clip-1", + [ + { + id: "clip-1-position", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 } }, + ], + }, + }, + { + id: "clip-1-visual", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup: "visual", + keyframes: { + format: "percentage", + keyframes: [{ percentage: 100, properties: { opacity: 0 } }], + }, + }, + ], + ], + ]), }); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + + // Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure + // lives in the left column. clip-2 has no keyframes so it never shows one. + const collapseButton = host.querySelector( + 'button[aria-label="Collapse clip-1 keyframes"]', + ); + expect(collapseButton).not.toBeNull(); + expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull(); + expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull(); + + const clip = host.querySelector('[data-el-id="clip-1"]'); + const row = clip?.parentElement?.parentElement; + expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); + + // Collapsing sticks (does not bounce back open via auto-expand). + act(() => collapseButton?.click()); + expectTrackExpansion(row, [], TRACK_H); + + const expandButton = host.querySelector( + 'button[aria-label="Expand clip-1 keyframes"]', + ); + expect(expandButton).not.toBeNull(); + act(() => expandButton?.click()); + expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); + act(() => root.unmount()); + }); + + it("marks every clip in selectedElementIds as selected", () => { + const host = createSizedTimelineHost(720); + usePlayerStore.setState({ duration: 6, timelineReady: true, @@ -461,23 +745,29 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { it("computes fit pps against the 60s floor for short compositions", () => { // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width. // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.) - const pps = getTimelineFitPps(viewport, 10); - expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S); - expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6); + const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD); + expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S); + expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6); }); it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => { - expect(getTimelineFitPps(viewport, 60)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM), + expect(getTimelineFitPps(viewport, 60, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (60 * FIT_ZOOM_HEADROOM), ); - expect(getTimelineFitPps(viewport, 120)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM), + expect(getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (120 * FIT_ZOOM_HEADROOM), + ); + }); + + it("subtracts the expanded keyframe label column before fitting headroom", () => { + expect(getTimelineFitPps(viewport, 120, LABEL_COL_W)).toBeCloseTo( + (viewport - LABEL_COL_W - 2) / (120 * FIT_ZOOM_HEADROOM), ); }); it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => { - const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2; - const pps = getTimelineFitPps(viewport, 120); + const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2; + const pps = getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD); // Composition content occupies usable/1.2 px; the remaining ~17% is empty // droppable ruler/lane surface past the end. expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM); @@ -485,17 +775,17 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { }); it("falls back to 100 pps before the viewport is measured", () => { - expect(getTimelineFitPps(0, 10)).toBe(100); - expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100); - expect(getTimelineFitPps(Number.NaN, 10)).toBe(100); + expect(getTimelineFitPps(0, 10, GUTTER)).toBe(100); + expect(getTimelineFitPps(GUTTER, 10, GUTTER)).toBe(100); + expect(getTimelineFitPps(Number.NaN, 10, GUTTER)).toBe(100); }); it("uses the floor for zero/invalid durations", () => { - expect(getTimelineFitPps(viewport, 0)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, + expect(getTimelineFitPps(viewport, 0, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S, ); - expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, + expect(getTimelineFitPps(viewport, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S, ); }); }); @@ -504,14 +794,24 @@ describe("getTimelineDisplayContentWidth", () => { it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => { // 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered. expect( - getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }), + getTimelineDisplayContentWidth({ + trackContentWidth: 200, + viewportWidth: 400, + contentOrigin: GUTTER, + pps: 20, + }), ).toBe(MIN_TIMELINE_EXTENT_S * 20); }); it("still fills the viewport when that is larger than the 60s floor", () => { expect( - getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }), - ).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2); + getTimelineDisplayContentWidth({ + trackContentWidth: 200, + viewportWidth: 2000, + contentOrigin: GUTTER + TRACKS_LEFT_PAD, + pps: 5, + }), + ).toBe(2000 - (GUTTER + TRACKS_LEFT_PAD) - 2); }); it("tracks a drag ghost past every other bound (drag-to-extend)", () => { @@ -519,6 +819,7 @@ describe("getTimelineDisplayContentWidth", () => { getTimelineDisplayContentWidth({ trackContentWidth: 500, viewportWidth: 400, + contentOrigin: GUTTER, pps: 5, dragGhostEndPx: 5000, }), @@ -530,6 +831,7 @@ describe("getTimelineDisplayContentWidth", () => { getTimelineDisplayContentWidth({ trackContentWidth: 500, viewportWidth: 400, + contentOrigin: GUTTER, pps: 5, resizeGhostEndPx: 4200, }), @@ -538,7 +840,12 @@ describe("getTimelineDisplayContentWidth", () => { it("keeps long content authoritative", () => { expect( - getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }), + getTimelineDisplayContentWidth({ + trackContentWidth: 9000, + viewportWidth: 400, + contentOrigin: GUTTER, + pps: 50, + }), ).toBe(9000); }); }); @@ -565,7 +872,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 200, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 10, nextPixelsPerSecond: 20, duration: 120, @@ -578,7 +885,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 0, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 20, nextPixelsPerSecond: 5, duration: 120, @@ -591,7 +898,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 120, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 0, nextPixelsPerSecond: 20, duration: 120, @@ -601,28 +908,47 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { }); describe("getTimelinePlayheadLeft", () => { - it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => { + it("offsets the wrapper by half the head width so the line CENTER = contentOrigin + t*pps", () => { // Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must - // equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom. - expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe( + // equal contentOrigin + t*pps at any zoom, for both the padded default + // origin and the plain gutter origin. + expect(getTimelinePlayheadLeft(4, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( GUTTER + TRACKS_LEFT_PAD + 4 * 20, ); - expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe( + expect(getTimelinePlayheadLeft(10, 7.5, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( GUTTER + TRACKS_LEFT_PAD + 75, ); + expect(getTimelinePlayheadLeft(4, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20); + expect(getTimelinePlayheadLeft(10, 7.5, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75); + }); + + it("uses the expanded keyframe label column as the playhead origin", () => { + expect(getTimelinePlayheadLeft(4, 20, LABEL_COL_W) + PLAYHEAD_HEAD_W / 2).toBe( + LABEL_COL_W + 4 * 20, + ); }); it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => { - expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD); + expect(getTimelinePlayheadLeft(0, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( + GUTTER + TRACKS_LEFT_PAD, + ); + }); + + it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => { + expect(getTimelinePlayheadLeft(0, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER); }); it("guards invalid input", () => { - expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe( + expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER + TRACKS_LEFT_PAD)).toBe( GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, ); - expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe( + expect(getTimelinePlayheadLeft(4, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBe( GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, ); + expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2); + expect(getTimelinePlayheadLeft(4, Number.NaN, LABEL_COL_W)).toBe( + LABEL_COL_W - PLAYHEAD_HEAD_W / 2, + ); }); }); @@ -686,14 +1012,15 @@ describe("resolveTimelineAssetDrop", () => { rectTop: 200, scrollLeft: 0, scrollTop: 0, + contentOrigin: GUTTER, pixelsPerSecond: 100, duration: 10, trackHeight: 72, trackOrder: [0, 3, 7], }, - 480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps - // clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) + - // TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3. + 432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER) + // clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48) + // + TRACK_H/2(24) = 368 → row 1 → track 3. 368, ), ).toEqual({ start: 3, track: 3 }); @@ -707,12 +1034,13 @@ describe("resolveTimelineAssetDrop", () => { rectTop: 200, scrollLeft: 0, scrollTop: 0, + contentOrigin: GUTTER, pixelsPerSecond: 100, duration: 10, trackHeight: 72, trackOrder: [0, 3, 7], }, - 250 + TRACKS_LEFT_PAD, + 250, // rectLeft(100) + GUTTER(32) + 1.18s*100pps (contentOrigin = GUTTER) 600, ), ).toEqual({ start: 1.18, track: 8 }); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 38bef2479b..1e3f0719f9 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,19 +16,27 @@ 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 } 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"; -import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; +import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -107,6 +114,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(); @@ -118,7 +141,6 @@ export const Timeline = memo(function Timeline({ const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); const [shiftHeld, setShiftHeld] = useState(false); - const [razorGuideX, setRazorGuideX] = useState(null); useMountEffect(() => { const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown"); @@ -155,9 +177,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; @@ -224,6 +247,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -242,28 +266,23 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, + contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, onBlockDrop: pinnedOnBlockDrop, onCompositionDrop: pinnedOnCompositionDrop, }); - 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, onShiftClickKeyframe, onContextMenuKeyframe } = + const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = useTimelineKeyframeHandlers({ expandedElements, keyframeCache, @@ -303,6 +322,7 @@ export const Timeline = memo(function Timeline({ isDragging, scrollRef, lastScrollLeftRef, + contentOrigin, }); const laneGapStrips = useTimelineGapHighlights({ @@ -335,12 +355,21 @@ export const Timeline = memo(function Timeline({ setZoomMode, setManualZoomPercent, onSeek, + contentOrigin, }); useTimelineActiveClips({ scrollRef, currentTime, clipStateVersion, }); + const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = + useTimelineRazorInteraction({ + active: activeTool === "razor", + scrollRef, + contentOrigin, + pixelsPerSecond: pps, + onSplitAll: onRazorSplitAll, + }); const { rangeSelection, @@ -364,7 +393,9 @@ export const Timeline = memo(function Timeline({ setShowPopover, elementsRef: expandedElementsRef, trackOrderRef, + rowHeightsRef, onSelectElement, + contentOrigin, }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag @@ -423,13 +454,8 @@ export const Timeline = memo(function Timeline({ ref={setContainerRef} aria-label="Timeline" className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`} - onMouseMove={(e) => { - if (activeTool === "razor" && scrollRef.current) { - const rect = scrollRef.current.getBoundingClientRect(); - setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft); - } - }} - onMouseLeave={() => setRazorGuideX(null)} + onMouseMove={updateRazorGuide} + onMouseLeave={clearRazorGuide} style={{ touchAction: "pan-x pan-y", background: theme.shellBackground, @@ -450,14 +476,7 @@ export const Timeline = memo(function Timeline({ // Let interactive controls (keyframe nav/toggle, caret, inputs) handle // their own clicks — scrubbing here would preventDefault and eat them. if (e.target instanceof Element && e.target.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 splitTime = Math.max(0, x / pps); - onRazorSplitAll?.(splitTime); - return; - } + if (splitAllAtPointer(e)) return; handlePointerDown(e); }} onPointerMove={handlePointerMove} @@ -468,18 +487,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} @@ -505,9 +528,12 @@ export const Timeline = memo(function Timeline({ getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} keyframeCache={keyframeCache} + gsapAnimations={gsapAnimations} selectedKeyframes={selectedKeyframes} currentTime={currentTime} + onSeek={onSeek} beatAnalysis={adjustedBeatAnalysis} + onSelectSegment={onSelectSegment} onClickKeyframe={onClickKeyframe} onShiftClickKeyframe={onShiftClickKeyframe} onMoveKeyframe={onMoveKeyframe} @@ -525,16 +551,7 @@ export const Timeline = memo(function Timeline({ openGapMenu({ x: e.clientX, y: e.clientY, track, time }); }} /> - {activeTool === "razor" && razorGuideX !== null && ( -
- )} + {activeTool === "razor" && razorGuideX !== null && }
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 4ee3afc531..3d9769afd5 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,13 +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, @@ -22,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"; /** @@ -35,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; @@ -70,19 +77,25 @@ export interface TimelineLaneBaseProps { getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; keyframeCache?: Map; + gsapAnimations: Map; selectedKeyframes: Set; currentTime: number; - onClickKeyframe?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void; - onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => 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, - keyframe: TimelineKeyframeTarget, + target: TimelineKeyframeTarget, ) => void; onMoveKeyframe?: ( elementId: string, keyframe: TimelineKeyframeTarget, toClipPercentage: number, + propertyGroup?: string, + tweenPercentage?: number, + animationId?: string, ) => Promise; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; /** @@ -99,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"]; @@ -107,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps { export function TimelineLanes({ pps, + contentOrigin, + contentGutter, trackContentWidth, theme, displayTrackOrder, + rowHeights, trackOrder, tracks, trackStyles, + laneCounts, selectedElementId, selectedElementIds, hoveredClip, @@ -137,8 +155,11 @@ export function TimelineLanes({ getPreviewElement, getTrackStyle, keyframeCache, + gsapAnimations, selectedKeyframes, currentTime, + onSeek, + onSelectSegment, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, @@ -147,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 ( <> { @@ -161,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 = @@ -178,58 +208,55 @@ 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). */} -