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
85 changes: 51 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 @@ -41,8 +40,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 @@ -119,9 +119,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 @@ -154,7 +153,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 @@ -302,28 +300,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 @@ -358,12 +374,6 @@ export const Timeline = memo(function Timeline({
onSeek,
contentOrigin,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
});

const {
rangeSelection,
setRangeSelection,
Expand Down Expand Up @@ -396,8 +406,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 @@ -488,6 +502,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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,73 @@ describe("Timeline row virtualization", () => {
act(() => root.unmount());
usePlayerStore.getState().reset();
});

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"]),
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
33 changes: 32 additions & 1 deletion packages/studio/src/player/components/TimelineLanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspec
import { renderClipChildren } from "./timelineClipChildren";
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";

interface TimelineLanesProps extends TimelineLaneBaseProps {
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
Expand All @@ -43,6 +45,9 @@ export function TimelineLanes({
rowGeometry,
virtualRows,
rowsVirtualized,
clipIndex,
renderTimeRange,
pinnedClipIdentities,
trackOrder,
tracks,
trackStyles,
Expand Down Expand Up @@ -110,6 +115,29 @@ export function TimelineLanes({
if (trackNum === undefined) return null;
const rowHeight = rowGeometry.getRowHeight(row);
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const indexedRenderElements = rowsVirtualized
? queryTimelineClipIndex(clipIndex, trackNum, renderTimeRange, pinnedClipIdentities)
: els;
const indexedRenderSet = new Set(indexedRenderElements);
const renderElements = rowsVirtualized
? els.filter((element) => {
if (indexedRenderSet.has(element)) return true;
if (
!multiDragPreview ||
!isMultiDragPassenger(element.key ?? element.id, multiDragPreview)
) {
return false;
}
const previewStart =
element.start +
multiDragPassengerOffsetPx(element.key ?? element.id, pps, multiDragPreview) /
pps;
const previewEnd = previewStart + Math.max(0, element.duration);
return previewEnd <= previewStart
? previewStart >= renderTimeRange.start && previewStart < renderTimeRange.end
: previewStart < renderTimeRange.end && previewEnd > renderTimeRange.start;
})
: els;
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
Expand Down Expand Up @@ -202,6 +230,7 @@ export function TimelineLanes({
? draggedClip.snapTime
: null
}
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
/>
{/* Beat dots on the active track (the one holding the selection),
falling back to the music track when nothing is selected. */}
Expand All @@ -210,6 +239,7 @@ export function TimelineLanes({
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
/>
)}
{isPendingTrack && (
Expand All @@ -229,7 +259,7 @@ export function TimelineLanes({
)}
{
// fallow-ignore-next-line complexity
els.map((el) => {
renderElements.map((el) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
// Only the track's active keyframe clip shows expanded lanes;
Expand Down Expand Up @@ -276,6 +306,7 @@ export function TimelineLanes({
isSelected={isSelected}
isHovered={hoveredClip === clipKey}
isDragging={false}
isActive={isTimelineClipActive(previewElement, currentTime)}
hasCustomContent={!!renderClipContent}
capabilities={capabilities}
theme={theme}
Expand Down
Loading
Loading