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
7 changes: 4 additions & 3 deletions packages/studio/src/components/sidebar/AssetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import { ContextMenu } from "./AssetContextMenu";
import { usePlayerStore } from "../../player/store/playerStore";
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
Expand Down Expand Up @@ -133,7 +134,7 @@ export function AssetCard({
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);

const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
Expand All @@ -158,7 +159,7 @@ export function AssetCard({
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
requestClipReveal(clipKey);
requestTimelineFocus(timelineClipFocusId(clipKey));
return;
}
}
Expand All @@ -171,7 +172,7 @@ export function AssetCard({
asset,
projectId,
setSelectedElementId,
requestClipReveal,
requestTimelineFocus,
setPreviewAsset,
clearPreviewAsset,
],
Expand Down
7 changes: 4 additions & 3 deletions packages/studio/src/components/sidebar/AudioRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";

export function AudioRow({
projectId,
Expand Down Expand Up @@ -43,7 +44,7 @@ export function AudioRow({
// CapCut-style click behavior: drag-threshold gate.
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
Expand All @@ -67,7 +68,7 @@ export function AudioRow({
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
requestClipReveal(clipKey);
requestTimelineFocus(timelineClipFocusId(clipKey));
return;
}
}
Expand All @@ -80,7 +81,7 @@ export function AudioRow({
asset,
projectId,
setSelectedElementId,
requestClipReveal,
requestTimelineFocus,
setPreviewAsset,
clearPreviewAsset,
],
Expand Down
78 changes: 39 additions & 39 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
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";
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";

export {
generateTicks,
Expand Down Expand Up @@ -127,7 +127,6 @@ 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 clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
const hasKeyframedClips = useMemo(
Expand Down Expand Up @@ -183,7 +182,6 @@ export const Timeline = memo(function Timeline({
const ppsRef = useRef(100);
const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration;
// Declared before the fitPps derivation so the edit-pin wrappers can close over it.
const fitPpsRef = useRef(100);

const {
Expand Down Expand Up @@ -277,34 +275,6 @@ export const Timeline = memo(function Timeline({
expandedElements.length,
displayLayout.totalH,
]);
const { enabled: rowVirtualizationActive, virtualRows } = useTimelineRowVirtualization({
scrollRef,
viewport,
rowGeometry: displayLayout.rowGeometry,
sessionEpoch,
elements: expandedElements,
selectedElementId,
revealElementId: clipRevealRequest?.elementId ?? null,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
resizingRowKey: resizingClip?.element.track,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
syncScrollViewport,
});
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
});

const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
useTimelineGeometry({
viewportWidth: viewport.clientWidth,
Expand All @@ -321,6 +291,41 @@ export const Timeline = memo(function Timeline({
lastScrollLeftRef,
contentOrigin,
});
const { logicalRows, pinnedElementId, rowVirtualizationActive, virtualRows } =
useTimelineLogicalFocus({
scrollRef,
tracks,
layout: displayLayout,
laneCounts,
selectedElementId,
selectedElementIds,
gsapAnimations,
elements: expandedElements,
pixelsPerSecond: pps,
contentOrigin,
allowHorizontal: zoomMode === "manual",
viewport,
sessionEpoch,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
resizingRowKey: resizingClip?.element.track,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
syncScrollViewport,
});
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
});

const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
tracks,
viewport,
Expand All @@ -332,15 +337,10 @@ export const Timeline = memo(function Timeline({
resizingElementIds:
resizingClip?.groupPreview?.map((change) => change.key) ??
(resizingClip ? [resizingClip.element.key ?? resizingClip.element.id] : undefined),
revealElementId: clipRevealRequest?.elementId,
focusedElementId: pinnedElementId,
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,
Expand Down Expand Up @@ -476,8 +476,7 @@ export const Timeline = memo(function Timeline({
onDragLeave={assetDrop.handleAssetDragLeave}
onDrop={assetDrop.handleAssetDrop}
onPointerDown={(e) => {
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
// their own clicks — scrubbing here would preventDefault and eat them.
// Interactive controls own their clicks; scrubbing would preventDefault and eat them.
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
if (splitAllAtPointer(e)) return;
handlePointerDown(e);
Expand Down Expand Up @@ -505,6 +504,7 @@ export const Timeline = memo(function Timeline({
rowHeights={displayLayout.displayRowHeights}
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
logicalRows={logicalRows}
rowsVirtualized={rowVirtualizationActive}
clipIndex={clipIndex}
renderTimeRange={renderTimeRange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ describe("Timeline row virtualization", () => {
usePlayerStore.setState({
duration: 1_000,
timelineReady: true,
timelineProjectId: "project-a",
timelineSessionEpoch: 4,
zoomMode: "manual",
manualZoomPercent: 2_000,
selectedElementId: "clip-490",
Expand Down Expand Up @@ -215,12 +217,15 @@ describe("Timeline row virtualization", () => {
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"));
const { timelineClipFocusId } = await import("./timelineNavigationIdentity");
await act(async () =>
usePlayerStore.getState().requestTimelineFocus(timelineClipFocusId("clip-300")),
);
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
await act(async () => {});
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
expect(usePlayerStore.getState().timelineFocus?.id).toBe(timelineClipFocusId("clip-300"));
expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300");
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();

Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
import type { TimelineEditCapabilities } from "./timelineEditing";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { timelineClipFocusId } from "./timelineNavigationIdentity";

interface TimelineClipProps {
el: TimelineElement;
Expand Down Expand Up @@ -89,6 +90,7 @@ export const TimelineClip = memo(function TimelineClip({
<div
data-clip={isGestureActor ? undefined : "true"}
data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
data-timeline-focus-id={isGestureActor ? undefined : timelineClipFocusId(el.key ?? el.id)}
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/player/components/TimelineLaneTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";

/** Props shared by TimelineCanvas and its lane renderer. */
export interface TimelineLaneBaseProps {
Expand All @@ -21,6 +22,7 @@ export interface TimelineLaneBaseProps {
rowHeights: readonly number[];
rowGeometry: TimelineRowGeometry;
virtualRows: readonly TimelineVirtualRow[];
logicalRows: readonly TimelineLogicalRow[];
rowsVirtualized: boolean;
clipIndex: TimelineClipIndex;
renderTimeRange: TimelineTimeRange;
Expand Down
28 changes: 6 additions & 22 deletions packages/studio/src/player/components/TimelineLanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
import { buildTimelineLogicalRows, type TimelineLogicalRow } from "./timelineKeyboardNavigation";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";

interface TimelineLanesProps extends TimelineLaneBaseProps {
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
Expand All @@ -46,6 +46,7 @@ export function TimelineLanes({
displayTrackOrder,
rowGeometry,
virtualRows,
logicalRows,
rowsVirtualized,
clipIndex,
renderTimeRange,
Expand Down Expand Up @@ -98,32 +99,15 @@ export function TimelineLanes({
}: TimelineLanesProps) {
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
const { logicalRows, logicalRowsByTrack } = useMemo(() => {
const rows = buildTimelineLogicalRows({
tracks,
displayTrackOrder,
laneCounts,
selectedElementId,
selectedElementIds,
expandedClipIds: STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : new Set(),
gsapAnimations,
});
const logicalRowsByTrack = useMemo(() => {
const byTrack = new Map<number, TimelineLogicalRow[]>();
for (const logicalRow of rows) {
for (const logicalRow of logicalRows) {
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
trackRows.push(logicalRow);
byTrack.set(logicalRow.physicalTrackKey, trackRows);
}
return { logicalRows: rows, logicalRowsByTrack: byTrack };
}, [
displayTrackOrder,
expandedClipIds,
gsapAnimations,
laneCounts,
selectedElementId,
selectedElementIds,
tracks,
]);
return byTrack;
}, [logicalRows]);
const toggleClipExpandedTracked = (key: string) => {
const willExpand = !expandedClipIds.has(key);
trackStudioKeyframeLaneExpand({ expanded: willExpand });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function TimelinePropertyLanes({
key={group}
id={timelineLogicalRowCellId(timelinePropertyRowId(elementId, group), "content")}
data-property-group={group}
data-timeline-element-id={elementId}
data-timeline-property-lane=""
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute"
Expand Down
9 changes: 5 additions & 4 deletions packages/studio/src/player/components/TimelineTrackHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ function PropertyGroupHeaderRow({
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
}) {
const elementId = expandedElement.key ?? expandedElement.id;
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
Expand All @@ -408,10 +409,10 @@ function PropertyGroupHeaderRow({

return (
<div
id={timelineLogicalRowCellId(
timelinePropertyRowId(expandedElement.key ?? expandedElement.id, lane.group),
"header",
)}
id={timelineLogicalRowCellId(timelinePropertyRowId(elementId, lane.group), "header")}
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
data-timeline-element-id={elementId}
tabIndex={-1}
data-property-group={lane.group}
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute left-0 flex items-center gap-1 px-1.5 text-[10px] text-white/65"
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/player/components/TimelineTrackRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export function TimelineTrackRow({
aria-level={logicalRow.level}
aria-expanded={logicalRow.expandable ? logicalRow.expanded : undefined}
data-timeline-logical-row-id={logicalRow.id}
data-timeline-focus-id={logicalRow.id}
tabIndex={-1}
className="flex"
style={{ height }}
>
Expand Down
Loading
Loading