feat(studio): wire expanded keyframe timeline lanes

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:42 +02:00
parent 6b58678d94
commit 4a12eb9d9c
23 changed files with 1088 additions and 396 deletions
+2
View File
@@ -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}
@@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => 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={
<PreviewOverlays
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
blockPreview={blockPreview}
isGestureRecording={isGestureRecording}
@@ -520,12 +520,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
<KeyframeDiamondContextMenu
state={kfMenu}
onClose={() => setKfMenu(null)}
onDelete={(_elId, target) =>
animId && handleGsapRemoveKeyframe(animId, target.percentage)
onDelete={(_elId, keyframe) =>
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
}
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
onMoveToPlayhead={(_element, target) =>
animId && handleGsapMoveKeyframeToPlayhead(animId, target.percentage)
onMoveToPlayhead={(_element, keyframe) =>
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
}
/>
)}
@@ -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 && (
<MotionPathOverlay
iframeRef={previewIframeRef}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
selection={shouldShowMotionPath ? domEditSelection : null}
compositionSize={compositionDimensions}
isPlaying={isPlaying}
/>
@@ -19,10 +19,10 @@ export interface KeyframeDiamondContextMenuState {
interface KeyframeDiamondContextMenuProps {
state: KeyframeDiamondContextMenuState;
onClose: () => void;
onDelete: (elementId: string, target: TimelineKeyframeTarget) => void;
onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAll: (element: TimelineElement) => void;
/** Retime the keyframe to the current playhead, preserving its value + ease. */
onMoveToPlayhead?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
}
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
@@ -33,11 +33,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
onMoveToPlayhead,
}: KeyframeDiamondContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
// One target object for every action: passing the identity as loose positional
// arguments let an adapter forward the percentage alone, which drops the menu
// back to first-match-by-percentage and picks the wrong animation whenever two
// collide at the same percentage.
const target: TimelineKeyframeTarget = {
// 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,
@@ -64,7 +62,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, target);
onMoveToPlayhead(state.element, keyframe);
onClose();
}}
>
@@ -77,7 +75,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, target);
onDelete(state.elementId, keyframe);
onClose();
}}
>
@@ -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();
});
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
const clip = host.querySelector<HTMLElement>(`[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<HTMLElement>("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", () => {
// 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,
});
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);
const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
renderTimelineGeometry("clip-1");
expect(() => {
act(() => {
root.render(React.createElement(Timeline));
});
}).not.toThrow();
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<HTMLElement>(
'[data-keyframe-group="position"][data-keyframe-percentage="50"]',
);
if (!diamond) throw new Error("Missing expanded position keyframe");
const propertyLane = diamond.closest<HTMLElement>("[data-timeline-property-lane]");
if (!propertyLane) throw new Error("Missing flat position property lane");
const headerLane = trackHeader.querySelector<HTMLElement>('[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<HTMLButtonElement>(
'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<HTMLElement>('[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<HTMLButtonElement>(
'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 });
@@ -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 {
@@ -106,6 +113,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();
@@ -117,7 +140,6 @@ export const Timeline = memo(function Timeline({
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
const [shiftHeld, setShiftHeld] = useState(false);
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
useMountEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
@@ -154,9 +176,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;
@@ -223,6 +246,7 @@ export const Timeline = memo(function Timeline({
ppsRef,
durationRef,
trackOrderRef,
rowHeightsRef,
onMoveElement: pinnedOnMoveElement,
onMoveElements: pinnedOnMoveElements,
onResizeElement: pinnedOnResizeElement,
@@ -241,27 +265,22 @@ 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, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
useTimelineKeyframeHandlers({
expandedElements,
@@ -302,6 +321,7 @@ export const Timeline = memo(function Timeline({
isDragging,
scrollRef,
lastScrollLeftRef,
contentOrigin,
});
const laneGapStrips = useTimelineGapHighlights({
@@ -334,12 +354,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,
@@ -363,7 +392,9 @@ export const Timeline = memo(function Timeline({
setShowPopover,
elementsRef: expandedElementsRef,
trackOrderRef,
rowHeightsRef,
onSelectElement,
contentOrigin,
});
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
@@ -422,13 +453,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,
@@ -449,14 +475,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}
@@ -467,18 +486,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}
@@ -504,11 +527,13 @@ export const Timeline = memo(function Timeline({
getPreviewElement={getPreviewElement}
getTrackStyle={getTrackStyle}
keyframeCache={keyframeCache}
gsapAnimations={gsapAnimations}
selectedKeyframes={selectedKeyframes}
currentTime={currentTime}
onSeek={onSeek}
beatAnalysis={adjustedBeatAnalysis}
onClickKeyframe={onClickKeyframe}
onSelectSegment={onSelectSegment}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
onMoveKeyframe={onMoveKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
@@ -525,16 +550,7 @@ export const Timeline = memo(function Timeline({
openGapMenu({ x: e.clientX, y: e.clientY, track, time });
}}
/>
{activeTool === "razor" && razorGuideX !== null && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-10"
style={{
left: razorGuideX,
width: 1,
background: "rgba(239,68,68,0.7)",
}}
/>
)}
{activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
</div>
<TimelineOverlays
theme={theme}
@@ -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 (
<div
className="relative"
style={{ height: props.totalH, width: GUTTER + TRACKS_LEFT_PAD + props.trackContentWidth }}
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
>
<TimelineRuler
major={props.major}
@@ -113,6 +119,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
majorTickInterval={props.majorTickInterval}
theme={props.theme}
beatAnalysis={props.beatAnalysis}
contentOrigin={props.contentOrigin}
/>
{/* 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
<div
className="absolute pointer-events-none"
style={{
top: getTimelineRowTop(draggedRowIndex) + CLIP_Y,
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.previewStart * props.pps,
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
left: props.contentOrigin + draggedClip.previewStart * props.pps,
width: Math.max(draggedClip.element.duration * props.pps, 4),
height: TRACK_H - CLIP_Y * 2,
height: draggedRowHeight - CLIP_Y * 2,
border: "1px solid rgba(60,230,172,0.55)",
background: "rgba(60,230,172,0.12)",
borderRadius: 4,
@@ -184,8 +192,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
<div
className="absolute pointer-events-none"
style={{
top: getTimelineRowTop(draggedClip.insertRow) - 0.5,
left: GUTTER + TRACKS_LEFT_PAD,
top: getTimelineRowTop(draggedClip.insertRow, props.rowHeights) - 0.5,
left: props.contentOrigin,
width: props.trackContentWidth,
height: 1,
background: "#3CE6AC",
@@ -200,7 +208,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
<div
className="absolute pointer-events-none"
style={{
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.snapTime * props.pps,
left: props.contentOrigin + draggedClip.snapTime * props.pps,
top: RULER_H,
bottom: 0,
width: 1,
@@ -222,7 +230,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
top: activeDraggedPosition.top,
left: activeDraggedPosition.left,
width: Math.max(activeDraggedElement.duration * props.pps, 4),
height: TRACK_H - CLIP_Y * 2,
height: draggedRowHeight - CLIP_Y * 2,
zIndex: 40,
}}
>
@@ -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. */}
<div
ref={props.playheadRef}
className="absolute top-0 bottom-0 pointer-events-none"
style={{
left: `${getTimelinePlayheadLeft(0, 0)}px`,
left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
width: PLAYHEAD_HEAD_W,
zIndex: 100,
display: beatDragging ? "none" : undefined,
@@ -8,6 +8,7 @@ interface TimelineClipProps {
el: TimelineElement;
pps: number;
clipY: number;
clipHeight?: number;
isSelected: boolean;
isHovered: boolean;
isDragging?: boolean;
@@ -30,6 +31,7 @@ export const TimelineClip = memo(function TimelineClip({
el,
pps,
clipY,
clipHeight,
isSelected,
isHovered,
isDragging = false,
@@ -71,7 +73,7 @@ export const TimelineClip = memo(function TimelineClip({
left: leftPx,
width: widthPx,
top: clipY,
bottom: clipY,
...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
borderRadius: theme.clipRadius,
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
@@ -1,59 +0,0 @@
import type { ReactNode } from "react";
import { TimelineClip } from "./TimelineClip";
import { getTimelineEditCapabilities } from "./timelineEditing";
import { CLIP_Y, TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
interface TimelineDragGhostProps {
element: TimelineElement;
position: { left: number; top: number };
pps: number;
selectedElementId: string | null;
hasCustomContent: boolean;
theme: TimelineTheme;
children: ReactNode;
}
export function TimelineDragGhost({
element,
position,
pps,
selectedElementId,
hasCustomContent,
theme,
children,
}: TimelineDragGhostProps) {
return (
<div
className="absolute pointer-events-none"
style={{
top: position.top,
left: position.left,
width: Math.max(element.duration * pps, 4),
height: TRACK_H - CLIP_Y * 2,
zIndex: 40,
}}
>
<TimelineClip
el={{ ...element, start: 0 }}
pps={pps}
clipY={0}
isSelected={selectedElementId === (element.key ?? element.id)}
isHovered={false}
isDragging={true}
hasCustomContent={hasCustomContent}
capabilities={getTimelineEditCapabilities(element)}
theme={theme}
isComposition={!!element.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onResizeStart={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
{children}
</TimelineClip>
</div>
);
}
@@ -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<number, TrackVisualStyle>;
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: Set<string>;
hoveredClip: string | null;
@@ -70,21 +77,25 @@ export interface TimelineLaneBaseProps {
getPreviewElement: (element: TimelineElement) => TimelineElement;
getTrackStyle: (tag: string) => TrackVisualStyle;
keyframeCache?: Map<string, KeyframeCacheEntry>;
gsapAnimations: Map<string, GsapAnimation[]>;
selectedKeyframes: Set<string>;
currentTime: number;
onClickKeyframe?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
/** Click on the segment BETWEEN two diamonds: selects it to edit its ease. */
onSelectSegment?: (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<boolean>;
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
/**
@@ -101,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"];
@@ -109,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps {
export function TimelineLanes({
pps,
contentOrigin,
contentGutter,
trackContentWidth,
theme,
displayTrackOrder,
rowHeights,
trackOrder,
tracks,
trackStyles,
laneCounts,
selectedElementId,
selectedElementIds,
hoveredClip,
@@ -139,22 +155,32 @@ export function TimelineLanes({
getPreviewElement,
getTrackStyle,
keyframeCache,
gsapAnimations,
selectedKeyframes,
currentTime,
onSeek,
onSelectSegment,
onClickKeyframe,
onShiftClickKeyframe,
onSelectSegment,
onContextMenuKeyframe,
onMoveKeyframe,
onContextMenuClip,
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 (
<>
{
@@ -164,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 =
@@ -181,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 (
<div key={trackNum} className="relative flex" style={{ height: TRACK_H }}>
<div
className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
style={{
width: GUTTER,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
borderBottom: `1px solid ${theme.rowBorder}`,
<div
key={trackNum}
className="relative flex"
style={{
height: rowHeight,
background: rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
<TimelineTrackHeader
trackNumber={trackNum}
trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${trackNum}`}
contentOrigin={contentOrigin}
keyframeClip={keyframeClip}
clipCount={els.length}
isExpanded={keyframeClipExpanded}
animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
currentTime={currentTime}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isActive={
keyframeClipKey != null &&
(selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey))
}
isHovered={keyframeClipKey != null && hoveredClip === keyframeClipKey}
theme={theme}
onToggleClipExpanded={() => {
if (keyframeClipKey) {
toggleClipExpandedTracked(keyframeClipKey);
}
}}
>
{isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
<button
type="button"
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isTrackHidden
? "text-[#3CE6AC] hover:text-white"
: "text-white/35 hover:text-white/75"
}`}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
void onToggleTrackHidden?.(trackNum, !isTrackHidden);
}}
>
{isTrackHidden ? (
<EyeSlash size={14} weight="bold" aria-hidden="true" />
) : (
<Eye size={14} weight="bold" aria-hidden="true" />
)}
</button>
</div>
{/* 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). */}
<div
aria-hidden="true"
className="flex-shrink-0"
style={{ width: TRACKS_LEFT_PAD }}
onToggleTrackHidden={onToggleTrackHidden}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
/>
<div
style={{
width: trackContentWidth,
background: rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
marginLeft: contentGutter, // room for a 0% diamond left of t=0
opacity: isTrackHidden ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
@@ -290,6 +314,13 @@ export function TimelineLanes({
els.map((el) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
// Only the track's active keyframe clip shows expanded lanes;
// other clips (incl. siblings on a shared track) show compact
// diamonds on their own bar instead.
const showsLanes =
STUDIO_KEYFRAMES_ENABLED &&
elementKey === keyframeClipKey &&
keyframeClipExpanded;
const capabilities = getTimelineEditCapabilities(el);
const isSelected =
selectedElementId === elementKey || selectedElementIds.has(elementKey);
@@ -323,6 +354,7 @@ export function TimelineLanes({
el={previewElement}
pps={pps}
clipY={CLIP_Y}
clipHeight={showsLanes ? TRACK_H - 2 * CLIP_Y : undefined}
isSelected={isSelected}
isHovered={hoveredClip === clipKey}
isDragging={false}
@@ -465,35 +497,70 @@ export function TimelineLanes({
renderClipContent,
renderClipOverlay,
)}
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={TRACK_H - 2 * CLIP_Y}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) / previewElement.duration) *
100
: 0
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(_elId, keyframe) =>
onClickKeyframe?.(previewElement, keyframe)
}
onShiftClickKeyframe={onShiftClickKeyframe}
onSelectSegment={onSelectSegment}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
suppressClickRef={suppressClickRef}
/>
)}
{STUDIO_KEYFRAMES_ENABLED &&
!showsLanes &&
keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={rowHeight - 2 * CLIP_Y}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) /
previewElement.duration) *
100
: 0
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(_elId, target) =>
onClickKeyframe?.(previewElement, target)
}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
onSelectSegment={onSelectSegment}
suppressClickRef={suppressClickRef}
/>
)}
</TimelineClip>
);
if (!isPassenger) return clip;
const propertyLanes = showsLanes && (
<TimelinePropertyLanes
key={`${clipKey}-property-lanes`}
animations={gsapAnimations.get(elementKey) ?? []}
clipStart={previewElement.start}
clipDuration={previewElement.duration}
clipLeftPx={previewElement.start * pps}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) / previewElement.duration) * 100
: 0
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
onShiftClickKeyframe={(target) =>
onShiftClickKeyframe?.(elementKey, target)
}
onContextMenuKeyframe={(e, target) =>
onContextMenuKeyframe?.(e, elementKey, target)
}
onMoveKeyframe={(target, toClipPercentage) =>
onMoveKeyframe?.(elementKey, target, toClipPercentage) ??
Promise.resolve(false)
}
suppressClickRef={suppressClickRef}
/>
);
if (!isPassenger) return [clip, propertyLanes];
return (
<div
key={clipKey}
@@ -506,6 +573,7 @@ export function TimelineLanes({
}}
>
{clip}
{propertyLanes}
</div>
);
})
@@ -102,12 +102,10 @@ export function TimelineOverlays({
<KeyframeDiamondContextMenu
state={kfContextMenu}
onClose={() => setKfContextMenu(null)}
onDelete={(elId, target) => onDeleteKeyframe?.(elId, target)}
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
onDelete={(elId, keyframe) => onDeleteKeyframe?.(elId, keyframe)}
onDeleteAll={(element) => onDeleteAllKeyframes?.(element)}
onMoveToPlayhead={
onMoveKeyframeToPlayhead
? (element, target) => onMoveKeyframeToPlayhead(element, target)
: undefined
onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined
}
/>
)}
@@ -0,0 +1,60 @@
import { useCallback, useState, type MouseEvent, type PointerEvent, type RefObject } from "react";
import { getTimelineContentXFromClient } from "./timelineLayout";
interface TimelineRazorInteractionOptions {
active: boolean;
scrollRef: RefObject<HTMLDivElement | null>;
contentOrigin: number;
pixelsPerSecond: number;
onSplitAll?: (time: number) => void;
}
export function useTimelineRazorInteraction({
active,
scrollRef,
contentOrigin,
pixelsPerSecond,
onSplitAll,
}: TimelineRazorInteractionOptions) {
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
const updateRazorGuide = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
const scroll = scrollRef.current;
if (!active || !scroll) return;
const rect = scroll.getBoundingClientRect();
setRazorGuideX(event.clientX - rect.left + scroll.scrollLeft);
},
[active, scrollRef],
);
const clearRazorGuide = useCallback(() => setRazorGuideX(null), []);
const splitAllAtPointer = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
const scroll = scrollRef.current;
if (!active || !event.shiftKey || event.button !== 0 || !scroll) return false;
const rect = scroll.getBoundingClientRect();
const x = getTimelineContentXFromClient({
clientX: event.clientX,
rectLeft: rect.left,
scrollLeft: scroll.scrollLeft,
contentOrigin,
});
onSplitAll?.(Math.max(0, x / pixelsPerSecond));
return true;
},
[active, contentOrigin, onSplitAll, pixelsPerSecond, scrollRef],
);
return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
}
export function TimelineRazorGuide({ x }: { x: number }) {
return (
<div
className="absolute top-0 bottom-0 pointer-events-none z-10"
style={{ left: x, width: 1, background: "rgba(239,68,68,0.7)" }}
/>
);
}
@@ -1,6 +1,6 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER, RULER_H, TRACKS_LEFT_PAD, formatTimelineTickLabel } from "./timelineLayout";
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { secondsToFrame } from "../lib/time";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
@@ -15,6 +15,7 @@ interface TimelineRulerProps {
majorTickInterval: number;
theme: TimelineTheme;
beatAnalysis?: MusicBeatAnalysis | null;
contentOrigin: number;
}
export const TimelineRuler = memo(function TimelineRuler({
@@ -27,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
majorTickInterval,
theme,
beatAnalysis,
contentOrigin,
}: TimelineRulerProps) {
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const beatTimes = beatAnalysis?.beatTimes ?? [];
@@ -45,7 +47,7 @@ export const TimelineRuler = memo(function TimelineRuler({
the ruler's own small ticks mark intervals (no full-height lines). */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
style={{ left: contentOrigin, width: trackContentWidth, zIndex: 0 }}
height={totalH}
>
{showBeats &&
@@ -69,35 +71,23 @@ export const TimelineRuler = memo(function TimelineRuler({
</svg>
{/* Ruler sticky so the timestamps stay visible while the tracks scroll
vertically. Opaque background (plus the gutter corner block) so clips
vertically. Opaque background (plus the label-column corner block) so clips
scrolling underneath don't bleed through; z-index sits above the track
rows and drag overlays but below the playhead (z 100). */}
<div
className="sticky top-0 flex"
style={{
height: RULER_H,
width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
zIndex: 70,
}}
style={{ height: RULER_H, width: contentOrigin + trackContentWidth, zIndex: 70 }}
>
<div
className="sticky left-0 z-[12] flex-shrink-0"
style={{
width: GUTTER,
// Ruler corner uses the panel surface — same as the ruler strip
// itself, and NO right border: the ruler band stays completely
// clean until 00:00 (the header-boundary line belongs to the track
// rows below, not the ruler).
width: contentOrigin,
// Ruler corner uses the panel surface — same as the ruler strip itself.
background: theme.shellBackground,
}}
/>
{/* Left breathing pad scrolls with the content, so 00:00 starts a
beat right of the gutter (see TRACKS_LEFT_PAD). */}
<div
aria-hidden="true"
className="flex-shrink-0"
style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
/>
{/* Breathing pad before 00:00 is folded into contentOrigin (see
Timeline.tsx: GUTTER + TRACKS_LEFT_PAD), so no separate pad div. */}
<div
className="relative overflow-hidden"
style={{
@@ -110,7 +100,7 @@ export const TimelineRuler = memo(function TimelineRuler({
>
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
t * pps matching the playhead line, which is also centered on
GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
a tick spans [x, x+1) and its center is half a pixel right. */}
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
@@ -5,7 +5,7 @@ import {
TIMELINE_COMPOSITION_MIME,
} from "../../utils/timelineCompositionDrop";
import { usePlayerStore } from "../store/playerStore";
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
import { resolveTimelineAssetDrop } from "./timelineLayout";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
@@ -13,6 +13,8 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
ppsRef: RefObject<number>;
durationRef: RefObject<number>;
trackOrderRef: RefObject<number[]>;
rowHeightsRef: RefObject<readonly number[]>;
contentOrigin: number;
}
type TimelinePlacement = { start: number; track: number };
@@ -54,6 +56,8 @@ export function useTimelineAssetDrop({
ppsRef,
durationRef,
trackOrderRef,
rowHeightsRef,
contentOrigin,
onFileDrop,
onAssetDrop,
onBlockDrop,
@@ -85,10 +89,11 @@ export function useTimelineAssetDrop({
rectTop: rect?.top ?? 0,
scrollLeft: scroll?.scrollLeft ?? 0,
scrollTop: scroll?.scrollTop ?? 0,
contentOrigin,
pixelsPerSecond: ppsRef.current,
duration: durationRef.current,
clampStartToDuration: !usePointerStart,
trackHeight: TRACK_H,
rowHeights: rowHeightsRef.current,
trackOrder: trackOrderRef.current,
},
clientX,
@@ -99,7 +104,7 @@ export function useTimelineAssetDrop({
track: pointer.track,
};
},
[scrollRef, ppsRef, durationRef, trackOrderRef],
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin],
);
const handleAssetDrop = useCallback(
@@ -154,15 +154,16 @@ describe("track-area breathing pad y-math", () => {
rectTop: 0,
scrollLeft: 0,
scrollTop: 0,
contentOrigin: GUTTER,
pixelsPerSecond: 100,
duration: 60,
trackHeight: TRACK_H,
rowHeights: trackHeights(3),
trackOrder: [0, 1, 2],
};
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
const clientX = GUTTER + 100; // t = 1s (contentOrigin = GUTTER)
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
expect(track).toBe(0);
expect(start).toBe(1);
@@ -179,6 +180,13 @@ describe("track-area breathing pad y-math", () => {
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
expect(track).toBe(3); // max(trackOrder)+1
});
it("keeps a drop in an expanded lane region on that track", () => {
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
const clientY = getTimelineRowTop(0, rowHeights) + TRACK_H + LANE_H;
const { track } = resolveTimelineAssetDrop({ ...base, rowHeights }, GUTTER, clientY);
expect(track).toBe(0);
});
});
});
@@ -188,6 +196,7 @@ describe("getTimelineScrubTime", () => {
clientX,
viewportLeft: 0,
scrollLeft: 0,
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
pixelsPerSecond: 100,
duration,
});
@@ -13,7 +13,6 @@ export const CLIP_HANDLE_W = 18;
export function getTimelineLaneTop(laneIndex: number): number {
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
}
/**
* Collapsed-row characterization value for the new-track INSERT band. Runtime
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
@@ -43,7 +42,7 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
*/
export const TRACKS_LEFT_PAD = 48;
interface TimelineTrackHeightClip {
export interface TimelineTrackHeightClip {
clipId: string;
laneCount: number;
}
@@ -307,12 +306,16 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
* remaining ruler runs to 1:00.
* Manual zoom multiplies this base, so the floor only anchors the default.
*/
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
export function getTimelineFitPps(
viewportWidth: number,
effectiveDuration: number,
contentOrigin: number,
): number {
const safeDuration =
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
return (viewportWidth - contentOrigin - 2) / span;
}
/**
@@ -325,6 +328,7 @@ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: numb
export function getTimelineDisplayContentWidth(input: {
trackContentWidth: number;
viewportWidth: number;
contentOrigin: number;
pps: number;
dragGhostEndPx?: number;
resizeGhostEndPx?: number;
@@ -332,7 +336,7 @@ export function getTimelineDisplayContentWidth(input: {
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
return Math.max(
input.trackContentWidth,
input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
input.viewportWidth - input.contentOrigin - 2,
input.dragGhostEndPx ?? 0,
input.resizeGhostEndPx ?? 0,
MIN_TIMELINE_EXTENT_S * safePps,
@@ -340,6 +344,15 @@ export function getTimelineDisplayContentWidth(input: {
}
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
export function getTimelineContentXFromClient(input: {
clientX: number;
rectLeft: number;
scrollLeft: number;
contentOrigin: number;
}): number {
return input.clientX - input.rectLeft + input.scrollLeft - input.contentOrigin;
}
export function shouldAutoScrollTimeline(
zoomMode: ZoomMode,
scrollWidth: number,
@@ -362,7 +375,7 @@ export function getTimelineScrollLeftForZoomTransition(
export function getTimelineScrollLeftForZoomAnchor(input: {
pointerX: number;
currentScrollLeft: number;
gutter: number;
contentOrigin: number;
currentPixelsPerSecond: number;
nextPixelsPerSecond: number;
duration: number;
@@ -379,9 +392,17 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
) {
return Math.max(0, input.currentScrollLeft);
}
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
const timelineX = Math.max(
0,
getTimelineContentXFromClient({
clientX: input.pointerX,
rectLeft: 0,
scrollLeft: input.currentScrollLeft,
contentOrigin: input.contentOrigin,
}),
);
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
return Math.max(0, input.contentOrigin + timeAtPointer * nextPps - input.pointerX);
}
/* ── Playhead / canvas ────────────────────────────────────────────── */
@@ -390,33 +411,32 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
* shifted LEFT by half this width for the line's center to land exactly on
* `GUTTER + time * pps` see {@link getTimelinePlayheadLeft}.
* `contentOrigin + time * pps` see {@link getTimelinePlayheadLeft}.
*/
export const PLAYHEAD_HEAD_W = 9;
/**
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
* sits exactly on `contentOrigin + time * pps` (the same x the ruler ticks center
* on) at every zoom level. Without the half-head offset the line sat
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
*/
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
export function getTimelinePlayheadLeft(
time: number,
pixelsPerSecond: number,
contentOrigin: number,
): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
return contentOrigin - PLAYHEAD_HEAD_W / 2;
}
return (
GUTTER +
TRACKS_LEFT_PAD +
Math.max(0, time) * Math.max(0, pixelsPerSecond) -
PLAYHEAD_HEAD_W / 2
);
return contentOrigin + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
}
/**
* Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport
* clientX. Clamped to [0, duration], NOT rejected the scrub surface starts
* `GUTTER + TRACKS_LEFT_PAD` px right of the viewport edge, so any pointer left
* of t=0 maps to a negative offset. Callers used to bail on that instead of
* `contentOrigin` px right of the viewport edge, so any pointer left of t=0
* maps to a negative offset. Callers used to bail on that instead of
* clamping, which made the last 80px of the drag to zero silently do nothing:
* the playhead stuck wherever the last in-range sample landed, and only a very
* slow drag that happened to sample inside the sliver before the origin reached
@@ -426,12 +446,18 @@ export function getTimelineScrubTime(input: {
clientX: number;
viewportLeft: number;
scrollLeft: number;
contentOrigin: number;
pixelsPerSecond: number;
duration: number;
}): number {
const { clientX, viewportLeft, scrollLeft, pixelsPerSecond, duration } = input;
const { clientX, viewportLeft, scrollLeft, contentOrigin, pixelsPerSecond, duration } = input;
if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0;
const x = clientX - viewportLeft + scrollLeft - GUTTER - TRACKS_LEFT_PAD;
const x = getTimelineContentXFromClient({
clientX,
rectLeft: viewportLeft,
scrollLeft,
contentOrigin,
});
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
}
@@ -500,16 +526,22 @@ export function resolveTimelineAssetDrop(
rectTop: number;
scrollLeft: number;
scrollTop: number;
contentOrigin: number;
pixelsPerSecond: number;
duration: number;
clampStartToDuration?: boolean;
trackHeight: number;
rowHeights?: readonly number[];
trackOrder: number[];
},
clientX: number,
clientY: number,
): { start: number; track: number } {
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
const x = getTimelineContentXFromClient({
clientX,
rectLeft: input.rectLeft,
scrollLeft: input.scrollLeft,
contentOrigin: input.contentOrigin,
});
const contentY = clientY - input.rectTop + input.scrollTop;
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
const start = Math.max(
@@ -519,7 +551,7 @@ export function resolveTimelineAssetDrop(
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
// past the last lane (getDefaultDroppedTrack then appends a new track).
const rowIndex = Math.floor(getTimelineRowFromY(contentY));
const rowIndex = Math.floor(getTimelineRowFromY(contentY, input.rowHeights));
return {
start,
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
@@ -1,11 +1,4 @@
import {
GUTTER,
RULER_H,
CLIP_Y,
TRACKS_LEFT_PAD,
getTimelineRowHeight,
getTimelineRowTop,
} from "./timelineLayout";
import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout";
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
/** Pointer must travel at least this far (either axis) before a pointerdown on
@@ -76,7 +69,7 @@ export function getTimelineClipRect(
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
trackOrder: number[],
pps: number,
contentOrigin: number = GUTTER + TRACKS_LEFT_PAD,
contentOrigin: number,
rowHeights: readonly number[] = [],
): Rect | null {
const row = trackOrder.indexOf(clip.track);
@@ -105,7 +98,7 @@ export function computeMarqueeSelection(input: {
clips: MarqueeClipInput[];
trackOrder: number[];
pps: number;
contentOrigin?: number;
contentOrigin: number;
marquee: Rect;
baseSelection?: Iterable<string>;
rowHeights?: readonly number[];
@@ -21,6 +21,7 @@ interface UseTimelineGeometryInput {
isDragging: RefObject<boolean>;
scrollRef: RefObject<HTMLDivElement | null>;
lastScrollLeftRef: RefObject<number>;
contentOrigin: number;
}
// Derive the timeline's horizontal geometry from the viewport, zoom, and any live
@@ -40,10 +41,11 @@ export function useTimelineGeometry({
isDragging,
scrollRef,
lastScrollLeftRef,
contentOrigin,
}: UseTimelineGeometryInput) {
// Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short
// comps show a 60s ruler with usable empty space (see getTimelineFitPps).
const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration);
const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration, contentOrigin);
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
ppsRef.current = pps;
const trackContentWidth = Math.max(0, effectiveDuration * pps);
@@ -70,6 +72,7 @@ export function useTimelineGeometry({
const displayContentWidth = getTimelineDisplayContentWidth({
trackContentWidth,
viewportWidth,
contentOrigin,
pps,
dragGhostEndPx,
resizeGhostEndPx,
@@ -3,8 +3,6 @@ import { liveTime, type ZoomMode } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { getPinchTimelineZoomPercent } from "./timelineZoom";
import {
GUTTER,
TRACKS_LEFT_PAD,
getTimelinePlayheadLeft,
getTimelineScrubTime,
getTimelineScrollLeftForZoomTransition,
@@ -32,6 +30,7 @@ interface UseTimelinePlayheadInput {
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
onSeek?: (time: number) => void;
contentOrigin: number;
}
export function useTimelinePlayhead({
@@ -53,6 +52,7 @@ export function useTimelinePlayhead({
setZoomMode,
setManualZoomPercent,
onSeek,
contentOrigin,
}: UseTimelinePlayheadInput) {
const dragScrollRaf = useRef(0);
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
@@ -61,6 +61,8 @@ export function useTimelinePlayhead({
// anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
const previousAnchorPpsRef = useRef(pps);
const skipCenterAnchorRef = useRef(false);
const contentOriginRef = useRef(contentOrigin);
contentOriginRef.current = contentOrigin;
useLayoutEffect(() => {
const scroll = scrollRef.current;
@@ -75,21 +77,21 @@ export function useTimelinePlayhead({
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
pointerX: scroll.clientWidth / 2,
currentScrollLeft: scroll.scrollLeft,
gutter: GUTTER + TRACKS_LEFT_PAD,
contentOrigin,
currentPixelsPerSecond: prevPps,
nextPixelsPerSecond: pps,
duration: durationRef.current,
});
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
}, [pps, scrollRef, durationRef]);
}, [pps, scrollRef, durationRef, contentOrigin]);
const syncPlayheadPosition = useCallback(
(time: number) => {
if (!playheadRef.current || durationRef.current <= 0) return;
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current, contentOrigin)}px`;
},
[playheadRef, durationRef, ppsRef],
[playheadRef, durationRef, ppsRef, contentOrigin],
);
useEffect(() => {
@@ -121,7 +123,7 @@ export function useTimelinePlayhead({
if (!playheadRef.current || durationRef.current <= 0) return;
// Playback deliberately does NOT scroll the viewport to chase the playhead —
// the user's scroll position is theirs; the playhead may run off-screen.
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current)}px`;
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current, contentOriginRef.current)}px`;
});
return unsub;
});
@@ -135,13 +137,14 @@ export function useTimelinePlayhead({
clientX,
viewportLeft: rect.left,
scrollLeft: el.scrollLeft,
contentOrigin,
pixelsPerSecond: pps,
duration: effectiveDuration,
});
liveTime.notify(time);
onSeek?.(time);
},
[scrollRef, effectiveDuration, pps, onSeek],
[scrollRef, effectiveDuration, pps, onSeek, contentOrigin],
);
const autoScrollDuringDrag = useCallback(
@@ -191,7 +194,7 @@ export function useTimelinePlayhead({
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
pointerX: e.clientX - rect.left,
currentScrollLeft: scroll.scrollLeft,
gutter: GUTTER + TRACKS_LEFT_PAD,
contentOrigin,
currentPixelsPerSecond: ppsRef.current,
nextPixelsPerSecond: nextPps,
duration: durationRef.current,
@@ -214,6 +217,7 @@ export function useTimelinePlayhead({
manualZoomPercentRef,
setManualZoomPercent,
setZoomMode,
contentOrigin,
],
);
@@ -7,7 +7,7 @@ import {
} from "./timelineEditing";
import type { TimelineElement } from "../store/playerStore";
import { liveTime, usePlayerStore } from "../store/playerStore";
import { GUTTER, TRACKS_LEFT_PAD, getTimelineScrubTime } from "./timelineLayout";
import { getTimelineScrubTime } from "./timelineLayout";
import {
computeMarqueeSelection,
getMarqueeRect,
@@ -30,7 +30,9 @@ interface UseTimelineRangeSelectionInput {
setShowPopover: (v: boolean) => void;
elementsRef: React.RefObject<TimelineElement[]>;
trackOrderRef: React.RefObject<number[]>;
rowHeightsRef: React.RefObject<readonly number[]>;
onSelectElement?: (element: TimelineElement | null) => void;
contentOrigin: number;
}
interface MarqueeDragState {
@@ -72,12 +74,16 @@ function commitMarqueeSelection(
marquee: MarqueeDragState,
elements: TimelineElement[],
trackOrder: number[],
rowHeights: readonly number[],
pps: number,
contentOrigin: number,
): void {
const { ids, primaryId } = computeMarqueeSelection({
clips: toMarqueeClips(elements),
trackOrder,
rowHeights,
pps,
contentOrigin,
marquee: rect,
baseSelection: additive ? marquee.baseIds : undefined,
});
@@ -101,7 +107,9 @@ export function useTimelineRangeSelection({
setShowPopover,
elementsRef,
trackOrderRef,
rowHeightsRef,
onSelectElement,
contentOrigin,
}: UseTimelineRangeSelectionInput) {
const isRangeSelecting = useRef(false);
const rangeAnchorTime = useRef(0);
@@ -168,10 +176,12 @@ export function useTimelineRangeSelection({
marquee,
elementsRef.current ?? [],
trackOrderRef.current ?? [],
rowHeightsRef.current,
ppsRef.current,
contentOrigin,
);
},
[toContentPoint, elementsRef, trackOrderRef, ppsRef],
[toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin],
);
const stopMarqueeAutoScroll = useCallback(() => {
@@ -228,14 +238,13 @@ export function useTimelineRangeSelection({
setShowPopover(false);
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x =
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
const time = Math.max(0, x / pps);
rangeAnchorTime.current = time;
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
}
},
[scrollRef, pps, setShowPopover],
[scrollRef, pps, setShowPopover, contentOrigin],
);
const handlePointerDown = useCallback(
@@ -291,6 +300,7 @@ export function useTimelineRangeSelection({
clientX,
viewportLeft: rect.left,
scrollLeft: el.scrollLeft,
contentOrigin,
pixelsPerSecond: pps,
duration: el.scrollWidth / pps,
}),
@@ -306,7 +316,7 @@ export function useTimelineRangeSelection({
});
}
},
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging],
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
);
const handlePointerMove = useCallback(
@@ -314,8 +324,7 @@ export function useTimelineRangeSelection({
if (isRangeSelecting.current) {
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x =
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
setRangeSelection((prev) =>
prev
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
@@ -335,7 +344,15 @@ export function useTimelineRangeSelection({
if (!isDragging.current) return;
updateScrubDrag(e.clientX);
},
[pps, scrollRef, isDragging, applyMarqueeAtClient, syncMarqueeAutoScroll, updateScrubDrag],
[
pps,
scrollRef,
isDragging,
applyMarqueeAtClient,
syncMarqueeAutoScroll,
updateScrubDrag,
contentOrigin,
],
);
// Release of a shift time-range gesture: keep a real range (or a shift-click
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it } from "vitest";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { LANE_H, TRACK_H } from "./timelineLayout";
import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
usePlayerStore.getState().reset();
});
describe("useTimelineTrackLayout", () => {
it("counts a flat tween lane and reserves its expanded row height", () => {
const elements: TimelineElement[] = [
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
];
const animations = new Map<string, GsapAnimation[]>([
[
"clip-1",
[
{
id: "position-tween",
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 1,
properties: { x: 420 },
propertyGroup: "position",
},
],
],
]);
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
function Probe() {
layout = useTimelineTrackLayout(elements, animations, null, new Set());
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(React.createElement(Probe)));
expect(layout?.laneCounts.get("clip-1")).toBe(1);
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
act(() => root.unmount());
});
});
@@ -0,0 +1,161 @@
import { useMemo, useRef } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationContributesLane } from "./TimelinePropertyLanes";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import {
TRACK_H,
getTimelineCanvasHeight,
getTimelineRowHeight,
trackHeights,
type TimelineTrackHeightClip,
} from "./timelineLayout";
export { getTrackStyle } from "./timelineIcons";
/**
* The single keyframed element whose property lanes a track shows when expanded.
* A track can hold several elements (same z-index is common), but keyframes are
* per-element, so we scope to ONE active element the selected one if it's on
* this track, otherwise the element with the most lanes. Selecting a clip is how
* you switch which element you're keyframing. Returns null when no element on the
* track has keyframes.
*/
export function resolveTrackKeyframeClip(
elements: readonly TimelineElement[],
laneCounts: ReadonlyMap<string, number>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
): TimelineElement | null {
const keyframed = elements.filter(
(element) => (laneCounts.get(element.key ?? element.id) ?? 0) >= 1,
);
if (keyframed.length === 0) return null;
const selected = keyframed.find((element) => {
const key = element.key ?? element.id;
return key === selectedElementId || selectedElementIds.has(key);
});
if (selected) return selected;
return [...keyframed].sort(
(a, b) => (laneCounts.get(b.key ?? b.id) ?? 0) - (laneCounts.get(a.key ?? a.id) ?? 0),
)[0]!;
}
/** Lanes per clip: the count of distinct property groups whose tween contributes
* a lane (real keyframes or a synthesizable flat tween). */
function computeLaneCounts(
tracks: [number, TimelineElement[]][],
gsapAnimations: Map<string, GsapAnimation[]>,
): Map<string, number> {
const laneCounts = new Map<string, number>();
for (const [, elements] of tracks) {
for (const element of elements) {
const clipId = element.key ?? element.id;
const propertyGroups = new Set<string>();
for (const animation of gsapAnimations.get(clipId) ?? []) {
if (animation.propertyGroup && animationContributesLane(animation)) {
propertyGroups.add(animation.propertyGroup);
}
}
laneCounts.set(clipId, propertyGroups.size);
}
}
return laneCounts;
}
function useTimelineRowHeights(
tracks: [number, TimelineElement[]][],
gsapAnimations: Map<string, GsapAnimation[]>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
) {
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const { laneCounts, rowHeights } = useMemo(() => {
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
// Row height follows only the active keyframe clip, so a track with several
// keyframed elements never reserves empty lanes for the ones not shown.
const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => {
const active = resolveTrackKeyframeClip(
elements,
laneCounts,
selectedElementId,
selectedElementIds,
);
if (!active) return [];
const clipId = active.key ?? active.id;
return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
});
return {
laneCounts,
rowHeights: trackHeights(
heightTracks,
STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : undefined,
),
};
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
const rowHeightsRef = useRef<readonly number[]>(rowHeights);
rowHeightsRef.current = rowHeights;
return { laneCounts, rowHeights, rowHeightsRef };
}
export function useTimelineTrackLayout(
expandedElements: TimelineElement[],
gsapAnimations: Map<string, GsapAnimation[]>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
) {
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights(
tracks,
gsapAnimations,
selectedElementId,
selectedElementIds,
);
return {
tracks,
trackStyles,
trackOrder,
trackOrderRef,
laneCounts,
rowHeights,
rowHeightsRef,
};
}
function useDisplayRowHeights(
displayTrackOrder: readonly number[],
trackOrder: readonly number[],
rowHeights: readonly number[],
) {
return useMemo(
() =>
displayTrackOrder.map((track) => {
const row = trackOrder.indexOf(track);
return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights);
}),
[displayTrackOrder, trackOrder, rowHeights],
);
}
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
return useMemo(() => {
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
}, [draggedClip, trackOrder]);
}
export function useTimelineDisplayLayout(
draggedClip: DraggedClipState | null,
trackOrder: number[],
rowHeights: readonly number[],
) {
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights);
const totalH = getTimelineCanvasHeight(displayRowHeights);
return { displayTrackOrder, displayRowHeights, totalH };
}