(
))}
>
diff --git a/packages/studio/src/player/components/TimelineTrackRow.tsx b/packages/studio/src/player/components/TimelineTrackRow.tsx
index de1865a41..d360edb20 100644
--- a/packages/studio/src/player/components/TimelineTrackRow.tsx
+++ b/packages/studio/src/player/components/TimelineTrackRow.tsx
@@ -1,14 +1,19 @@
import type { ReactNode } from "react";
+import { timelineLogicalRowCellId } from "./timelineNavigationIdentity";
+import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
interface TimelineTrackRowProps {
index: number;
rowKey: number;
- rowCount: number;
+ logicalRow: TimelineLogicalRow;
+ propertyRows: readonly TimelineLogicalRow[];
+ lanesId: string;
top: number;
height: number;
virtualized: boolean;
background: string;
borderColor: string;
+ rovingTargetId?: string | null;
children: ReactNode;
}
@@ -16,23 +21,24 @@ interface TimelineTrackRowProps {
export function TimelineTrackRow({
index,
rowKey,
- rowCount,
+ logicalRow,
+ propertyRows,
+ lanesId,
top,
height,
virtualized,
background,
borderColor,
+ rovingTargetId = null,
children,
}: TimelineTrackRowProps) {
return (
- {children}
+
+ {children}
+
+ {propertyRows.map((row) => {
+ const group = row.propertyGroup;
+ const keyframeCount = row.items.filter((item) => item.kind === "keyframe").length;
+ const easeCount = row.items.filter((item) => item.kind === "ease").length;
+ return (
+ // ponytail: aria-owns maps this hidden logical row onto the two visible
+ // property-lane cells without duplicating interactive controls.
+
+
+ {group}
+
+
+ {keyframeCount} keyframes, {easeCount} ease controls
+
+
+ );
+ })}
);
}
diff --git a/packages/studio/src/player/components/timelineDiamondTypes.ts b/packages/studio/src/player/components/timelineDiamondTypes.ts
index f4a437830..78a40f485 100644
--- a/packages/studio/src/player/components/timelineDiamondTypes.ts
+++ b/packages/studio/src/player/components/timelineDiamondTypes.ts
@@ -29,7 +29,7 @@ export interface TimelineClipDiamondsProps {
keyframesData: KeyframeCacheEntry;
clipWidthPx: number;
clipHeightPx: number;
- /** Needed to compare the playhead to keyframes in output-frame time. */
+ /** Needed to compare the playhead to keyframes and voice their absolute time. */
clipDuration: number;
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
* the bottom half so they clear the strip at the top. */
@@ -38,7 +38,11 @@ export interface TimelineClipDiamondsProps {
isSelected: boolean;
currentPercentage: number;
elementId: string;
+ /** Absolute clip start, used only to voice a keyframe's time in its label. */
+ clipStart?: number;
selectedKeyframes: ReadonlySet
;
+ /** Focus id of the one timeline control currently in the tab order. */
+ rovingTargetId?: string | null;
onClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onContextMenuKeyframe?: (
@@ -86,12 +90,15 @@ export interface TimelineDiamondLaneProps extends Omit<
}
export const DIAMOND_RATIO = 0.8;
-// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
-// slightly outside [0, 100] (from rounding or stale cache during the async
-// persist → reload cycle) are still rendered (the clip is overflow-visible) at
-// their true position rather than hidden.
-export const KF_MIN_PCT = -5;
-export const KF_MAX_PCT = 105;
+
+/** Absolute time of a keyframe, for the screen-reader label. */
+export function keyframeTimeLabel(
+ clipStart: number,
+ clipDuration: number,
+ percentage: number,
+): string {
+ return `${Number((clipStart + (clipDuration * percentage) / 100).toFixed(2))}s`;
+}
/**
* The full identity of a diamond, used by every callback and by the selection
diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts
index 7495a6754..041f2a8f6 100644
--- a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts
+++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts
@@ -5,9 +5,8 @@ import {
buildTimelineLogicalRows,
resolveTimelineFocusFallback,
resolveTimelineNavigationTarget,
- timelineClipFocusId,
- timelineTrackRowId,
} from "./timelineKeyboardNavigation";
+import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
function clip(id: string, track: number, start: number, duration = 2): TimelineElement {
return { id, track, start, duration, tag: "div" };
@@ -74,18 +73,31 @@ describe("buildTimelineLogicalRows", () => {
const rows = model();
expect(
- rows.map(({ physicalTrackKey, logicalIndex, level, parentId }) => ({
+ rows.map(({ physicalTrackKey, logicalIndex, level, parentId, expandable }) => ({
physicalTrackKey,
logicalIndex,
level,
parentId,
+ expandable,
})),
).toEqual([
- { physicalTrackKey: 1, logicalIndex: 0, level: 1, parentId: null },
- { physicalTrackKey: 1, logicalIndex: 1, level: 2, parentId: timelineTrackRowId(1) },
- { physicalTrackKey: 1, logicalIndex: 2, level: 2, parentId: timelineTrackRowId(1) },
- { physicalTrackKey: 2, logicalIndex: 3, level: 1, parentId: null },
- { physicalTrackKey: 3, logicalIndex: 4, level: 1, parentId: null },
+ { physicalTrackKey: 1, logicalIndex: 0, level: 1, parentId: null, expandable: true },
+ {
+ physicalTrackKey: 1,
+ logicalIndex: 1,
+ level: 2,
+ parentId: timelineTrackRowId(1),
+ expandable: false,
+ },
+ {
+ physicalTrackKey: 1,
+ logicalIndex: 2,
+ level: 2,
+ parentId: timelineTrackRowId(1),
+ expandable: false,
+ },
+ { physicalTrackKey: 2, logicalIndex: 3, level: 1, parentId: null, expandable: false },
+ { physicalTrackKey: 3, logicalIndex: 4, level: 1, parentId: null, expandable: false },
]);
expect(rows[0]?.expanded).toBe(true);
expect(rows[3]?.items).toEqual([]);
@@ -156,6 +168,12 @@ describe("resolveTimelineNavigationTarget", () => {
expect(
resolveTimelineNavigationTarget(rows, timelineClipFocusId("late"), "ArrowRight")?.id,
).toBe(timelineClipFocusId("late"));
+ expect(resolveTimelineNavigationTarget(rows, timelineTrackRowId(1), "ArrowRight")?.id).toBe(
+ timelineClipFocusId("early"),
+ );
+ expect(
+ resolveTimelineNavigationTarget(rows, timelineClipFocusId("early"), "ArrowLeft")?.id,
+ ).toBe(timelineTrackRowId(1));
expect(resolveTimelineNavigationTarget(rows, activeId, "Home")?.id).toBe(timelineTrackRowId(1));
expect(resolveTimelineNavigationTarget(rows, timelineTrackRowId(1), "End")?.id).toBe(
timelineClipFocusId("late"),
@@ -203,7 +221,16 @@ describe("resolveTimelineNavigationTarget", () => {
).toBe(timelineTrackRowId(1));
expect(
resolveTimelineNavigationTarget(rows, current, "End", { timelineBoundary: true })?.id,
- ).toBe(timelineClipFocusId("right"));
+ ).toBe(timelineTrackRowId(3));
+ });
+
+ it("returns from a property row to its parent with ArrowLeft", () => {
+ const rows = model();
+ const property = rows.find((row) => row.propertyGroup === "position")!;
+
+ expect(resolveTimelineNavigationTarget(rows, property.id, "ArrowLeft")?.id).toBe(
+ timelineTrackRowId(1),
+ );
});
it("breaks equal-distance vertical ties by time then stable identity", () => {
diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts
index b4eac8f25..7ba14a824 100644
--- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts
+++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts
@@ -5,6 +5,13 @@ import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
+import {
+ timelineClipFocusId,
+ timelineEaseFocusId,
+ timelineKeyframeFocusId,
+ timelinePropertyRowId,
+ timelineTrackRowId,
+} from "./timelineNavigationIdentity";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
export type TimelineNavigationKey =
@@ -17,6 +24,21 @@ export type TimelineNavigationKey =
| "PageUp"
| "PageDown";
+const NAVIGATION_KEYS: ReadonlySet = new Set([
+ "ArrowLeft",
+ "ArrowRight",
+ "ArrowUp",
+ "ArrowDown",
+ "Home",
+ "End",
+ "PageUp",
+ "PageDown",
+]);
+
+export function isTimelineNavigationKey(key: string): key is TimelineNavigationKey {
+ return NAVIGATION_KEYS.has(key);
+}
+
export interface TimelineLogicalItem {
id: string;
kind: "clip" | "keyframe" | "ease";
@@ -34,6 +56,8 @@ export interface TimelineLogicalRow {
logicalIndex: number;
level: 1 | 2;
parentId: string | null;
+ elementId: string | null;
+ expandable: boolean;
expanded: boolean;
propertyGroup?: PropertyGroupName;
items: readonly TimelineLogicalItem[];
@@ -58,31 +82,6 @@ export interface TimelineNavigationOptions {
timelineBoundary?: boolean;
}
-function stableId(kind: string, ...parts: Array): string {
- // Attribute-safe by construction; callers embedding it in CSS selectors must use CSS.escape.
- return JSON.stringify(["timeline", kind, ...parts]);
-}
-
-export function timelineTrackRowId(track: number): string {
- return stableId("track", track);
-}
-
-function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string {
- return stableId("property", elementId, group);
-}
-
-export function timelineClipFocusId(elementId: string): string {
- return stableId("clip", elementId);
-}
-
-function timelineKeyframeFocusId(elementId: string, target: TimelineKeyframeTarget): string {
- return stableId("keyframe", timelineKeyframeSelectionKey(elementId, target));
-}
-
-function timelineEaseFocusId(elementId: string, target: TimelineKeyframeTarget): string {
- return stableId("ease", timelineKeyframeSelectionKey(elementId, target));
-}
-
function elementId(element: TimelineElement): string {
return element.key ?? element.id;
}
@@ -205,6 +204,8 @@ export function buildTimelineLogicalRows({
logicalIndex: rows.length,
level: 1,
parentId: null,
+ elementId: activeId,
+ expandable: lanes.length > 0,
expanded,
items: clipItems(trackId, elements),
});
@@ -218,6 +219,8 @@ export function buildTimelineLogicalRows({
logicalIndex: rows.length,
level: 2,
parentId: trackId,
+ elementId: activeId,
+ expandable: false,
expanded: false,
propertyGroup: lane.group,
items: propertyItems(rowId, activeClip, lane.keyframes),
@@ -227,7 +230,7 @@ export function buildTimelineLogicalRows({
return rows;
}
-function locateTarget(rows: readonly TimelineLogicalRow[], id: string) {
+export function locateTimelineLogicalTarget(rows: readonly TimelineLogicalRow[], id: string) {
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex]!;
if (row.id === id) return { row, rowIndex, itemIndex: -1, target: row };
@@ -261,17 +264,21 @@ export function resolveTimelineNavigationTarget(
key: TimelineNavigationKey,
options: TimelineNavigationOptions = {},
): TimelineLogicalTarget | null {
- const current = locateTarget(rows, currentId);
+ const current = locateTimelineLogicalTarget(rows, currentId);
if (!current) return null;
const { row, rowIndex, itemIndex, target } = current;
if (key === "Home" || key === "End") {
const boundaryRow = options.timelineBoundary ? (key === "Home" ? rows[0] : rows.at(-1)) : row;
if (!boundaryRow) return target;
+ if (options.timelineBoundary) return boundaryRow;
return key === "Home" ? boundaryRow : (boundaryRow.items.at(-1) ?? boundaryRow);
}
if (key === "ArrowLeft") {
- if (itemIndex < 0) return row;
+ if (itemIndex < 0) {
+ if (!row.parentId) return row;
+ return locateTimelineLogicalTarget(rows, row.parentId)?.target ?? row;
+ }
return itemIndex === 0 ? row : row.items[itemIndex - 1]!;
}
if (key === "ArrowRight") {
@@ -304,26 +311,26 @@ export function resolveTimelineFocusFallback(
nextRows: readonly TimelineLogicalRow[],
currentId: string,
): TimelineLogicalTarget | null {
- const unchanged = locateTarget(nextRows, currentId);
+ const unchanged = locateTimelineLogicalTarget(nextRows, currentId);
if (unchanged) return unchanged.target;
- const previous = locateTarget(previousRows, currentId);
+ const previous = locateTimelineLogicalTarget(previousRows, currentId);
if (!previous) return null;
if (previous.itemIndex >= 0) {
for (let index = previous.itemIndex - 1; index >= 0; index -= 1) {
- const candidate = locateTarget(nextRows, previous.row.items[index]!.id);
+ const candidate = locateTimelineLogicalTarget(nextRows, previous.row.items[index]!.id);
if (candidate) return candidate.target;
}
for (let index = previous.itemIndex + 1; index < previous.row.items.length; index += 1) {
- const candidate = locateTarget(nextRows, previous.row.items[index]!.id);
+ const candidate = locateTimelineLogicalTarget(nextRows, previous.row.items[index]!.id);
if (candidate) return candidate.target;
}
}
- const survivingRow = locateTarget(nextRows, previous.row.id);
+ const survivingRow = locateTimelineLogicalTarget(nextRows, previous.row.id);
if (survivingRow) return survivingRow.target;
if (previous.row.parentId) {
- const parent = locateTarget(nextRows, previous.row.parentId);
+ const parent = locateTimelineLogicalTarget(nextRows, previous.row.parentId);
if (parent) return parent.target;
}
return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null;
diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts
index b16a03b3b..e6c192526 100644
--- a/packages/studio/src/player/components/timelineLaneProps.ts
+++ b/packages/studio/src/player/components/timelineLaneProps.ts
@@ -9,6 +9,7 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us
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 the scroll container ({@link import("./TimelineCanvas")}) and
@@ -27,6 +28,8 @@ export interface TimelineLaneBaseProps {
rowHeights: readonly number[];
rowGeometry: TimelineRowGeometry;
virtualRows: readonly TimelineVirtualRow[];
+ logicalRows: readonly TimelineLogicalRow[];
+ focusedTargetId: string | null;
rowsVirtualized: boolean;
clipIndex: TimelineClipIndex;
renderTimeRange: TimelineTimeRange;
diff --git a/packages/studio/src/player/components/timelineNavigationIdentity.ts b/packages/studio/src/player/components/timelineNavigationIdentity.ts
new file mode 100644
index 000000000..2641190b7
--- /dev/null
+++ b/packages/studio/src/player/components/timelineNavigationIdentity.ts
@@ -0,0 +1,38 @@
+import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
+import {
+ timelineKeyframeSelectionKey,
+ type TimelineKeyframeTarget,
+} from "./timelineKeyframeIdentity";
+
+function stableId(kind: string, ...parts: Array): string {
+ // Attribute-safe by construction; callers embedding it in CSS selectors must use CSS.escape.
+ return JSON.stringify(["timeline", kind, ...parts]);
+}
+
+export function timelineTrackRowId(track: number): string {
+ return stableId("track", track);
+}
+
+export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string {
+ return stableId("property", elementId, group);
+}
+
+export function timelineLogicalRowCellId(
+ lanesId: string,
+ rowId: string,
+ cell: "header" | "content",
+): string {
+ return `${lanesId}-${stableId("cell", rowId, cell)}`;
+}
+
+export function timelineClipFocusId(elementId: string): string {
+ return stableId("clip", elementId);
+}
+
+export function timelineKeyframeFocusId(elementId: string, target: TimelineKeyframeTarget): string {
+ return stableId("keyframe", timelineKeyframeSelectionKey(elementId, target));
+}
+
+export function timelineEaseFocusId(elementId: string, target: TimelineKeyframeTarget): string {
+ return stableId("ease", timelineKeyframeSelectionKey(elementId, target));
+}
diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts
index e5bf746f5..f48941a40 100644
--- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts
+++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts
@@ -1,10 +1,7 @@
-import { useMemo, type RefObject } from "react";
+import { useMemo } from "react";
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
-import type { TimelineElement } from "../store/playerStore";
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
-import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
-import { useTimelineRevealClip } from "./useTimelineRevealClip";
interface UseTimelineClipRenderWindowInput {
tracks: Parameters[0];
@@ -15,17 +12,10 @@ interface UseTimelineClipRenderWindowInput {
selectedElementId?: string;
draggedElementId?: string;
resizingElementIds?: readonly string[];
- revealElementId?: string;
+ focusedElementId?: string;
focusedEaseElementId?: string;
clipContextMenuElementId?: string;
keyframeContextMenuElementId?: string;
- focusedElementId?: string;
- scrollRef: RefObject;
- elements: readonly TimelineElement[];
- rowGeometry: TimelineRowGeometry;
- allowHorizontalReveal: boolean;
- rowVirtualizationActive: boolean;
- sessionEpoch: number;
}
export function useTimelineClipRenderWindow({
@@ -37,17 +27,10 @@ export function useTimelineClipRenderWindow({
selectedElementId,
draggedElementId,
resizingElementIds,
- revealElementId,
+ focusedElementId,
focusedEaseElementId,
clipContextMenuElementId,
keyframeContextMenuElementId,
- focusedElementId,
- scrollRef,
- elements,
- rowGeometry,
- allowHorizontalReveal,
- rowVirtualizationActive,
- sessionEpoch,
}: UseTimelineClipRenderWindowInput) {
const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]);
const renderTimeRange = useMemo(
@@ -61,11 +44,10 @@ export function useTimelineClipRenderWindow({
selectedElementId,
draggedElementId,
...(resizingElementIds ?? []),
- revealElementId,
+ focusedElementId,
focusedEaseElementId,
clipContextMenuElementId,
keyframeContextMenuElementId,
- focusedElementId,
].filter((identity): identity is string => identity !== undefined),
),
[
@@ -75,21 +57,8 @@ export function useTimelineClipRenderWindow({
focusedElementId,
keyframeContextMenuElementId,
resizingElementIds,
- revealElementId,
selectedElementId,
],
);
- useTimelineRevealClip({
- scrollRef,
- elements,
- rowGeometry,
- pixelsPerSecond,
- contentOrigin,
- allowHorizontal: allowHorizontalReveal,
- deferFocusUntilViewportUpdate: rowVirtualizationActive,
- focusedElementId,
- viewportVersion: viewport,
- sessionEpoch,
- });
return { clipIndex, renderTimeRange, pinnedClipIdentities };
}
diff --git a/packages/studio/src/player/components/useTimelineFocusCoordinator.test.tsx b/packages/studio/src/player/components/useTimelineFocusCoordinator.test.tsx
new file mode 100644
index 000000000..32439881c
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineFocusCoordinator.test.tsx
@@ -0,0 +1,174 @@
+// @vitest-environment happy-dom
+
+import React, { act, useMemo, useRef } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { usePlayerStore } from "../store/playerStore";
+import { createTimelineRowGeometry } from "./timelineLayout";
+import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
+import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
+import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const element = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
+const elements = [element];
+const syncScrollViewport = () => {};
+const clipId = timelineClipFocusId("hero");
+const rowId = timelineTrackRowId(1);
+const rows: readonly TimelineLogicalRow[] = [
+ {
+ id: rowId,
+ kind: "row",
+ physicalTrackKey: 1,
+ logicalIndex: 0,
+ level: 1,
+ parentId: null,
+ elementId: null,
+ expandable: false,
+ expanded: false,
+ items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }],
+ },
+];
+
+function Harness({
+ mountedId,
+ logicalRows = rows,
+ projectId = "project-a",
+}: {
+ mountedId?: string;
+ logicalRows?: readonly TimelineLogicalRow[];
+ projectId?: string;
+}) {
+ const scrollRef = useRef(null);
+ const rowGeometry = useMemo(() => {
+ const rowKeys = [...new Set(logicalRows.map((row) => row.physicalTrackKey))];
+ return createTimelineRowGeometry(
+ rowKeys,
+ rowKeys.map(() => 48),
+ );
+ }, [logicalRows]);
+ const focus = useTimelineFocusCoordinator({
+ scrollRef,
+ logicalRows,
+ elements,
+ rowGeometry,
+ pixelsPerSecond: 100,
+ contentOrigin: 32,
+ allowHorizontal: true,
+ viewportVersion: mountedId,
+ projectId,
+ sessionEpoch: 1,
+ syncScrollViewport,
+ });
+ return (
+ {
+ scrollRef.current = node;
+ if (node) {
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
+ Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
+ }
+ }}
+ data-focus={`${focus.focusedRowKey}:${focus.pinnedElementId}`}
+ >
+ {mountedId &&
}
+
+ );
+}
+
+let host: HTMLDivElement;
+let root: Root;
+beforeEach(() => {
+ usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 });
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ usePlayerStore.getState().reset();
+ document.body.replaceChildren();
+});
+
+describe("useTimelineFocusCoordinator", () => {
+ it("pins and scrolls from model coordinates until mount, then permits repeat reveal", async () => {
+ usePlayerStore.getState().requestTimelineFocus(clipId);
+ await act(async () => root.render());
+ const scroll = host.firstElementChild as HTMLDivElement;
+ expect(scroll.scrollLeft).toBe(1_944);
+ expect(scroll.dataset.focus).toBe("1:hero");
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
+
+ await act(async () => root.render());
+ const firstNonce = usePlayerStore.getState().timelineFocus?.nonce;
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
+
+ scroll.scrollLeft = 0;
+ await act(async () => usePlayerStore.getState().requestTimelineFocus(clipId));
+ await act(async () => root.render());
+ expect(scroll.scrollLeft).toBe(1_944);
+ expect(usePlayerStore.getState().timelineFocus?.nonce).toBe((firstNonce ?? 0) + 1);
+ });
+
+ it("focuses the latest request when it replaces an unmounted request", async () => {
+ usePlayerStore.getState().requestTimelineFocus(rowId);
+ usePlayerStore.getState().requestTimelineFocus(clipId);
+
+ await act(async () => root.render());
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
+ });
+
+ it("does not retry an unchanged unmounted target on an unrelated render", async () => {
+ usePlayerStore.getState().requestTimelineFocus(clipId);
+ await act(async () => root.render());
+ const scroll = host.firstElementChild as HTMLDivElement;
+ const querySelector = vi.spyOn(scroll, "querySelector");
+
+ await act(async () => root.render());
+
+ expect(querySelector).not.toHaveBeenCalled();
+ });
+
+ it("ignores stale scope and never queries outside its own viewport", async () => {
+ usePlayerStore.getState().requestTimelineFocus(clipId);
+ await act(async () => root.render());
+ expect(document.activeElement).not.toBe(host.querySelector("[data-timeline-focus-id]"));
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
+
+ const externalTarget = document.createElement("div");
+ externalTarget.dataset.timelineFocusId = clipId;
+ externalTarget.tabIndex = -1;
+ document.body.append(externalTarget);
+ await act(async () => root.render());
+ expect(document.activeElement).not.toBe(externalTarget);
+ });
+
+ it("persists a deterministic parent-row fallback when a focused clip disappears", async () => {
+ usePlayerStore.getState().requestTimelineFocus(clipId);
+ await act(async () => root.render());
+
+ const collapsedRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
+ await act(async () => root.render());
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(rowId);
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(rowId);
+ });
+
+ it("persists the next surviving row when both a clip and its parent track disappear", async () => {
+ const nextRowId = timelineTrackRowId(2);
+ const nextRow: TimelineLogicalRow = {
+ ...rows[0]!,
+ id: nextRowId,
+ physicalTrackKey: 2,
+ items: [],
+ };
+ usePlayerStore.getState().requestTimelineFocus(clipId);
+ await act(async () => root.render());
+
+ await act(async () => root.render());
+
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(nextRowId);
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(nextRowId);
+ });
+});
diff --git a/packages/studio/src/player/components/useTimelineFocusCoordinator.ts b/packages/studio/src/player/components/useTimelineFocusCoordinator.ts
new file mode 100644
index 000000000..3f678d490
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineFocusCoordinator.ts
@@ -0,0 +1,221 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from "react";
+import type { TimelineElement } from "../store/playerStore";
+import { usePlayerStore } from "../store/playerStore";
+import type { TimelineFocusRequest } from "../store/timelineFocusState";
+import type { TimelineRowGeometry } from "./timelineLayout";
+import { CLIP_Y, RULER_H } from "./timelineLayout";
+import {
+ locateTimelineLogicalTarget,
+ resolveTimelineFocusFallback,
+ type TimelineLogicalRow,
+ type TimelineLogicalTarget,
+} from "./timelineKeyboardNavigation";
+import { computeRevealScroll } from "./timelineRevealScroll";
+
+interface TimelineFocusCoordinatorInput {
+ scrollRef: RefObject;
+ logicalRows: readonly TimelineLogicalRow[];
+ elements: readonly TimelineElement[];
+ rowGeometry: TimelineRowGeometry;
+ pixelsPerSecond: number;
+ contentOrigin: number;
+ allowHorizontal: boolean;
+ viewportVersion: unknown;
+ projectId: string | null;
+ sessionEpoch: number;
+ syncScrollViewport: (element: HTMLDivElement) => void;
+}
+
+export interface TimelineFocusCoordinatorState {
+ focusedTargetId: string | null;
+ focusedRowKey: number | undefined;
+ pinnedElementId: string | undefined;
+}
+
+interface ResolvedFocus {
+ target: TimelineLogicalTarget;
+ row: TimelineLogicalRow;
+}
+
+function isCurrentRequest(
+ request: TimelineFocusRequest | null,
+ projectId: string | null,
+ sessionEpoch: number,
+): request is TimelineFocusRequest {
+ return (
+ request !== null && request.projectId === projectId && request.sessionEpoch === sessionEpoch
+ );
+}
+
+function focusElement(container: HTMLDivElement, targetId: string): boolean {
+ // data-timeline-focus-id is the reserved DOM bridge between logical IDs and this actor.
+ const target = container.querySelector(
+ `[data-timeline-focus-id=${CSS.escape(targetId)}]`,
+ );
+ if (!target) return false;
+ if (target.ownerDocument.activeElement === target) return true;
+ target.setAttribute("data-reveal-highlight", "true");
+ target.focus({ preventScroll: true });
+ if (target.ownerDocument.activeElement !== target) {
+ target.removeAttribute("data-reveal-highlight");
+ return false;
+ }
+ // The reveal highlight is intentionally one-shot; ordinary refocus uses the standard focus ring.
+ target.addEventListener("blur", () => target.removeAttribute("data-reveal-highlight"), {
+ once: true,
+ });
+ return true;
+}
+
+// This is one atomic two-axis reveal calculation; each branch selects target geometry only.
+// fallow-ignore-next-line complexity
+function scrollToTarget(
+ container: HTMLDivElement,
+ resolution: ResolvedFocus,
+ elements: readonly TimelineElement[],
+ rowGeometry: TimelineRowGeometry,
+ pixelsPerSecond: number,
+ contentOrigin: number,
+ allowHorizontal: boolean,
+): boolean {
+ const rowIndex = rowGeometry.getRowIndex(resolution.row.physicalTrackKey);
+ if (rowIndex < 0) return false;
+ const elementId =
+ resolution.target.kind === "row" ? resolution.row.elementId : resolution.target.elementId;
+ const element = elementId
+ ? elements.find((candidate) => (candidate.key ?? candidate.id) === elementId)
+ : undefined;
+ const pointTime = resolution.target.kind === "row" ? null : resolution.target.time;
+ const left = element && resolution.target.kind === "clip" ? element.start : pointTime;
+ const right =
+ element && resolution.target.kind === "clip" ? element.start + element.duration : pointTime;
+ const rowTop = rowGeometry.getRowTop(rowIndex);
+ const target = computeRevealScroll({
+ scrollLeft: container.scrollLeft,
+ scrollTop: container.scrollTop,
+ viewportWidth: container.clientWidth,
+ viewportHeight: container.clientHeight,
+ clipLeft: contentOrigin + (left ?? 0) * pixelsPerSecond,
+ clipRight: contentOrigin + (right ?? 0) * pixelsPerSecond,
+ clipTop: rowTop + CLIP_Y,
+ clipBottom: rowTop + rowGeometry.getRowHeight(rowIndex) - CLIP_Y,
+ stickyLeft: contentOrigin,
+ stickyTop: RULER_H,
+ allowHorizontal: allowHorizontal && left !== null,
+ });
+ if (target.left !== null) container.scrollLeft = target.left;
+ if (target.top !== null) container.scrollTop = target.top;
+ return target.left !== null || target.top !== null;
+}
+
+/** Model-first focus actor; mounting is a consequence of its returned pins. */
+// Resolution, fallback, reveal, and focus form one ordered state machine.
+// fallow-ignore-next-line complexity
+export function useTimelineFocusCoordinator({
+ scrollRef,
+ logicalRows,
+ elements,
+ rowGeometry,
+ pixelsPerSecond,
+ contentOrigin,
+ allowHorizontal,
+ viewportVersion,
+ projectId,
+ sessionEpoch,
+ syncScrollViewport,
+}: TimelineFocusCoordinatorInput): TimelineFocusCoordinatorState {
+ const request = usePlayerStore((state) => state.timelineFocus);
+ const previousRowsRef = useRef(logicalRows);
+ const resolvedRef = useRef<{ nonce: number; id: string } | null>(null);
+ const appliedRef = useRef<{ nonce: number; id: string } | null>(null);
+ const resolution = useMemo(() => {
+ if (isCurrentRequest(request, projectId, sessionEpoch)) {
+ if (resolvedRef.current?.nonce !== request.nonce) {
+ resolvedRef.current = { nonce: request.nonce, id: request.id };
+ }
+ const resolvedId = resolvedRef.current.id;
+ let located = locateTimelineLogicalTarget(logicalRows, resolvedId);
+ if (!located) {
+ const fallback = resolveTimelineFocusFallback(
+ previousRowsRef.current,
+ logicalRows,
+ resolvedId,
+ );
+ if (fallback) {
+ // ponytail: Cache the fallback under this nonce so render-phase resolution
+ // converges before the effect persists the replacement request.
+ resolvedRef.current = { nonce: request.nonce, id: fallback.id };
+ located = locateTimelineLogicalTarget(logicalRows, fallback.id);
+ }
+ }
+ return located ? { target: located.target, row: located.row } : null;
+ }
+ resolvedRef.current = null;
+ return null;
+ }, [logicalRows, projectId, request, sessionEpoch]);
+
+ useLayoutEffect(() => {
+ previousRowsRef.current = logicalRows;
+ }, [logicalRows]);
+
+ // Apply a request exactly once after its logical target and DOM node are both ready.
+ // fallow-ignore-next-line complexity
+ useEffect(() => {
+ if (!isCurrentRequest(request, projectId, sessionEpoch)) return;
+ if (!resolution) {
+ usePlayerStore.getState().clearTimelineFocus(request.nonce);
+ return;
+ }
+ if (resolution.target.id !== request.id) {
+ usePlayerStore.getState().requestTimelineFocus(resolution.target.id);
+ return;
+ }
+ if (
+ appliedRef.current?.nonce === request.nonce &&
+ appliedRef.current.id === resolution.target.id
+ ) {
+ return;
+ }
+ const container = scrollRef.current;
+ if (!container) return;
+ if (
+ scrollToTarget(
+ container,
+ resolution,
+ elements,
+ rowGeometry,
+ pixelsPerSecond,
+ contentOrigin,
+ allowHorizontal,
+ )
+ ) {
+ syncScrollViewport(container);
+ }
+ if (!focusElement(container, resolution.target.id)) return;
+ appliedRef.current = { nonce: request.nonce, id: resolution.target.id };
+ }, [
+ allowHorizontal,
+ contentOrigin,
+ elements,
+ pixelsPerSecond,
+ projectId,
+ request,
+ resolution,
+ rowGeometry,
+ scrollRef,
+ sessionEpoch,
+ syncScrollViewport,
+ viewportVersion,
+ ]);
+
+ const pinnedElementId = resolution
+ ? resolution.target.kind === "row"
+ ? (resolution.row.elementId ?? undefined)
+ : resolution.target.elementId
+ : undefined;
+ return {
+ focusedTargetId: resolution?.target.id ?? null,
+ focusedRowKey: resolution?.row.physicalTrackKey,
+ pinnedElementId,
+ };
+}
diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.test.tsx b/packages/studio/src/player/components/useTimelineKeyboardActor.test.tsx
new file mode 100644
index 000000000..117b0f931
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineKeyboardActor.test.tsx
@@ -0,0 +1,267 @@
+// @vitest-environment happy-dom
+
+import React, { act, useRef } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { usePlayerStore } from "../store/playerStore";
+import { createTimelineRowGeometry } from "./timelineLayout";
+import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
+import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
+
+Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
+ configurable: true,
+ value: true,
+});
+
+const rows: readonly TimelineLogicalRow[] = [
+ {
+ id: "track-1",
+ kind: "row",
+ physicalTrackKey: 1,
+ logicalIndex: 0,
+ level: 1,
+ parentId: null,
+ elementId: "clip-1",
+ expandable: true,
+ expanded: false,
+ items: [{ id: "clip-1", kind: "clip", rowId: "track-1", elementId: "clip-1", time: 1 }],
+ },
+ {
+ id: "track-2",
+ kind: "row",
+ physicalTrackKey: 2,
+ logicalIndex: 1,
+ level: 1,
+ parentId: null,
+ elementId: "clip-2",
+ expandable: false,
+ expanded: false,
+ items: [{ id: "clip-2", kind: "clip", rowId: "track-2", elementId: "clip-2", time: 2 }],
+ },
+ {
+ id: "track-3",
+ kind: "row",
+ physicalTrackKey: 3,
+ logicalIndex: 2,
+ level: 1,
+ parentId: null,
+ elementId: null,
+ expandable: false,
+ expanded: false,
+ items: [],
+ },
+];
+
+interface HarnessProps {
+ focusedTargetId?: string | null;
+ logicalRows?: readonly TimelineLogicalRow[];
+ rowHeights?: readonly number[];
+ onToggleRow?: (target: TimelineLogicalRow) => void;
+}
+
+function Harness({
+ focusedTargetId = null,
+ logicalRows = rows,
+ rowHeights = logicalRows.map(() => 48),
+ onToggleRow = vi.fn(),
+}: HarnessProps) {
+ const scrollRef = useRef(null);
+ const keyboard = useTimelineKeyboardActor({
+ logicalRows,
+ focusedTargetId,
+ rowGeometry: createTimelineRowGeometry(
+ [...new Set(logicalRows.map((row) => row.physicalTrackKey))],
+ rowHeights,
+ ),
+ scrollRef,
+ onToggleRow,
+ });
+ return (
+
+ {logicalRows
+ .flatMap((row) => [row, ...row.items])
+ .map((target) =>
+ target.kind === "row" ? (
+
+ {target.id}
+
+
+ ) : (
+
+ ),
+ )}
+
+ );
+}
+
+function renderHarness(props: React.ComponentProps = {}) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => root.render());
+ return { host, root };
+}
+
+function key(target: Element, value: string, init: KeyboardEventInit = {}) {
+ const event = new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: value,
+ ...init,
+ });
+ act(() => target.dispatchEvent(event));
+ return event;
+}
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ usePlayerStore.setState({ timelineFocus: null, timelineFocusNonce: 0 });
+});
+
+describe("useTimelineKeyboardActor", () => {
+ it("exposes exactly one roving target and persists focused logical identity", () => {
+ const { host, root } = renderHarness({ focusedTargetId: "clip-2" });
+ expect(host.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
+ expect(host.querySelectorAll("[data-native-control]")).toHaveLength(3);
+ expect(
+ [...host.querySelectorAll("[data-native-control]")].every(
+ (el) => el.tabIndex === 0,
+ ),
+ ).toBe(true);
+ const target = host.querySelector('[data-timeline-focus-id="track-3"]')!;
+ act(() => target.focus());
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3");
+ act(() => root.unmount());
+ });
+
+ it("requests navigation focus without clicking or seeking", () => {
+ const { host, root } = renderHarness({ focusedTargetId: "clip-1" });
+ const target = host.querySelector('[data-timeline-focus-id="clip-1"]')!;
+ const click = vi.fn();
+ target.addEventListener("click", click);
+ const event = key(target, "ArrowDown");
+ expect(event.defaultPrevented).toBe(true);
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-2");
+ expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
+ expect(click).not.toHaveBeenCalled();
+ act(() => root.unmount());
+ });
+
+ it("supports modified timeline boundaries and viewport-sized paging", () => {
+ const { host, root } = renderHarness({ focusedTargetId: "clip-2" });
+ const viewport = host.firstElementChild as HTMLDivElement;
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 48 });
+ const target = host.querySelector('[data-timeline-focus-id="clip-2"]')!;
+ key(target, "End", { metaKey: true });
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3");
+ key(target, "PageUp");
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-1");
+ act(() => root.unmount());
+ });
+
+ it("sizes paging around off-screen focus instead of the current viewport", () => {
+ const logicalRows: readonly TimelineLogicalRow[] = [
+ rows[0]!,
+ {
+ ...rows[0]!,
+ id: "property-1",
+ logicalIndex: 1,
+ level: 2,
+ parentId: "track-1",
+ expandable: false,
+ items: [],
+ },
+ {
+ ...rows[0]!,
+ id: "property-2",
+ logicalIndex: 2,
+ level: 2,
+ parentId: "track-1",
+ expandable: false,
+ items: [],
+ },
+ { ...rows[1]!, logicalIndex: 3 },
+ { ...rows[2]!, logicalIndex: 4 },
+ ];
+ const { host, root } = renderHarness({
+ focusedTargetId: "track-3",
+ logicalRows,
+ rowHeights: [104, 48, 48],
+ });
+ const viewport = host.firstElementChild as HTMLDivElement;
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 47 });
+ const target = host.querySelector('[data-timeline-focus-id="track-3"]')!;
+ key(target, "PageUp");
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-2");
+ act(() => root.unmount());
+ });
+
+ it("supports APG disclosure arrows plus Enter and Space", () => {
+ const onToggleRow = vi.fn();
+ const { host, root } = renderHarness({ focusedTargetId: "track-1", onToggleRow });
+ const row = host.querySelector('[data-timeline-focus-id="track-1"]')!;
+ const clip = host.querySelector('[data-timeline-focus-id="clip-1"]')!;
+ const nativeClick = vi.fn();
+ clip.addEventListener("click", nativeClick);
+ expect(key(row, "ArrowRight").defaultPrevented).toBe(true);
+ expect(onToggleRow).toHaveBeenCalledWith(rows[0]);
+ expect(key(row, " ").defaultPrevented).toBe(true);
+ expect(onToggleRow).toHaveBeenCalledWith(rows[0]);
+ const enter = key(clip, "Enter");
+ expect(enter.defaultPrevented).toBe(false);
+ // dispatchEvent does not synthesize a browser default action, so model it explicitly.
+ if (!enter.defaultPrevented) act(() => clip.click());
+ expect(nativeClick).toHaveBeenCalledOnce();
+ expect(onToggleRow).toHaveBeenCalledTimes(2);
+ act(() => root.unmount());
+ });
+
+ it("collapses expanded rows and leaves native header controls to the browser", () => {
+ const onToggleRow = vi.fn();
+ const expandedRows = [{ ...rows[0]!, expanded: true }, ...rows.slice(1)];
+ const { host, root } = renderHarness({
+ focusedTargetId: "track-1",
+ logicalRows: expandedRows,
+ onToggleRow,
+ });
+ const row = host.querySelector('[data-timeline-focus-id="track-1"]')!;
+ expect(key(row, "ArrowLeft").defaultPrevented).toBe(true);
+ expect(onToggleRow).toHaveBeenCalledWith(expandedRows[0]);
+
+ usePlayerStore.setState({ timelineFocus: null, timelineFocusNonce: 0 });
+ const control = host.querySelector('[data-native-control="track-1"]')!;
+ const nativeClick = vi.fn();
+ control.addEventListener("click", nativeClick);
+ act(() => control.focus());
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
+ const enter = key(control, "Enter");
+ expect(enter.defaultPrevented).toBe(false);
+ // dispatchEvent does not synthesize a browser default action, so model it explicitly.
+ if (!enter.defaultPrevented) act(() => control.click());
+ expect(nativeClick).toHaveBeenCalledOnce();
+ expect(onToggleRow).toHaveBeenCalledOnce();
+ act(() => root.unmount());
+ });
+
+ it("dispatches the existing scoped context-menu callback", () => {
+ const { host, root } = renderHarness({ focusedTargetId: "clip-1" });
+ const target = host.querySelector('[data-timeline-focus-id="clip-1"]')!;
+ const context = vi.fn((event: Event) => event.preventDefault());
+ target.addEventListener("contextmenu", context);
+ key(target, "F10", { shiftKey: true });
+ expect(context).toHaveBeenCalledOnce();
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.ts b/packages/studio/src/player/components/useTimelineKeyboardActor.ts
new file mode 100644
index 000000000..3969a5f84
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineKeyboardActor.ts
@@ -0,0 +1,147 @@
+import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
+import { usePlayerStore } from "../store/playerStore";
+import type { TimelineRowGeometry } from "./timelineLayout";
+import {
+ isTimelineNavigationKey,
+ locateTimelineLogicalTarget,
+ resolveTimelineNavigationTarget,
+ type TimelineLogicalRow,
+} from "./timelineKeyboardNavigation";
+
+interface TimelineKeyboardActorInput {
+ logicalRows: readonly TimelineLogicalRow[];
+ focusedTargetId: string | null;
+ rowGeometry: TimelineRowGeometry;
+ scrollRef: RefObject;
+ onToggleRow: (target: TimelineLogicalRow) => void;
+}
+
+function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
+ if (!(event.target instanceof Element)) return null;
+ // Header actions stay native Tab stops because they have no row-level shortcut.
+ // The nearest interactive ancestor wins so their events never masquerade as row events.
+ const target = event.target.closest(
+ "button, input, select, textarea, a[href], [contenteditable], [data-timeline-focus-id]",
+ );
+ return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
+}
+
+function viewportPageSize(
+ logicalRowCountByTrack: ReadonlyMap,
+ focusedTrackKey: number,
+ geometry: TimelineRowGeometry,
+ viewport: HTMLDivElement | null,
+): number {
+ if (!viewport || logicalRowCountByTrack.size === 0) return 1;
+ const focusedRow = geometry.getRowIndex(focusedTrackKey);
+ const first = Math.max(
+ 0,
+ focusedRow >= 0 ? focusedRow : Math.floor(geometry.getRowFromY(viewport.scrollTop)),
+ );
+ const pageTop = focusedRow >= 0 ? geometry.getRowTop(focusedRow) : viewport.scrollTop;
+ // Stay just inside the viewport so an exact row boundary does not count the next row.
+ const last = Math.min(
+ geometry.rowKeys.length - 1,
+ Math.floor(geometry.getRowFromY(pageTop + Math.max(0, viewport.clientHeight - 0.001))),
+ );
+ let count = 0;
+ for (let index = first; index <= last; index += 1) {
+ count += logicalRowCountByTrack.get(geometry.rowKeys[index]!) ?? 0;
+ }
+ return Math.max(1, count);
+}
+
+function openContextMenu(target: HTMLElement): void {
+ const bounds = target.getBoundingClientRect();
+ target.dispatchEvent(
+ new MouseEvent("contextmenu", {
+ bubbles: true,
+ cancelable: true,
+ clientX: bounds.left + bounds.width / 2,
+ clientY: bounds.top + bounds.height / 2,
+ }),
+ );
+}
+
+/** The timeline's sole keyboard actor; controls only describe their logical identity. */
+export function useTimelineKeyboardActor({
+ logicalRows,
+ focusedTargetId,
+ rowGeometry,
+ scrollRef,
+ onToggleRow,
+}: TimelineKeyboardActorInput) {
+ const rovingTargetId =
+ (focusedTargetId && locateTimelineLogicalTarget(logicalRows, focusedTargetId)?.target.id) ??
+ logicalRows[0]?.id ??
+ null;
+ const logicalRowCountByTrack = useMemo(() => {
+ const counts = new Map();
+ for (const row of logicalRows) {
+ counts.set(row.physicalTrackKey, (counts.get(row.physicalTrackKey) ?? 0) + 1);
+ }
+ return counts;
+ }, [logicalRows]);
+
+ const onFocus = useCallback(
+ (event: FocusEvent) => {
+ const id = eventTarget(event)?.dataset.timelineFocusId;
+ if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id);
+ },
+ // ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice.
+ [focusedTargetId],
+ );
+
+ const onKeyDown = useCallback(
+ // One handler owns navigation, context-menu, and disclosure keyboard semantics.
+ // fallow-ignore-next-line complexity
+ (event: KeyboardEvent) => {
+ const targetElement = eventTarget(event);
+ const id = targetElement?.dataset.timelineFocusId;
+ if (!targetElement || !id) return;
+ const located = locateTimelineLogicalTarget(logicalRows, id);
+ if (!located) return;
+
+ if (isTimelineNavigationKey(event.key)) {
+ if (
+ located.target.kind === "row" &&
+ ((event.key === "ArrowRight" && located.target.expandable && !located.target.expanded) ||
+ (event.key === "ArrowLeft" && located.target.expandable && located.target.expanded))
+ ) {
+ event.preventDefault();
+ onToggleRow(located.target);
+ return;
+ }
+ const next = resolveTimelineNavigationTarget(logicalRows, id, event.key, {
+ pageSize: viewportPageSize(
+ logicalRowCountByTrack,
+ located.row.physicalTrackKey,
+ rowGeometry,
+ scrollRef.current,
+ ),
+ timelineBoundary: event.ctrlKey || event.metaKey,
+ });
+ event.preventDefault();
+ if (next && next.id !== id) usePlayerStore.getState().requestTimelineFocus(next.id);
+ return;
+ }
+ if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
+ event.preventDefault();
+ openContextMenu(targetElement);
+ return;
+ }
+ if (
+ (event.key !== "Enter" && event.key !== " ") ||
+ located.target.kind !== "row" ||
+ !located.target.expandable
+ ) {
+ return;
+ }
+ event.preventDefault();
+ onToggleRow(located.target);
+ },
+ [logicalRowCountByTrack, logicalRows, onToggleRow, rowGeometry, scrollRef],
+ );
+
+ return { rovingTargetId, onFocus, onKeyDown };
+}
diff --git a/packages/studio/src/player/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts
new file mode 100644
index 000000000..36f6de3db
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts
@@ -0,0 +1,80 @@
+import type { RefObject } from "react";
+import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
+import type { TimelineElement } from "../store/playerStore";
+import type { TimelineRowGeometry } from "./timelineLayout";
+import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
+import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
+import { usePlayerStore } from "../store/playerStore";
+import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
+import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
+
+interface TimelineLogicalFocusInput {
+ scrollRef: RefObject;
+ tracks: readonly (readonly [number, readonly TimelineElement[]])[];
+ layout: { displayTrackOrder: readonly number[]; rowGeometry: TimelineRowGeometry };
+ laneCounts: ReadonlyMap;
+ selectedElementId: string | null;
+ selectedElementIds: ReadonlySet;
+ gsapAnimations: ReadonlyMap;
+ elements: readonly TimelineElement[];
+ pixelsPerSecond: number;
+ contentOrigin: number;
+ allowHorizontal: boolean;
+ viewport: TimelineScrollViewportSnapshot;
+ sessionEpoch: number;
+ draggedRowKey?: number;
+ resizingElementIds?: readonly string[];
+ clipContextMenuRowKey?: number;
+ keyframeContextMenuRowKey?: number;
+ lastScrollLeftRef: RefObject;
+ syncScrollViewport: (element: HTMLDivElement) => void;
+}
+
+export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
+ const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
+ const projectId = usePlayerStore((state) => state.timelineProjectId);
+ const logicalRows = useTimelineLogicalRows({
+ tracks: input.tracks,
+ displayTrackOrder: input.layout.displayTrackOrder,
+ laneCounts: input.laneCounts,
+ selectedElementId: input.selectedElementId,
+ selectedElementIds: input.selectedElementIds,
+ expandedClipIds,
+ gsapAnimations: input.gsapAnimations,
+ });
+ const focus = useTimelineFocusCoordinator({
+ scrollRef: input.scrollRef,
+ logicalRows,
+ elements: input.elements,
+ rowGeometry: input.layout.rowGeometry,
+ pixelsPerSecond: input.pixelsPerSecond,
+ contentOrigin: input.contentOrigin,
+ allowHorizontal: input.allowHorizontal,
+ viewportVersion: input.viewport,
+ projectId,
+ sessionEpoch: input.sessionEpoch,
+ syncScrollViewport: input.syncScrollViewport,
+ });
+ const rows = useTimelineRowVirtualization({
+ scrollRef: input.scrollRef,
+ viewport: input.viewport,
+ rowGeometry: input.layout.rowGeometry,
+ sessionEpoch: input.sessionEpoch,
+ elements: input.elements,
+ selectedElementId: input.selectedElementId,
+ focusedRowKey: focus.focusedRowKey,
+ draggedRowKey: input.draggedRowKey,
+ resizingElementIds: input.resizingElementIds,
+ clipContextMenuRowKey: input.clipContextMenuRowKey,
+ keyframeContextMenuRowKey: input.keyframeContextMenuRowKey,
+ lastScrollLeftRef: input.lastScrollLeftRef,
+ syncScrollViewport: input.syncScrollViewport,
+ });
+ return {
+ logicalRows,
+ ...focus,
+ rowVirtualizationActive: rows.enabled,
+ virtualRows: rows.virtualRows,
+ timelineFocusProps: rows.timelineFocusProps,
+ };
+}
diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx
new file mode 100644
index 000000000..f20f2699a
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx
@@ -0,0 +1,57 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import { usePlayerStore } from "../store/playerStore";
+import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
+import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const tracks = Array.from(
+ { length: 1_000 },
+ (_, track) =>
+ [
+ track,
+ [{ id: `clip-${track}`, tag: "div", track, start: track, duration: 1 }],
+ ] as const satisfies readonly [number, readonly TimelineElement[]],
+);
+const displayTrackOrder = tracks.map(([track]) => track);
+const laneCounts = new Map();
+const selectedElementIds = new Set();
+const expandedClipIds = new Set();
+const gsapAnimations = new Map();
+
+function Harness({ snapshots }: { snapshots: Array }) {
+ usePlayerStore((state) => state.requestedSeekTime);
+ const logicalRows = useTimelineLogicalRows({
+ tracks,
+ displayTrackOrder,
+ laneCounts,
+ selectedElementId: null,
+ selectedElementIds,
+ expandedClipIds,
+ gsapAnimations,
+ });
+ snapshots.push(logicalRows);
+ return null;
+}
+
+afterEach(() => usePlayerStore.getState().reset());
+
+describe("useTimelineLogicalRows", () => {
+ it("preserves the dense logical model across an unrelated store update", () => {
+ const host = document.createElement("div");
+ const root = createRoot(host);
+ const snapshots: Array = [];
+ act(() => root.render());
+ const first = snapshots.at(-1);
+
+ act(() => usePlayerStore.setState({ requestedSeekTime: 1 }));
+
+ expect(snapshots.at(-1)).toBe(first);
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.ts b/packages/studio/src/player/components/useTimelineLogicalRows.ts
new file mode 100644
index 000000000..eabe5d642
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineLogicalRows.ts
@@ -0,0 +1,47 @@
+import { useMemo } from "react";
+import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
+import type { TimelineElement } from "../store/playerStore";
+import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
+
+interface TimelineLogicalRowsInput {
+ tracks: readonly (readonly [number, readonly TimelineElement[]])[];
+ displayTrackOrder: readonly number[];
+ laneCounts: ReadonlyMap;
+ selectedElementId: string | null;
+ selectedElementIds: ReadonlySet;
+ expandedClipIds: ReadonlySet;
+ gsapAnimations: ReadonlyMap;
+}
+
+/** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
+export function useTimelineLogicalRows({
+ tracks,
+ displayTrackOrder,
+ laneCounts,
+ selectedElementId,
+ selectedElementIds,
+ expandedClipIds,
+ gsapAnimations,
+}: TimelineLogicalRowsInput) {
+ return useMemo(
+ () =>
+ buildTimelineLogicalRows({
+ tracks,
+ displayTrackOrder,
+ laneCounts,
+ selectedElementId,
+ selectedElementIds,
+ expandedClipIds,
+ gsapAnimations,
+ }),
+ [
+ displayTrackOrder,
+ expandedClipIds,
+ gsapAnimations,
+ laneCounts,
+ selectedElementId,
+ selectedElementIds,
+ tracks,
+ ],
+ );
+}
diff --git a/packages/studio/src/player/components/useTimelineRevealClip.test.tsx b/packages/studio/src/player/components/useTimelineRevealClip.test.tsx
deleted file mode 100644
index 9afc4ec21..000000000
--- a/packages/studio/src/player/components/useTimelineRevealClip.test.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-// @vitest-environment happy-dom
-
-import React, { act, useRef } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, describe, expect, it } from "vitest";
-import type { TimelineElement } from "../store/playerStore";
-import { usePlayerStore } from "../store/playerStore";
-import { createTimelineRowGeometry } from "./timelineLayout";
-import { useTimelineRevealClip } from "./useTimelineRevealClip";
-
-Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
-
-const element: TimelineElement = {
- id: "hero",
- tag: "div",
- start: 20,
- duration: 2,
- track: 1,
-};
-const geometry = createTimelineRowGeometry([1], [48]);
-
-function createHarnessRoot() {
- const host = document.createElement("div");
- document.body.append(host);
- return { host, root: createRoot(host) };
-}
-
-interface HarnessProps {
- mounted: boolean;
- version: number;
- width?: number;
- height?: number;
- deferFocusUntilViewportUpdate?: boolean;
- focusedElementId?: string;
-}
-
-function Harness({
- mounted,
- version,
- width = 300,
- height = 100,
- deferFocusUntilViewportUpdate = false,
- focusedElementId,
-}: HarnessProps) {
- const scrollRef = useRef(null);
- useTimelineRevealClip({
- scrollRef,
- elements: [element],
- rowGeometry: geometry,
- pixelsPerSecond: 100,
- contentOrigin: 32,
- allowHorizontal: true,
- deferFocusUntilViewportUpdate,
- focusedElementId,
- viewportVersion: version,
- sessionEpoch: 1,
- });
- return (
- {
- scrollRef.current = node;
- if (node) {
- Object.defineProperty(node, "clientWidth", { configurable: true, value: width });
- Object.defineProperty(node, "clientHeight", { configurable: true, value: height });
- }
- }}
- >
- {mounted &&
}
-
- );
-}
-
-async function renderHarness(root: Root, props: HarnessProps): Promise {
- await act(async () => root.render());
-}
-
-afterEach(() => {
- usePlayerStore.getState().reset();
- document.body.replaceChildren();
-});
-
-describe("useTimelineRevealClip", () => {
- it("scrolls from model coordinates, then consumes only after the clip mounts", async () => {
- const { host, root } = createHarnessRoot();
- usePlayerStore.getState().requestClipReveal("hero");
-
- await renderHarness(root, { mounted: false, version: 0 });
- const scroll = host.firstElementChild as HTMLDivElement;
- expect(scroll.scrollLeft).toBe(1_944);
- expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
-
- await renderHarness(root, { mounted: true, version: 1 });
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
- expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
-
- scroll.scrollLeft = 0;
- await act(async () => usePlayerStore.getState().requestClipReveal("hero"));
- await renderHarness(root, { mounted: true, version: 2 });
- expect(scroll.scrollLeft).toBe(1_944);
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
- await act(async () => root.unmount());
- });
-
- it("consumes an invalid target without scrolling", async () => {
- const { root } = createHarnessRoot();
- usePlayerStore.getState().requestClipReveal("missing");
- await renderHarness(root, { mounted: false, version: 0 });
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
- await act(async () => root.unmount());
- });
-
- it("keeps a reveal pending through zero-size viewport and virtualized focus handoff", async () => {
- const { host, root } = createHarnessRoot();
- usePlayerStore.getState().requestClipReveal("hero");
-
- await renderHarness(root, { mounted: true, version: 0, width: 0, height: 0 });
- const scroll = host.firstElementChild as HTMLDivElement;
- expect(scroll.scrollLeft).toBe(0);
- expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
- expect(document.activeElement?.getAttribute("data-el-id")).not.toBe("hero");
-
- await renderHarness(root, {
- mounted: true,
- version: 1,
- deferFocusUntilViewportUpdate: true,
- });
- expect(scroll.scrollLeft).toBe(1_944);
- expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
- await renderHarness(root, {
- mounted: true,
- version: 2,
- deferFocusUntilViewportUpdate: true,
- focusedElementId: "hero",
- });
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
- expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
- await act(async () => root.unmount());
- });
-});
diff --git a/packages/studio/src/player/components/useTimelineRevealClip.ts b/packages/studio/src/player/components/useTimelineRevealClip.ts
deleted file mode 100644
index cffa13e9c..000000000
--- a/packages/studio/src/player/components/useTimelineRevealClip.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { useEffect, useRef } from "react";
-import type { TimelineElement } from "../store/playerStore";
-import { usePlayerStore } from "../store/playerStore";
-import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
-import { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout";
-import { computeRevealScroll } from "./timelineRevealScroll";
-
-interface UseTimelineRevealClipInput {
- scrollRef: React.RefObject;
- elements: readonly TimelineElement[];
- rowGeometry: TimelineRowGeometry;
- pixelsPerSecond: number;
- contentOrigin: number;
- allowHorizontal: boolean;
- deferFocusUntilViewportUpdate: boolean;
- focusedElementId?: string;
- viewportVersion: unknown;
- sessionEpoch: number;
-}
-
-function escapeSelectorValue(value: string): string {
- return typeof CSS !== "undefined" && typeof CSS.escape === "function"
- ? CSS.escape(value)
- : value.replace(/["\\]/g, "\\$&");
-}
-
-function scrollToTimelineElement(
- container: HTMLDivElement,
- element: TimelineElement,
- row: number,
- rowGeometry: TimelineRowGeometry,
- pixelsPerSecond: number,
- contentOrigin: number,
- allowHorizontal: boolean,
-): boolean {
- const clipLeft = contentOrigin + element.start * pixelsPerSecond;
- const target = computeRevealScroll({
- scrollLeft: container.scrollLeft,
- scrollTop: container.scrollTop,
- viewportWidth: container.clientWidth,
- viewportHeight: container.clientHeight,
- clipLeft,
- clipRight: clipLeft + Math.max(element.duration * pixelsPerSecond, 4),
- clipTop: rowGeometry.getRowTop(row) + CLIP_Y,
- clipBottom: rowGeometry.getRowTop(row) + rowGeometry.getRowHeight(row) - CLIP_Y,
- stickyLeft: contentOrigin,
- stickyTop: RULER_H,
- allowHorizontal,
- });
- if (target.left !== null) container.scrollLeft = target.left;
- if (target.top !== null) container.scrollTop = target.top;
- const didScroll = target.left !== null || target.top !== null;
- if (didScroll) container.dispatchEvent(new Event("scroll"));
- return didScroll;
-}
-
-function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean {
- const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`);
- if (!(clip instanceof HTMLElement)) return false;
- const alreadyHighlighted = clip.hasAttribute("data-reveal-highlight");
- clip.setAttribute("data-reveal-highlight", "true");
- clip.focus({ preventScroll: true });
- if (document.activeElement !== clip) {
- clip.removeAttribute("data-reveal-highlight");
- return false;
- }
- if (!alreadyHighlighted) {
- clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), {
- once: true,
- });
- }
- return true;
-}
-
-function focusAndConsumeReveal(
- container: HTMLDivElement,
- request: { elementId: string; nonce: number },
- deferUntilFocusPin: boolean,
- focusedElementId?: string,
-): void {
- if (!focusRevealedElement(container, request.elementId)) return;
- if (deferUntilFocusPin && focusedElementId !== request.elementId) return;
- if (usePlayerStore.getState().clipRevealRequest === request) {
- usePlayerStore.getState().clearClipRevealRequest();
- }
-}
-
-function resolveRevealTarget(
- elements: readonly TimelineElement[],
- rowGeometry: TimelineRowGeometry,
- elementId: string,
-): { element: TimelineElement; row: number } | null {
- const element = elements.find((candidate) => getTimelineElementIdentity(candidate) === elementId);
- if (!element) return null;
- const row = rowGeometry.getRowIndex(element.track);
- return row < 0 ? null : { element, row };
-}
-
-function shouldScrollReveal(
- previous: { request: { elementId: string; nonce: number }; sessionEpoch: number } | null,
- request: { elementId: string; nonce: number },
- sessionEpoch: number,
-): boolean {
- return previous?.request !== request || previous.sessionEpoch !== sessionEpoch;
-}
-
-/** Coordinate-first reveal; the request remains pinned until its clip mounts. */
-export function useTimelineRevealClip({
- scrollRef,
- elements,
- rowGeometry,
- pixelsPerSecond,
- contentOrigin,
- allowHorizontal,
- deferFocusUntilViewportUpdate,
- focusedElementId,
- viewportVersion,
- sessionEpoch,
-}: UseTimelineRevealClipInput): void {
- const revealRequest = usePlayerStore((state) => state.clipRevealRequest);
- const scrolledRequestRef = useRef<{
- request: { elementId: string; nonce: number };
- sessionEpoch: number;
- } | null>(null);
-
- useEffect(() => {
- if (!revealRequest) {
- scrolledRequestRef.current = null;
- return;
- }
- const target = resolveRevealTarget(elements, rowGeometry, revealRequest.elementId);
- if (!target) {
- usePlayerStore.getState().clearClipRevealRequest();
- return;
- }
- const container = scrollRef.current;
- if (!container) return;
- if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
-
- if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) {
- scrolledRequestRef.current = { request: revealRequest, sessionEpoch };
- const didScroll = scrollToTimelineElement(
- container,
- target.element,
- target.row,
- rowGeometry,
- pixelsPerSecond,
- contentOrigin,
- allowHorizontal,
- );
- // Keep the reveal pin alive until the scroll snapshot catches up. If the
- // request were consumed here, horizontal windowing could unmount and
- // recreate the focused clip between the programmatic scroll and the next
- // viewport publication.
- if (didScroll && deferFocusUntilViewportUpdate) return;
- }
-
- // Focus is the durable row/clip pin. Do not consume the reveal pin until
- // the focus listener has published that replacement, or windowing can
- // briefly unmount and recreate the element between the two owners.
- focusAndConsumeReveal(
- container,
- revealRequest,
- deferFocusUntilViewportUpdate,
- focusedElementId,
- );
- }, [
- allowHorizontal,
- contentOrigin,
- deferFocusUntilViewportUpdate,
- elements,
- focusedElementId,
- pixelsPerSecond,
- revealRequest,
- rowGeometry,
- scrollRef,
- sessionEpoch,
- viewportVersion,
- ]);
-}
diff --git a/packages/studio/src/player/components/useTimelineRowVirtualization.ts b/packages/studio/src/player/components/useTimelineRowVirtualization.ts
index 23297d435..239663155 100644
--- a/packages/studio/src/player/components/useTimelineRowVirtualization.ts
+++ b/packages/studio/src/player/components/useTimelineRowVirtualization.ts
@@ -20,9 +20,9 @@ interface UseTimelineRowVirtualizationInput {
viewport: TimelineScrollViewportSnapshot;
rowGeometry: TimelineRowGeometry;
sessionEpoch: number;
- elements: TimelineElement[];
+ elements: readonly TimelineElement[];
selectedElementId: string | null;
- revealElementId: string | null;
+ focusedRowKey?: number;
draggedRowKey?: number;
resizingElementIds?: readonly string[];
clipContextMenuRowKey?: number;
@@ -31,19 +31,12 @@ interface UseTimelineRowVirtualizationInput {
syncScrollViewport: (element: HTMLDivElement, isScrolling?: boolean) => void;
}
-interface TimelineDomFocusPin {
- readonly rowKey?: number;
- readonly elementId?: string;
-}
-
-function getTimelineDomFocusPin(target: EventTarget | null): TimelineDomFocusPin | undefined {
+function getFocusedTimelineRowKey(target: EventTarget | null): number | undefined {
if (!(target instanceof Element)) return undefined;
const value = target.closest("[data-timeline-row-key]")?.dataset.timelineRowKey;
- const parsedRowKey = value === undefined ? undefined : Number(value);
- const rowKey =
- parsedRowKey !== undefined && Number.isFinite(parsedRowKey) ? parsedRowKey : undefined;
- const elementId = target.closest("[data-el-id]")?.dataset.elId;
- return rowKey === undefined && elementId === undefined ? undefined : { rowKey, elementId };
+ if (value === undefined) return undefined;
+ const rowKey = Number(value);
+ return Number.isFinite(rowKey) ? rowKey : undefined;
}
export function useTimelineRowVirtualization({
@@ -53,7 +46,7 @@ export function useTimelineRowVirtualization({
sessionEpoch,
elements,
selectedElementId,
- revealElementId,
+ focusedRowKey,
draggedRowKey,
resizingElementIds,
clipContextMenuRowKey,
@@ -62,21 +55,17 @@ export function useTimelineRowVirtualization({
syncScrollViewport,
}: UseTimelineRowVirtualizationInput) {
const enabled = STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED;
- const [domFocusPin, setDomFocusPin] = useState();
+ const [domFocusedRowKey, setDomFocusedRowKey] = useState();
const onTimelineFocus = useCallback((event: ReactFocusEvent) => {
- setDomFocusPin(getTimelineDomFocusPin(event.target));
+ setDomFocusedRowKey(getFocusedTimelineRowKey(event.target));
}, []);
const onTimelineBlur = useCallback((event: ReactFocusEvent) => {
- setDomFocusPin(getTimelineDomFocusPin(event.relatedTarget));
+ setDomFocusedRowKey(getFocusedTimelineRowKey(event.relatedTarget));
}, []);
- const focusIdentity = useMemo(
+ const selectedIdentity = useMemo(
() => resolveTimelineFocusIdentity(elements, selectedElementId),
[elements, selectedElementId],
);
- const revealIdentity = useMemo(
- () => resolveTimelineFocusIdentity(elements, revealElementId),
- [elements, revealElementId],
- );
const resizingRowKeys = useMemo(
() =>
resizingElementIds
@@ -89,16 +78,18 @@ export function useTimelineRowVirtualization({
[
draggedRowKey,
...resizingRowKeys,
- revealIdentity?.rowKey,
+ selectedIdentity?.rowKey,
+ focusedRowKey,
clipContextMenuRowKey,
keyframeContextMenuRowKey,
].filter((rowKey): rowKey is number => rowKey !== undefined),
[
clipContextMenuRowKey,
draggedRowKey,
+ focusedRowKey,
keyframeContextMenuRowKey,
resizingRowKeys,
- revealIdentity,
+ selectedIdentity,
],
);
const virtualRows = useTimelineVirtualRows({
@@ -108,7 +99,7 @@ export function useTimelineRowVirtualization({
rowGeometry,
sessionEpoch,
pinnedRowKeys,
- focusedRowKey: domFocusPin?.rowKey ?? focusIdentity?.rowKey,
+ focusedRowKey: domFocusedRowKey ?? focusedRowKey,
});
const previousLayoutRef = useRef(rowGeometry);
@@ -141,7 +132,6 @@ export function useTimelineRowVirtualization({
return {
enabled,
virtualRows,
- focusedElementId: domFocusPin?.elementId,
timelineFocusProps: { onFocus: onTimelineFocus, onBlur: onTimelineBlur },
};
}
diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts
index 799e3c388..d03b4238c 100644
--- a/packages/studio/src/player/store/playerStore.test.ts
+++ b/packages/studio/src/player/store/playerStore.test.ts
@@ -492,29 +492,36 @@ describe("usePlayerStore", () => {
});
});
- describe("clipRevealRequest", () => {
- it("starts null and carries the requested element id", () => {
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
- usePlayerStore.getState().requestClipReveal("el-1");
- expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
- });
+ describe("timelineFocus", () => {
+ it("stamps project scope and carries the requested logical id", () => {
+ usePlayerStore.getState().beginTimelineSession("project-a");
+ usePlayerStore.getState().requestTimelineFocus("clip:el-1");
+ expect(usePlayerStore.getState().timelineFocus).toMatchObject({
+ id: "clip:el-1",
+ projectId: "project-a",
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
+ });
+ const store = usePlayerStore.getState();
+ store.requestTimelineFocus("clip:el-1");
+ const first = usePlayerStore.getState().timelineFocus;
+ if (!first) throw new Error("expected timeline focus request");
+ store.clearTimelineFocus(first.nonce);
+ store.reset();
+ store.requestTimelineFocus("clip:el-1");
+ const second = usePlayerStore.getState().timelineFocus;
+ expect(second?.nonce).toBe(first.nonce + 1);
- it("bumps the nonce on repeat requests for the same clip", () => {
- usePlayerStore.getState().requestClipReveal("el-1");
- const first = usePlayerStore.getState().clipRevealRequest;
- usePlayerStore.getState().requestClipReveal("el-1");
- const second = usePlayerStore.getState().clipRevealRequest;
- expect(second?.nonce).not.toBe(first?.nonce);
- });
-
- it("clears via clearClipRevealRequest and on reset", () => {
- usePlayerStore.getState().requestClipReveal("el-1");
- usePlayerStore.getState().clearClipRevealRequest();
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
-
- usePlayerStore.getState().requestClipReveal("el-2");
- usePlayerStore.getState().reset();
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
+ store.beginTimelineSession("project-a");
+ store.requestTimelineFocus("clip:el-1");
+ const stale = usePlayerStore.getState().timelineFocus;
+ if (!stale) throw new Error("expected timeline focus request");
+ store.requestTimelineFocus("clip:el-2");
+ const replacement = usePlayerStore.getState().timelineFocus;
+ if (!replacement) throw new Error("expected replacement timeline focus request");
+ store.clearTimelineFocus(stale.nonce);
+ expect(usePlayerStore.getState().timelineFocus).toBe(replacement);
+ store.beginTimelineSession("project-b");
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
});
});
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 7686e7a87..349f9a445 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -10,6 +10,7 @@ import {
} from "../../utils/studioUiPreferences";
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
+import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
export type { KeyframeCacheEntry } from "./keyframeSlice";
export { liveTime } from "./liveTime";
@@ -221,15 +222,10 @@ interface PlayerState extends KeyframeSlice {
requestSeek: (time: number) => void;
clearSeekRequest: () => void;
- /**
- * Request the timeline to scroll a clip into view (e.g. clicking an
- * already-added asset card in the sidebar). Consumed and cleared by
- * useTimelineRevealClip. The nonce makes repeat requests for the same
- * clip observable so a second click re-reveals after the user scrolls away.
- */
- clipRevealRequest: { elementId: string; nonce: number } | null;
- requestClipReveal: (elementId: string) => void;
- clearClipRevealRequest: () => void;
+ timelineFocus: TimelineFocusRequest | null;
+ timelineFocusNonce: number;
+ requestTimelineFocus: (id: string) => void;
+ clearTimelineFocus: (nonce: number) => void;
lintFindingsByElement: Map;
setLintFindingsByElement: (map: Map) => void;
@@ -301,8 +297,8 @@ export function createTimelineResetState() {
focusedEaseSegment: null,
selectedElementIds: new Set(),
requestedSeekTime: null,
- clipRevealRequest: null,
lintFindingsByElement: new Map(),
+ timelineFocus: null,
keyframeCache: new Map(),
gsapAnimations: new Map(),
beatAnalysis: null,
@@ -375,12 +371,23 @@ export const usePlayerStore = create((set, get) => ({
requestSeek: (time) => set({ requestedSeekTime: time }),
clearSeekRequest: () => set({ requestedSeekTime: null }),
- clipRevealRequest: null,
- requestClipReveal: (elementId) =>
- set((s) => ({
- clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
- })),
- clearClipRevealRequest: () => set({ clipRevealRequest: null }),
+ timelineFocus: null,
+ timelineFocusNonce: 0,
+ requestTimelineFocus: (id) =>
+ set((s) => {
+ const nonce = s.timelineFocusNonce + 1;
+ return {
+ timelineFocusNonce: nonce,
+ timelineFocus: createTimelineFocusRequest(
+ id,
+ s.timelineProjectId,
+ s.timelineSessionEpoch,
+ nonce,
+ ),
+ };
+ }),
+ clearTimelineFocus: (nonce) =>
+ set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)),
lintFindingsByElement: new Map(),
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
diff --git a/packages/studio/src/player/store/timelineFocusState.ts b/packages/studio/src/player/store/timelineFocusState.ts
new file mode 100644
index 000000000..739d5ad05
--- /dev/null
+++ b/packages/studio/src/player/store/timelineFocusState.ts
@@ -0,0 +1,15 @@
+export interface TimelineFocusRequest {
+ id: string;
+ projectId: string | null;
+ sessionEpoch: number;
+ nonce: number;
+}
+
+export function createTimelineFocusRequest(
+ id: string,
+ projectId: string | null,
+ sessionEpoch: number,
+ nonce: number,
+): TimelineFocusRequest {
+ return { id, projectId, sessionEpoch, nonce };
+}