Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 52 additions & 34 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElement
import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { useTimelineZoom } from "./useTimelineZoom";
import { useTimelineAssetDrop } from "./timelineDragDrop";
import { TimelineEmptyState } from "./TimelineEmptyState";
Expand Down Expand Up @@ -43,8 +42,9 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";

// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
generateTicks,
formatTimelineTickLabel,
Expand Down Expand Up @@ -128,9 +128,8 @@ export const Timeline = memo(function Timeline({
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
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(
() => hasKeyframedTimelineClips(gsapAnimations),
[gsapAnimations],
Expand Down Expand Up @@ -162,7 +161,6 @@ export const Timeline = memo(function Timeline({
containerRef.current = el;
}, []);

// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
const lastScrollLeftRef = useRef(0);

const effectiveDuration = useMemo(
Expand Down Expand Up @@ -314,28 +312,46 @@ export const Timeline = memo(function Timeline({
toggleSelectedKeyframe,
});

const {
pps,
fitPps,
displayContentWidth,
displayDuration,
clipStateVersion,
zoomModeRef,
manualZoomPercentRef,
} = useTimelineGeometry({
viewportWidth: viewport.clientWidth,
effectiveDuration,
zoomMode,
manualZoomPercent,
ppsRef,
fitPpsRef,
draggedClip,
resizingClip,
expandedElements,
isDragging,
scrollRef,
lastScrollLeftRef,
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
useTimelineGeometry({
viewportWidth: viewport.clientWidth,
effectiveDuration,
zoomMode,
manualZoomPercent,
ppsRef,
fitPpsRef,
draggedClip,
resizingClip,
expandedElements,
isDragging,
scrollRef,
lastScrollLeftRef,
contentOrigin,
});
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
tracks,
viewport,
pixelsPerSecond: pps,
contentOrigin,
duration: displayDuration,
selectedElementId: selectedElementId ?? undefined,
draggedElementId: draggedClip?.element.key ?? draggedClip?.element.id,
resizingElementId: resizingClip?.element.key ?? resizingClip?.element.id,
revealElementId: clipRevealRequest?.elementId,
focusedEaseElementId: focusedEaseSegment?.elementId,
clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id,
keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id,
scrollRef,
elements: expandedElements,
rowGeometry: displayLayout.rowGeometry,
allowHorizontalReveal: zoomMode === "manual",
sessionEpoch,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion: renderTimeRange,
elementStateVersion: expandedElements,
});

const laneGapStrips = useTimelineGapHighlights({
Expand Down Expand Up @@ -370,11 +386,6 @@ export const Timeline = memo(function Timeline({
onSeek,
contentOrigin,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
});
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
useTimelineRazorInteraction({
active: activeTool === "razor",
Expand Down Expand Up @@ -416,8 +427,12 @@ export const Timeline = memo(function Timeline({
setRangeSelection(null),
);

const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
const { major, minor, majorTickInterval } = useTimelineTicks(
displayDuration,
pps,
timeDisplayMode,
rowVirtualizationActive ? renderTimeRange : undefined,
);

const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
Expand Down Expand Up @@ -493,6 +508,9 @@ export const Timeline = memo(function Timeline({
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
rowsVirtualized={rowVirtualizationActive}
clipIndex={clipIndex}
renderTimeRange={renderTimeRange}
pinnedClipIdentities={pinnedClipIdentities}
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
Expand All @@ -506,7 +524,7 @@ export const Timeline = memo(function Timeline({
blockedClipRef={blockedClipRef}
suppressClickRef={suppressClickRef}
scrollRef={scrollRef}
renderClipContent={renderClipContent}
renderClipContent={viewport.isScrolling ? undefined : renderClipContent}
renderClipOverlay={renderClipOverlay}
playheadRef={playheadRef}
onDrillDown={onDrillDown}
Expand Down
126 changes: 126 additions & 0 deletions packages/studio/src/player/components/Timeline.virtualization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,59 @@ afterAll(() => {
});

describe("Timeline row virtualization", () => {
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
selectedElementId: "clip-0",
elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
});

const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
try {
await act(async () =>
root.render(
React.createElement(Timeline, {
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
}),
),
);
await act(async () => {});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});

const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
expect(scroller).not.toBeNull();
expect(clip).not.toBeNull();
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
expect(host.querySelector("[data-rich-content]")).not.toBeNull();

await act(async () => {
scroller?.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
expect(host.querySelector("[data-rich-content]")).toBeNull();

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
} finally {
act(() => root.unmount());
usePlayerStore.getState().reset();
}
});

it("mounts a bounded list range over the full geometry height", async () => {
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight }] = await Promise.all([
import("./Timeline"),
Expand All @@ -60,6 +113,8 @@ describe("Timeline row virtualization", () => {
usePlayerStore.setState({
duration: 60,
timelineReady: true,
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
// fallow-ignore-next-line code-duplication
elements: Array.from({ length: 1_000 }, (_, track) => ({
id: `clip-${track}`,
tag: "div",
Expand Down Expand Up @@ -101,4 +156,75 @@ describe("Timeline row virtualization", () => {
act(() => root.unmount());
usePlayerStore.getState().reset();
}, 10_000);

it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
import("../lib/timelineViewportBudgets"),
]);
usePlayerStore.setState({
duration: 1_000,
timelineReady: true,
zoomMode: "manual",
manualZoomPercent: 2_000,
selectedElementId: "clip-490",
selectedElementIds: new Set(["clip-490"]),
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
// fallow-ignore-next-line code-duplication
elements: Array.from({ length: 500 }, (_, index) => ({
id: `clip-${index}`,
tag: "div",
start: index * 2,
duration: 1,
track: 0,
})),
});

const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
await act(async () => {});

const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
expect(initialClips.length).toBeGreaterThan(1);
expect(initialClips.length).toBeLessThanOrEqual(
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
);
expect(initialGridCells.length).toBeLessThan(100);
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);

const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
expect(scroller).not.toBeNull();
if (scroller) {
scroller.scrollLeft = 8_000;
await act(async () => {
scroller.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
}

const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
expect(scrolledClips.length).toBeLessThanOrEqual(
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
);
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);

await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
await act(async () => {});
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300");
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();

act(() => root.unmount());
usePlayerStore.getState().reset();
});
});
14 changes: 10 additions & 4 deletions packages/studio/src/player/components/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import { TimelineClip } from "./TimelineClip";
import { TimelineLanes } from "./TimelineLanes";
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
import { renderClipChildren } from "./timelineClipChildren";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
import { isTimelineClipActive } from "./useTimelineActiveClips";

interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
Expand Down Expand Up @@ -57,8 +57,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
onRazorSplitAll,
} = useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging);
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
useTimelineRevealClip(scrollRef);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
Expand Down Expand Up @@ -121,6 +119,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
theme={props.theme}
beatAnalysis={props.beatAnalysis}
contentOrigin={props.contentOrigin}
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
/>

{/* Breathing room between the sticky ruler and the first track lane — the
Expand Down Expand Up @@ -152,7 +151,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
const rowIndex = displayTrackOrder.indexOf(strip.track);
if (rowIndex < 0) return null;
const loud = strip.kind === "hover";
return strip.intervals.map((gap) => (
const visibleIntervals = props.rowsVirtualized
? strip.intervals.filter(
(gap) =>
gap.start < props.renderTimeRange.end && gap.end > props.renderTimeRange.start,
)
: strip.intervals;
return visibleIntervals.map((gap) => (
<div
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
className="pointer-events-none absolute"
Expand Down Expand Up @@ -244,6 +249,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
}
isHovered={false}
isDragging={true}
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
hasCustomContent={!!props.renderClipContent}
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
theme={props.theme}
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface TimelineClipProps {
isSelected: boolean;
isHovered: boolean;
isDragging?: boolean;
isActive?: boolean;
hasCustomContent: boolean;
capabilities: TimelineEditCapabilities;
theme?: TimelineTheme;
Expand All @@ -35,6 +36,7 @@ export const TimelineClip = memo(function TimelineClip({
isSelected,
isHovered,
isDragging = false,
isActive = false,
hasCustomContent,
capabilities,
theme = defaultTimelineTheme,
Expand Down Expand Up @@ -88,6 +90,8 @@ export const TimelineClip = memo(function TimelineClip({
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
data-active={isActive ? "" : undefined}
tabIndex={-1}
className={clipClassName}
style={style}
title={
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/player/components/TimelineLaneTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { TimelineTheme } from "./timelineTheme";
import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
import type { TrackVisualStyle } from "./timelineIcons";
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";

Expand All @@ -21,6 +22,9 @@ export interface TimelineLaneBaseProps {
rowGeometry: TimelineRowGeometry;
virtualRows: readonly TimelineVirtualRow[];
rowsVirtualized: boolean;
clipIndex: TimelineClipIndex;
renderTimeRange: TimelineTimeRange;
pinnedClipIdentities: ReadonlySet<string>;
trackOrder: number[];
tracks: [number, TimelineElement[]][];
trackStyles: Map<number, TrackVisualStyle>;
Expand Down
Loading
Loading