docs(studio): document timeline keyboard navigation (#3031)

* feat(studio): expose timeline treegrid semantics

* feat(studio): coordinate logical timeline focus

* feat(studio): add timeline keyboard controls

* docs(studio): document timeline keyboard navigation
This commit is contained in:
Miguel Ángel
2026-08-04 17:09:07 -07:00
committed by GitHub
parent 0f1333c80d
commit 7bf425b7a9
41 changed files with 1911 additions and 653 deletions
@@ -44,6 +44,8 @@ export function LayerDisclosureRow({
>
<button
type="button"
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
tabIndex={-1}
aria-expanded={isExpanded}
aria-controls={lanesId}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
@@ -244,6 +244,25 @@ describe("Timeline provider boundary", () => {
expect(propertyLane.style.background).toBe("");
expect(propertyLane.style.border).toBe("");
expect(propertyLane.style.borderRadius).toBe("");
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
const semanticRows = treegrid?.querySelectorAll<HTMLElement>('[role="row"]') ?? [];
expect(treegrid?.getAttribute("aria-rowcount")).toBe("3");
expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([
"1",
"2",
"3",
]);
expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1");
expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true");
expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2");
expect(semanticRows[1]?.textContent).toContain("position");
expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe(
headerLane.id,
);
expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe(
propertyLane.id,
);
expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
@@ -295,12 +314,12 @@ describe("Timeline provider boundary", () => {
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
expect(rows).toHaveLength(12);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");
expect(treegrid?.getAttribute("aria-rowcount")).toBe("12");
expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
expect(rows[11]?.getAttribute("aria-rowindex")).toBe("12");
act(() => root.unmount());
});
@@ -41,10 +41,10 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
export {
shouldAutoScrollTimeline,
@@ -122,7 +122,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 labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
@@ -171,7 +170,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 {
pinZoomBeforeEdit,
@@ -269,34 +267,6 @@ export const Timeline = memo(function Timeline({
expandedElements.length,
displayLayout.totalH,
]);
const rowWindow = useTimelineRowVirtualization({
scrollRef,
viewport,
rowGeometry: displayLayout.rowGeometry,
sessionEpoch,
elements: expandedElements,
selectedElementId,
revealElementId: clipRevealRequest?.elementId ?? null,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
resizingElementIds,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
syncScrollViewport,
});
const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow;
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,
@@ -313,6 +283,40 @@ export const Timeline = memo(function Timeline({
lastScrollLeftRef,
contentOrigin,
});
const timelineFocus = 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,
resizingElementIds,
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,
@@ -322,7 +326,7 @@ export const Timeline = memo(function Timeline({
selectedElementId: selectedElementId ?? undefined,
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
resizingElementIds,
revealElementId: clipRevealRequest?.elementId,
focusedElementId: timelineFocus.pinnedElementId,
focusedEaseElementId: focusedEaseSegment?.elementId,
clipContextMenuElementId: clipContextMenu
? getTimelineElementIdentity(clipContextMenu.element)
@@ -330,13 +334,6 @@ export const Timeline = memo(function Timeline({
keyframeContextMenuElementId: kfContextMenu
? getTimelineElementIdentity(kfContextMenu.element)
: undefined,
focusedElementId,
scrollRef,
elements: expandedElements,
rowGeometry: displayLayout.rowGeometry,
allowHorizontalReveal: zoomMode === "manual",
rowVirtualizationActive,
sessionEpoch,
});
useTimelineActiveClips({
scrollRef,
@@ -423,7 +420,7 @@ export const Timeline = memo(function Timeline({
displayDuration,
pps,
timeDisplayMode,
rowVirtualizationActive ? renderTimeRange : undefined,
timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
);
const getPreviewElement = useCallback(
@@ -469,13 +466,12 @@ export const Timeline = memo(function Timeline({
recordTimelineScroll(e.currentTarget);
syncScrollViewport(e.currentTarget, true);
}}
{...rowWindow.timelineFocusProps}
{...timelineFocus.timelineFocusProps}
onDragOver={assetDrop.handleAssetDragOver}
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);
@@ -502,8 +498,10 @@ export const Timeline = memo(function Timeline({
displayTrackOrder={displayLayout.displayTrackOrder}
rowHeights={displayLayout.displayRowHeights}
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
rowsVirtualized={rowVirtualizationActive}
virtualRows={timelineFocus.virtualRows}
logicalRows={timelineFocus.logicalRows}
focusedTargetId={timelineFocus.focusedTargetId}
rowsVirtualized={timelineFocus.rowVirtualizationActive}
clipIndex={clipIndex}
renderTimeRange={renderTimeRange}
pinnedClipIdentities={pinnedClipIdentities}
@@ -522,7 +520,9 @@ export const Timeline = memo(function Timeline({
scrollRef={scrollRef}
// Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
renderClipContent={
rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent
timelineFocus.rowVirtualizationActive && viewport.isScrolling
? undefined
: renderClipContent
}
renderClipOverlay={renderClipOverlay}
playheadRef={playheadRef}
@@ -132,7 +132,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
const rows = host.querySelectorAll('[role="listitem"]');
const rows = host.querySelectorAll("[data-timeline-row]");
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
@@ -201,16 +201,17 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
await settleUntil(
() =>
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
0,
(host.querySelector('[role="treegrid"]')?.querySelectorAll('[role="row"]').length ?? 0) > 0,
);
const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
expect(list?.parentElement?.style.height).toBe(
expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
expect(treegrid?.getAttribute("aria-rowcount")).toBe("1000");
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
expect(treegrid?.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
expect(treegrid?.parentElement?.style.height).toBe(
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
);
@@ -226,7 +227,44 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
scroller.dispatchEvent(new Event("scroll"));
});
}
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(treegrid?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(document.activeElement).toBe(focusedControl);
act(() => root.unmount());
usePlayerStore.getState().reset();
});
it("keeps focus pinning active after the scroll viewport remounts", async () => {
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
elements: clipsByTrack(1_000),
});
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 9 }));
await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
const firstScroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
await act(async () => usePlayerStore.setState({ timelineReady: false }));
expect(host.querySelector("[data-timeline-scroll-viewport]")).toBeNull();
await act(async () => usePlayerStore.setState({ timelineReady: true }));
await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
const firstRow = host.querySelector<HTMLElement>('[data-timeline-row-key="0"]');
const focusedControl = firstRow?.querySelector<HTMLButtonElement>("button");
expect(scroller).not.toBe(firstScroller);
expect(focusedControl).not.toBeNull();
act(() => focusedControl?.focus());
if (scroller) {
scroller.scrollTop = 500 * 48;
await dispatchScroll(scroller);
}
expect(host.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(document.activeElement).toBe(focusedControl);
act(() => root.unmount());
@@ -242,6 +280,8 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
usePlayerStore.setState({
duration: 1_000,
timelineReady: true,
timelineProjectId: "project-a",
timelineSessionEpoch: 4,
zoomMode: "manual",
manualZoomPercent: 2_000,
selectedElementId: "clip-490",
@@ -286,10 +326,13 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
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 advanceFrame();
await act(async () => {});
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
expect(usePlayerStore.getState().timelineFocus?.id).toBe(timelineClipFocusId("clip-300"));
const focusedClip = host.querySelector('[data-el-id="clip-300"]');
expect(document.activeElement).toBe(focusedClip);
await advanceFrame();
@@ -36,6 +36,7 @@ function renderClip({
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const onClick = vi.fn();
act(() => {
root.render(
@@ -50,7 +51,7 @@ function renderClip({
isComposition={false}
onHoverStart={vi.fn()}
onHoverEnd={vi.fn()}
onClick={vi.fn()}
onClick={onClick}
onDoubleClick={vi.fn()}
>
<div data-custom-content="true" />
@@ -58,7 +59,7 @@ function renderClip({
);
});
return { host, root };
return { host, onClick, root };
}
describe("TimelineClip", () => {
@@ -113,4 +114,18 @@ describe("TimelineClip", () => {
act(() => root.unmount());
});
it("is a roving native button with explicit selection semantics", () => {
const { host, onClick, root } = renderClip({
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
isSelected: true,
});
const clip = host.querySelector<HTMLButtonElement>(".timeline-clip")!;
expect(clip.type).toBe("button");
expect(clip.tabIndex).toBe(-1);
expect(clip.getAttribute("aria-pressed")).toBe("true");
act(() => clip.click());
expect(onClick).toHaveBeenCalledOnce();
act(() => root.unmount());
});
});
@@ -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;
@@ -18,6 +19,7 @@ interface TimelineClipProps {
capabilities: TimelineEditCapabilities;
theme?: TimelineTheme;
isComposition: boolean;
tabIndex?: 0 | -1;
onHoverStart: () => void;
onHoverEnd: () => void;
onPointerDown?: (e: React.PointerEvent) => void;
@@ -43,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
capabilities,
theme = defaultTimelineTheme,
isComposition,
tabIndex = -1,
onHoverStart,
onHoverEnd,
onPointerDown,
@@ -82,19 +85,28 @@ export const TimelineClip = memo(function TimelineClip({
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
cursor: "default",
appearance: "none",
color: "inherit",
font: "inherit",
padding: 0,
textAlign: "left",
transform: isDragging ? "translateY(-1px)" : undefined,
};
return (
<div
<button
type="button"
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}
data-active={isActive ? "" : undefined}
aria-hidden={isGestureActor ? "true" : undefined}
tabIndex={isGestureActor ? undefined : -1}
tabIndex={isGestureActor ? undefined : tabIndex}
aria-label={`${displayLabel}, ${startLabel} to ${endLabel} seconds`}
aria-pressed={isGestureActor ? undefined : isSelected}
className={clipClassName}
style={style}
title={
@@ -176,6 +188,6 @@ export const TimelineClip = memo(function TimelineClip({
</span>
)}
{children}
</div>
</button>
);
});
@@ -46,7 +46,7 @@ function createTimelineHost() {
return host;
}
function renderDiamonds(onClickKeyframe = vi.fn()) {
function renderDiamonds(onClickKeyframe = vi.fn(), onShiftClickKeyframe = vi.fn()) {
const host = createTimelineHost();
const root = createRoot(host);
act(() => {
@@ -61,19 +61,60 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
}}
clipWidthPx={200}
clipHeightPx={48}
clipDuration={10}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
/>,
);
});
return { host, root, onClickKeyframe };
return { host, root, onClickKeyframe, onShiftClickKeyframe };
}
it("pins authored keyframes outside the clip to an inspectable boundary marker", () => {
const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: -40, properties: { x: 0 }, propertyGroup: "position" },
{ percentage: -20, properties: { x: 25 }, propertyGroup: "position" },
{ percentage: 5, properties: { x: 50 }, propertyGroup: "position" },
{ percentage: 120, properties: { x: 100 }, propertyGroup: "position" },
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={5}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
/>,
);
});
const before = host.querySelectorAll<HTMLButtonElement>('[data-keyframe-outside-clip="before"]');
const after = host.querySelector<HTMLButtonElement>('[data-keyframe-outside-clip="after"]');
expect(Array.from(before, (marker) => marker.style.left)).toEqual(["-35px", "-23px"]);
expect(before[0]?.getAttribute("aria-label")).toBe("position keyframe at 6s (before clip)");
expect(after?.style.left).toBe("201px");
expect(after?.getAttribute("aria-label")).toBe("position keyframe at 22s (after clip)");
expect(host.querySelectorAll('button[aria-label*="keyframe at"]')).toHaveLength(4);
act(() => root.unmount());
});
function renderRetimeLane(
onMoveKeyframe = vi.fn().mockResolvedValue(true),
strict = false,
@@ -261,6 +302,33 @@ describe("TimelineClipDiamonds", () => {
act(() => root.unmount());
});
it("gives keyframes time-based names and native keyboard selection semantics", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
expect(diamond.getAttribute("aria-label")).toBe("Motion keyframe at 15s");
expect(diamond.getAttribute("aria-pressed")).toBe("false");
act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 })));
expect(onClickKeyframe).toHaveBeenCalledWith(
"clip-1",
expect.objectContaining({ percentage: 50 }),
);
act(() => root.unmount());
});
it("uses Shift+Space's native click for additive keyframe selection", () => {
const { host, root, onClickKeyframe, onShiftClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
act(() =>
diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })),
);
expect(onShiftClickKeyframe).toHaveBeenCalledWith(
"clip-1",
expect.objectContaining({ percentage: 50 }),
);
expect(onClickKeyframe).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("publishes retime previews after StrictMode effect replay", () => {
const { diamond, host, root } = renderRetimeLane(undefined, true);
const initialLeft = diamond.style.left;
@@ -1108,10 +1176,9 @@ describe("TimelineClipDiamonds", () => {
// Regression: onClickKeyframe's state updates can re-render the diamond
// button out from under the gesture before the browser auto-synthesizes the
// "click" event that follows a button's pointerdown+pointerup. That orphaned
// click then bubbles to the ancestor clip's onClick, which toggles selection
// off whenever the clip is already selected — the state a diamond click
// always happens in — so every keyframe click immediately deselected its
// own clip. suppressClickRef lets that ancestor ignore the stray click.
// click then bubbles to the ancestor clip's onClick. That stray click can
// replace keyframe focus or collapse a marquee selection, so suppressClickRef
// lets the ancestor ignore it.
it("arms suppressClickRef synchronously on a keyframe click", () => {
const suppressClickRef = { current: false };
const host = createTimelineHost();
@@ -1150,6 +1217,7 @@ describe("TimelineClipDiamonds", () => {
const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
const host = createTimelineHost();
const root = createRoot(host);
const onSelectSegment = vi.fn();
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
percentage,
tweenPercentage: percentage,
@@ -1182,13 +1250,15 @@ describe("TimelineClipDiamonds", () => {
isSelected
currentPercentage={0}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
onSelectSegment={onSelectSegment}
groupAware
/>,
);
});
return { host, root };
return { host, onSelectSegment, root };
};
it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
@@ -1201,8 +1271,23 @@ describe("TimelineClipDiamonds", () => {
});
it("shows the inline ease button on single-animation merged segments", () => {
const { host, root } = renderSegmentLane(false);
const { host, onSelectSegment, root } = renderSegmentLane(false);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
expect(ease.classList.contains("opacity-0")).toBe(true);
act(() => ease.click());
expect(onSelectSegment).toHaveBeenCalledOnce();
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
act(() => root.unmount());
});
it("ends connectors at the diamond boundaries", () => {
const { host, root } = renderSegmentLane(false);
const connectors = host.querySelectorAll<HTMLElement>("[data-keyframe-connector]");
expect(Array.from(connectors, (connector) => connector.style.left)).toEqual(["11px", "111px"]);
expect(Array.from(connectors, (connector) => connector.style.width)).toEqual(["78px", "78px"]);
act(() => root.unmount());
});
@@ -10,10 +10,10 @@ import {
subscribeTimelineKeyframeRetimePreview,
type TimelineKeyframeRetimeHandle,
} from "./useTimelineKeyframeHandlers";
import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
import {
DIAMOND_RATIO,
KF_MAX_PCT,
KF_MIN_PCT,
keyframeTimeLabel,
keyframeTarget,
type TimelineClipDiamondsProps,
type TimelineDiamondKeyframe,
@@ -68,13 +68,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
keyframesData,
clipWidthPx,
clipHeightPx,
clipDuration,
beatsActive,
accentColor,
isSelected,
currentPercentage,
elementId,
clipStart = 0,
clipDuration = 0,
selectedKeyframes,
rovingTargetId = null,
onClickKeyframe,
onShiftClickKeyframe,
onContextMenuKeyframe,
@@ -140,9 +142,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
// (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
// shrunken box: 24px there would reach up into the beat strip.
const hitHeight = beatsActive ? diamondSize : 24;
const sorted = keyframesData.keyframes
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
.sort((a, b) => a.percentage - b.percentage);
// Keyframes authored outside the element's visible clip window are parked at
// the boundary rather than hidden: dropping them made the lane's count
// disagree with its diamonds and left users unable to inspect or remove state
// that still affects the clip once it appears.
const sorted = [...keyframesData.keyframes].sort((a, b) => a.percentage - b.percentage);
const beforeClip = sorted.filter((keyframe) => keyframe.percentage < 0);
const afterClip = sorted.filter((keyframe) => keyframe.percentage > 100);
const boundaryStep = Math.max(6, Math.round(diamondSize * 0.55));
// The neighbour clamp bounds a dragged diamond between its immediate siblings
// so a retime can't reorder the tween. Siblings means "keyframes of the SAME
// tween": a merged row interleaves several animations, and two of them
@@ -175,16 +182,30 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
clipPcts: row.map((s) => s.clipPct),
});
}
const centerXOf = (percentage: number) =>
Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx));
const centerXOf = (keyframe: TimelineDiamondKeyframe, percentage = keyframe.percentage) => {
if (percentage < 0) {
const rank = beforeClip.indexOf(keyframe);
return -(beforeClip.length - Math.max(0, rank)) * boundaryStep;
}
if (percentage > 100) {
const rank = afterClip.indexOf(keyframe);
return clipWidthPx + (Math.max(0, rank) + 1) * boundaryStep;
}
return (percentage / 100) * clipWidthPx;
};
// One record per diamond, carrying its own geometry, so the connector and
// button passes below read neighbours as values instead of index lookups.
const markers = sorted.map((keyframe, index) => {
const centerX = centerXOf(keyframe.percentage);
const centerX = centerXOf(keyframe);
// Parked diamonds sit on their own boundary spacing, so the in-clip
// neighbour-gap shrink would only make them unreadable.
if (keyframe.percentage < 0 || keyframe.percentage > 100) {
return { keyframe, centerX, hitWidth: diamondSize, visualSize: diamondSize };
}
const previous = sorted[index - 1];
const next = sorted[index + 1];
const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
const previousGap = previous ? centerX - centerXOf(previous) : Infinity;
const nextGap = next ? centerXOf(next) - centerX : Infinity;
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
const hitWidth = Math.min(diamondSize, nearestGap);
return {
@@ -226,6 +247,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
<TimelineDiamondConnectors
markers={markers}
centerY={centerY}
elementId={elementId}
clipStart={clipStart}
clipDuration={clipDuration}
rovingTargetId={rovingTargetId}
baseColor={baseColor}
baseOpacity={baseOpacity}
groupAware={groupAware}
@@ -237,7 +262,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
{markers.map((marker, i) => {
const kf = marker.keyframe;
const target = keyframeTarget(kf);
const focusId = timelineKeyframeFocusId(elementId, target);
const kfKey = timelineKeyframeSelectionKey(elementId, target);
const boundary = kf.percentage < 0 ? "before" : kf.percentage > 100 ? "after" : null;
// Clamp against this keyframe's own tween, not the whole merged row.
const siblingRow = siblingRows.get(kf.animationId);
const siblingClipPcts = siblingRow?.clipPcts ?? [];
@@ -249,7 +276,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
// The 0% diamond's left half lands in the reserved left gutter (the
// content origin is inset past the label column, Figma-style) so it stays
// fully visible instead of being clipped by the sticky label column.
const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
const leftPx = centerXOf(kf, renderPct) - marker.hitWidth / 2;
const isKfSelected = selectedKeyframes.has(kfKey);
const atPlayhead = kf === playheadKeyframe;
const isHighlighted = isKfSelected || atPlayhead;
@@ -306,6 +333,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
type="button"
className="absolute"
data-timeline-focus-id={focusId}
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
data-keyframe-percentage={
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
@@ -313,6 +341,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
data-keyframe-at-playhead={String(atPlayhead)}
data-keyframe-selected={String(isKfSelected)}
aria-current={atPlayhead ? "time" : undefined}
data-keyframe-outside-clip={boundary ?? undefined}
tabIndex={focusId === rovingTargetId ? 0 : -1}
aria-label={`${kf.propertyGroup ?? "Motion"} keyframe at ${keyframeTimeLabel(clipStart, clipDuration, kf.percentage)}${boundary ? ` (${boundary} clip)` : ""}`}
aria-pressed={isKfSelected}
style={{
left: leftPx,
@@ -335,6 +366,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
onPointerDown={onPointerDown}
onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
onPointerUp={onPointerUp}
// Keyboard activation only (detail 0): pointer presses already
// resolve through the pointerup path above.
onClick={(e) => {
if (e.detail !== 0) return;
e.stopPropagation();
suppressNextClick();
if (e.shiftKey) onShiftClickKeyframe?.(target);
else onClickKeyframe?.(target);
}}
onPointerCancel={
canDrag
? (e) => {
@@ -348,7 +388,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
e.stopPropagation();
onContextMenuKeyframe?.(e, target);
}}
title={`${roundPct(kf.percentage)}%`}
title={`${roundPct(kf.percentage)}%${boundary ? ` · ${boundary} clip` : ""}`}
>
<svg
width={marker.visualSize}
@@ -0,0 +1,84 @@
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
import { CLIP_Y } from "./timelineLayout";
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
interface TimelineCompactDiamondsProps extends Pick<
TimelineLaneBaseProps,
| "currentTime"
| "selectedKeyframes"
| "onClickKeyframe"
| "onShiftClickKeyframe"
| "onContextMenuKeyframe"
| "onMoveKeyframe"
| "onSelectSegment"
| "suppressClickRef"
> {
element: TimelineElement;
elementId: string;
keyframesData: KeyframeCacheEntry;
pixelsPerSecond: number;
rowHeight: number;
beatsActive: boolean;
accentColor: string;
isSelected: boolean;
rovingTargetId: string | null;
}
/** Inline diamonds shown while the clip's property lanes are collapsed. */
export function TimelineCompactDiamonds({
element,
elementId,
keyframesData,
pixelsPerSecond,
rowHeight,
beatsActive,
accentColor,
isSelected,
currentTime,
selectedKeyframes,
rovingTargetId,
onClickKeyframe,
onShiftClickKeyframe,
onContextMenuKeyframe,
onMoveKeyframe,
onSelectSegment,
suppressClickRef,
}: TimelineCompactDiamondsProps) {
const width = Math.max(element.duration * pixelsPerSecond, 4);
return (
<div
className="absolute pointer-events-none"
style={{
left: element.start * pixelsPerSecond,
top: CLIP_Y,
width,
height: rowHeight - 2 * CLIP_Y,
zIndex: isSelected ? 11 : 6,
}}
>
<TimelineClipDiamonds
keyframesData={keyframesData}
clipWidthPx={width}
clipHeightPx={rowHeight - 2 * CLIP_Y}
beatsActive={beatsActive}
accentColor={accentColor}
isSelected={isSelected}
currentPercentage={
element.duration > 0 ? ((currentTime - element.start) / element.duration) * 100 : 0
}
elementId={elementId}
clipStart={element.start}
clipDuration={element.duration}
selectedKeyframes={selectedKeyframes}
rovingTargetId={rovingTargetId}
onClickKeyframe={(_id, target) => onClickKeyframe?.(element, target)}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
onSelectSegment={onSelectSegment}
suppressClickRef={suppressClickRef}
/>
</div>
);
}
@@ -2,7 +2,8 @@ import { Fragment, useRef } from "react";
import { KEYFRAME_DRAG_THRESHOLD_PX } from "../../components/editor/keyframeDrag";
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import type { TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
import { keyframeTimeLabel, type TimelineDiamondKeyframe } from "./timelineDiamondTypes";
import { timelineEaseFocusId } from "./timelineNavigationIdentity";
/** One diamond's geometry within its row, as computed by the lane. */
export interface TimelineDiamondMarker {
@@ -21,6 +22,10 @@ export interface TimelineDiamondMarker {
export function TimelineDiamondConnectors({
markers,
centerY,
elementId,
clipStart,
clipDuration,
rovingTargetId,
baseColor,
baseOpacity,
groupAware,
@@ -30,6 +35,11 @@ export function TimelineDiamondConnectors({
}: {
markers: readonly TimelineDiamondMarker[];
centerY: number;
elementId: string;
clipStart: number;
clipDuration: number;
/** Focus id of the one timeline control currently in the tab order. */
rovingTargetId: string | null;
baseColor: string;
baseOpacity: number;
groupAware: boolean;
@@ -48,6 +58,7 @@ export function TimelineDiamondConnectors({
if (x2 - x1 < 1) return null;
const connectorLeft = x1 + previous.visualSize / 2;
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
const target = keyframeTarget(kf);
return (
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
<div
@@ -70,7 +81,14 @@ export function TimelineDiamondConnectors({
width={x2 - x1}
centerY={centerY}
ease={kf.ease ?? globalEase}
target={keyframeTarget(kf)}
target={target}
focusId={timelineEaseFocusId(elementId, target)}
rovingTargetId={rovingTargetId}
afterLabel={keyframeTimeLabel(
clipStart,
clipDuration,
previous.keyframe.percentage,
)}
// connectorWidth is the clear span between the two diamonds'
// edges, so a 24x24 target centred in it overhangs a diamond as
// soon as the span is narrower than 24. The segment wrapper sits
@@ -111,6 +129,9 @@ function SegmentEaseControl({
centerY,
ease,
target,
focusId,
rovingTargetId,
afterLabel,
roomForFullTarget,
onSelectSegment,
}: {
@@ -119,6 +140,11 @@ function SegmentEaseControl({
centerY: number;
ease: string;
target: TimelineKeyframeTarget;
focusId: string;
/** Focus id of the one timeline control currently in the tab order. */
rovingTargetId: string | null;
/** Time label of the keyframe this segment starts at, for the accessible name. */
afterLabel: string;
roomForFullTarget: boolean;
onSelectSegment: (target: TimelineKeyframeTarget) => void;
}) {
@@ -151,7 +177,9 @@ function SegmentEaseControl({
<button
type="button"
data-keyframe-ease-button=""
aria-label={`Edit ${ease} easing`}
data-timeline-focus-id={focusId}
tabIndex={focusId === rovingTargetId ? 0 : -1}
aria-label={`Edit ${ease} easing after ${afterLabel}`}
title={`Edit ${ease} easing`}
// A visible 24x24 badge would collide with the diamonds either side, so
// the WCAG 2.2 (2.5.8) target is met with a centered transparent
@@ -9,6 +9,7 @@ import { getTrackStyle } from "./timelineIcons";
import { defaultTimelineTheme } from "./timelineTheme";
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -26,6 +27,17 @@ afterEach(() => {
const TRACK_A = 1 / 6;
const TRACK_B = 0.5;
/** Every string a screen reader or a sighted user actually reads. */
function visibleText(host: HTMLElement): string {
return host.textContent ?? "";
}
function ariaLabels(host: HTMLElement): string {
return Array.from(host.querySelectorAll("[aria-label]"))
.map((el) => el.getAttribute("aria-label") ?? "")
.join(" ");
}
function element(id: string, track: number): TimelineElement {
return { id, label: id, tag: "div", start: 0, duration: 2, track };
}
@@ -94,6 +106,16 @@ function renderLanes(options: RenderLanesOptions = {}): {
rowGeometry={getTimelineRowGeometry(rowHeights)}
virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
rowsVirtualized={false}
focusedTargetId={null}
logicalRows={buildTimelineLogicalRows({
tracks,
displayTrackOrder,
laneCounts,
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
gsapAnimations,
})}
clipIndex={createTimelineClipIndex(tracks)}
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
pinnedClipIdentities={new Set()}
@@ -152,7 +174,11 @@ describe("TimelineLanes track numbering", () => {
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
// Only what a user reads. The fractional key still identifies the row in
// `id` / `data-` attributes, which is exactly where an opaque sort key
// belongs.
expect(visibleText(view.host)).not.toContain("0.16666666666666666");
expect(ariaLabels(view.host)).not.toContain("0.16666666666666666");
act(() => view.root.unmount());
});
@@ -179,11 +205,12 @@ describe("TimelineLanes track numbering", () => {
onContextMenuLane,
});
// Row children: [sticky header column, time-mapped track content]. The rows
// sit inside the lanes list, which is what carries the virtualization
// positioning context.
const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
const secondTrackContent = rows[1]?.children.item(1);
// The lane's own content cell: the track row's second child, after the
// sticky header column.
const secondTrackContent = view.host
.querySelectorAll("[data-timeline-row]")[1]
?.querySelector('[role="row"]')
?.children.item(1);
act(() => {
secondTrackContent?.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
@@ -238,9 +265,40 @@ describe("TimelineLanes disclosure target", () => {
);
const firstIds = idsFor(first.host);
const secondIds = idsFor(second.host);
const cellIdsFor = (host: HTMLElement) =>
new Set(
Array.from(host.querySelectorAll<HTMLElement>("[data-property-group][id]"), (cell) =>
cell.getAttribute("id"),
).filter((id): id is string => id !== null),
);
const ownedIdsFor = (host: HTMLElement) =>
Array.from(host.querySelectorAll("[aria-owns]"), (owner) =>
owner.getAttribute("aria-owns"),
).filter((id): id is string => id !== null);
const firstCellIds = cellIdsFor(first.host);
const secondCellIds = cellIdsFor(second.host);
for (const { host } of [first, second]) {
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
expect(treegrid?.getAttribute("aria-colcount")).toBe("2");
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
expect(
[...host.querySelectorAll('[role="rowheader"]')].every(
(cell) => cell.getAttribute("aria-colindex") === "1",
),
).toBe(true);
expect(
[...host.querySelectorAll('[role="gridcell"]')].every(
(cell) => cell.getAttribute("aria-colindex") === "2",
),
).toBe(true);
}
expect(firstIds.length).toBeGreaterThan(0);
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
expect(firstCellIds.size).toBeGreaterThan(0);
expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false);
expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true);
expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true);
// Still a legal CSS id selector: the aria-controls lookups above use `#id`.
for (const id of [...firstIds, ...secondIds]) {
expect(id).toMatch(/^[A-Za-z][\w-]*$/);
@@ -1,7 +1,7 @@
import { Fragment, useId } from "react";
import { Fragment, useId, useMemo } from "react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
@@ -27,6 +27,9 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
interface TimelineLanesProps extends TimelineLaneBaseProps {
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
@@ -49,6 +52,8 @@ export function TimelineLanes({
displayTrackOrder,
rowGeometry,
virtualRows,
logicalRows,
focusedTargetId,
rowsVirtualized,
clipIndex,
renderTimeRange,
@@ -99,14 +104,20 @@ export function TimelineLanes({
onRazorSplit,
onRazorSplitAll,
}: TimelineLanesProps) {
// Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
// beside the main one) cannot both mint `...-track-0` and have every caret's
// aria-controls resolve to whichever mounted first. React's useId embeds
// colons, which are legal in an id and in aria-controls but need escaping in
// a CSS `#id` selector, so they come out here and the prefix stays plain.
// ponytail: One per-instance namespace prevents aria-controls and aria-owns
// from resolving into a second timeline that renders the same logical rows.
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
const logicalRowsByTrack = useMemo(() => {
const byTrack = new Map<number, TimelineLogicalRow[]>();
for (const logicalRow of logicalRows) {
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
trackRows.push(logicalRow);
byTrack.set(logicalRow.physicalTrackKey, trackRows);
}
return byTrack;
}, [logicalRows]);
const toggleClipExpandedTracked = (key: string) => {
const willExpand = !expandedClipIds.has(key);
trackStudioKeyframeLaneExpand({ expanded: willExpand });
@@ -128,10 +139,23 @@ export function TimelineLanes({
},
]
: [];
const keyboard = useTimelineKeyboardActor({
logicalRows,
focusedTargetId,
rowGeometry,
scrollRef,
onToggleRow: (row) => {
if (row.elementId) toggleClipExpandedTracked(row.elementId);
},
});
return (
<div
role="list"
role="treegrid"
aria-label="Timeline tracks"
aria-rowcount={logicalRows.length}
aria-colcount={2}
onFocus={keyboard.onFocus}
onKeyDown={keyboard.onKeyDown}
className={rowsVirtualized ? "absolute inset-0" : undefined}
>
{
@@ -140,6 +164,9 @@ export function TimelineLanes({
const trackNum = displayTrackOrder[row];
if (trackNum === undefined) return null;
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
const logicalRow = trackLogicalRows[0];
if (!logicalRow) return null;
const rowHeight = rowGeometry.getRowHeight(row);
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const renderElements = rowsVirtualized
@@ -156,9 +183,7 @@ export function TimelineLanes({
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
// All lanes use the same uniform color — no alternating stripes.
const rowBackground = theme.rowBackground;
// The beat-dot strip occupies the top of this track's lane (active track,
// or the music track when nothing is selected). When shown, keyframe
// diamonds shrink + drop to the bottom half so they don't collide with it.
// Keep diamonds below the beat strip on the active/music track.
const beatStripOnTrack =
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
(selectedElementId
@@ -166,9 +191,7 @@ export function TimelineLanes({
: els.some(isMusicTrack));
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
// The one keyframed element this track shows lanes for (selected, else
// most lanes). A track can hold several elements; scoping to one keeps
// their keyframes from cramming into a single row.
// Only the selected/most-keyframed clip owns expanded lanes on a shared track.
const keyframeClip = resolveTrackKeyframeClip(
els,
laneCounts,
@@ -178,22 +201,22 @@ export function TimelineLanes({
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
const keyframeClipExpanded =
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
// Minted here because this is the only place that sees BOTH ends of
// the disclosure: the caret in the sticky header and the diamond lanes
// on the canvas. Keyed by display row, not by `trackNum`, which is a
// fractional sort key and would mint ids like `...-0.16666666666666666`.
// Link the sticky caret to the canvas lanes with a stable display-row id.
const lanesId = `${lanesIdPrefix}-track-${row}`;
return (
<TimelineTrackRow
key={rowKey}
index={row}
rowKey={rowKey}
rowCount={displayTrackOrder.length}
logicalRow={logicalRow}
propertyRows={trackLogicalRows.slice(1)}
lanesId={lanesId}
top={rowGeometry.getRowTop(row)}
height={rowHeight}
virtualized={rowsVirtualized}
background={rowBackground}
borderColor={theme.rowBorder}
rovingTargetId={keyboard.rovingTargetId}
>
<TimelineTrackHeader
trackNumber={trackNum}
@@ -224,8 +247,11 @@ export function TimelineLanes({
onToggleTrackHidden={onToggleTrackHidden}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
rovingTargetId={keyboard.rovingTargetId}
/>
<div
role="gridcell"
aria-colindex={2}
style={{
width: trackContentWidth,
marginLeft: contentGutter, // room for a 0% diamond left of t=0
@@ -297,9 +323,8 @@ export function TimelineLanes({
const isSelected =
selectedElementId === elementKey || selectedElementIds.has(elementKey);
const isComposition = !!el.compositionSrc;
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
// fold in the map index, or a splice/reorder remounts every clip
// at/after the change (DOM flash, drag interruption).
// The element identity is already unique per clip. Never fold in the map
// index, or a splice/reorder remounts every clip at/after the change.
const clipKey = elementKey;
const isDraggingClip =
draggedClip?.started === true &&
@@ -307,11 +332,8 @@ export function TimelineLanes({
getTimelineElementIdentity(draggedElement) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
// Passenger of a live multi-drag: slide by the SAME formation
// delta (the grabbed clip's group-clamped delta) via a
// compositor transform on a same-geometry wrapper (absolute
// inset-0 → identical offset parent, so the clip's own
// left/top are preserved), plus the ghost's elevated z/opacity.
// Passenger of a live multi-drag: preserve the formation without changing
// the passenger's timeline data until the owning drag commits.
const isPassenger =
multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
const passengerOffsetPx = isPassenger
@@ -336,6 +358,9 @@ export function TimelineLanes({
capabilities={capabilities}
theme={theme}
isComposition={isComposition}
tabIndex={
keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onResizeStart={
@@ -473,41 +498,33 @@ export function TimelineLanes({
renderClipContent,
renderClipOverlay,
)}
{!showsLanes && keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={rowHeight - 2 * CLIP_Y}
clipDuration={previewElement.duration}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) / previewElement.duration) *
100
: 0
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(_elId, target) =>
onClickKeyframe?.(previewElement, target)
}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
onSelectSegment={onSelectSegment}
suppressClickRef={suppressClickRef}
/>
)}
</TimelineClip>
);
// Mounted for the track's keyframe clip in BOTH disclosure
// states, so the header caret's aria-controls resolves while
// collapsed too; collapsed just feeds it no animations, so
// the wrapper renders empty. The key is stable across a
// multi-drag: without it the passenger branch below remounts
// this subtree and interrupts the gesture.
const compactKeyframes = keyframeCache?.get(elementKey);
const compactDiamonds = !showsLanes && compactKeyframes && (
<TimelineCompactDiamonds
key={`${clipKey}-diamonds`}
element={previewElement}
elementId={elementKey}
keyframesData={compactKeyframes}
pixelsPerSecond={pps}
rowHeight={rowHeight}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentTime={currentTime}
selectedKeyframes={selectedKeyframes}
rovingTargetId={keyboard.rovingTargetId}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
onSelectSegment={onSelectSegment}
suppressClickRef={suppressClickRef}
/>
);
// Keep this shell mounted while collapsed so aria-controls stays valid
// and multi-drag cannot remount the subtree mid-gesture.
const propertyLanes = isTrackKeyframeClip && (
<TimelinePropertyLanes
key={`${clipKey}-property-lanes`}
@@ -529,6 +546,7 @@ export function TimelineLanes({
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
rovingTargetId={keyboard.rovingTargetId}
onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
onShiftClickKeyframe={(target) =>
@@ -544,14 +562,12 @@ export function TimelineLanes({
suppressClickRef={suppressClickRef}
/>
);
// Keep one keyed top-level child per element. Returning an
// array here makes React reconcile the outer array by
// position, so a window shift remounts otherwise stable
// clip keys and can tear down focus mid-reveal.
// One keyed child prevents window shifts from remounting focused clips.
if (!isPassenger) {
return (
<Fragment key={clipKey}>
{clip}
{compactDiamonds}
{propertyLanes}
</Fragment>
);
@@ -568,6 +584,7 @@ export function TimelineLanes({
}}
>
{clip}
{compactDiamonds}
{propertyLanes}
</div>
);
@@ -70,6 +70,7 @@ function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}
act(() => {
root.render(
<TimelinePropertyLanes
id="timeline-property-lanes-test"
animations={[]}
clipStart={0}
clipDuration={1}
@@ -367,6 +368,7 @@ describe("TimelinePropertyLanes", () => {
act(() => {
root.render(
<TimelinePropertyLanes
id="timeline-property-lanes-selected-test"
animations={animations}
clipStart={0}
clipDuration={1}
@@ -417,6 +419,7 @@ describe("TimelinePropertyLanes", () => {
act(() => {
root.render(
<TimelinePropertyLanes
id="timeline-property-lanes-unselected-test"
animations={animations}
clipStart={0}
clipDuration={1}
@@ -433,7 +436,7 @@ describe("TimelinePropertyLanes", () => {
});
const unselectedSegments = laneEaseSegments(host, "position");
expect(unselectedSegments).toHaveLength(2);
expect(revealEaseButton(unselectedSegments[0]!)).not.toBeNull();
expect(laneEaseButtons(host, "position")).toHaveLength(2);
act(() => root.unmount());
});
@@ -461,13 +464,14 @@ describe("TimelinePropertyLanes", () => {
expect(new Set(paths).size).toBe(3);
// Uniqueness alone passes even when the curves are swapped between segments.
// Each segment is labelled with the ease it draws, so pin the ORDER: a
// segment carries the ease of the keyframe it arrives at.
// segment carries the ease of the keyframe it arrives at. The trailing time
// is what separates two segments that share an ease name in the same lane.
expect(
segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
).toEqual([
"Edit none easing",
"Edit power2.out easing",
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
"Edit none easing after 0s",
"Edit power2.out easing after 0.33s",
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing after 0.66s",
]);
act(() => root.unmount());
});
@@ -9,6 +9,7 @@ import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
export interface TimelinePropertyLanesProps {
/**
@@ -16,7 +17,7 @@ export interface TimelinePropertyLanesProps {
* `aria-controls` at the lanes a sighted user sees it reveal. Minted by
* TimelineLanes, which owns both this subtree and the caret's.
*/
id?: string;
id: string;
animations: readonly GsapAnimation[];
clipStart: number;
clipDuration: number;
@@ -27,6 +28,7 @@ export interface TimelinePropertyLanesProps {
currentPercentage: number;
elementId: string;
selectedKeyframes: ReadonlySet<string>;
rovingTargetId?: string | null;
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
@@ -187,6 +189,7 @@ export function TimelinePropertyLanes({
currentPercentage,
elementId,
selectedKeyframes,
rovingTargetId = null,
onSelectSegment,
onClickKeyframe,
onShiftClickKeyframe,
@@ -223,9 +226,11 @@ export function TimelinePropertyLanes({
{laneData.map(({ group, keyframesData }, laneIndex) => (
<div
key={group}
id={timelineLogicalRowCellId(id, timelinePropertyRowId(elementId, group), "content")}
role="group"
aria-label={`${group} keyframes`}
data-property-group={group}
data-timeline-element-id={elementId}
data-timeline-property-lane=""
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute"
@@ -240,12 +245,14 @@ export function TimelinePropertyLanes({
keyframesData={keyframesData}
clipWidthPx={clipWidthPx}
clipHeightPx={LANE_H}
clipDuration={clipDuration}
accentColor={accentColor}
isSelected={isSelected}
currentPercentage={currentPercentage}
elementId={elementId}
clipStart={clipStart}
clipDuration={clipDuration}
selectedKeyframes={selectedKeyframes}
rovingTargetId={rovingTargetId}
onSelectSegment={onSelectSegment}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
@@ -379,6 +379,7 @@ describe("TimelineTrackHeader", () => {
act(() => {
lanesRoot.render(
<TimelinePropertyLanes
id="timeline-property-lanes-alignment-test"
animations={animations}
clipStart={0}
clipDuration={2}
@@ -16,6 +16,7 @@ import {
} from "./trackHeaderLaneState";
import { valueReadout } from "./trackHeaderLaneValues";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
interface TimelineTrackHeaderProps {
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
@@ -41,6 +42,7 @@ interface TimelineTrackHeaderProps {
currentTime: number;
isTrackHidden: boolean;
isAudioTrack: boolean;
rovingTargetId?: string | null;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
@@ -191,6 +193,7 @@ function PropertyGroupNavigation({
}
function PropertyGroupHeaderRow({
lanesId,
lane,
laneIndex,
isLastLane,
@@ -201,7 +204,9 @@ function PropertyGroupHeaderRow({
columnWidth,
onTogglePropertyGroupKeyframe,
onSeek,
rovingTargetId = null,
}: {
lanesId: string;
lane: TimelinePropertyLane;
laneIndex: number;
isLastLane: boolean;
@@ -212,7 +217,9 @@ function PropertyGroupHeaderRow({
columnWidth: number;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
rovingTargetId: string | null;
}) {
const elementId = expandedElement.key ?? expandedElement.id;
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
@@ -221,6 +228,10 @@ function PropertyGroupHeaderRow({
return (
<div
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
data-timeline-element-id={elementId}
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
data-property-group={lane.group}
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
@@ -298,6 +309,7 @@ export function TimelineTrackHeader({
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onSeek,
rovingTargetId = null,
}: TimelineTrackHeaderProps) {
const clipPercentage = keyframeClip
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
@@ -314,6 +326,8 @@ export function TimelineTrackHeader({
return (
<div
role="rowheader"
aria-colindex={1}
className={`sticky left-0 z-[12] shrink-0 ${
!isKeyframeLayer
? showTrackLabel
@@ -374,6 +388,7 @@ export function TimelineTrackHeader({
lanes.map((lane, laneIndex) => (
<PropertyGroupHeaderRow
key={lane.group}
lanesId={lanesId}
lane={lane}
laneIndex={laneIndex}
isLastLane={laneIndex === lanes.length - 1}
@@ -384,6 +399,7 @@ export function TimelineTrackHeader({
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
rovingTargetId={rovingTargetId}
/>
))}
</>
@@ -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 (
<div
role="listitem"
aria-posinset={index + 1}
aria-setsize={rowCount}
role="rowgroup"
data-index={index}
data-timeline-row={index}
data-timeline-row-key={rowKey}
className={`${virtualized ? "absolute left-0 right-0" : "relative"} flex`}
className={virtualized ? "absolute left-0 right-0" : "relative"}
style={{
top: virtualized ? top : undefined,
height,
@@ -40,7 +46,52 @@ export function TimelineTrackRow({
borderBottom: `1px solid ${borderColor}`,
}}
>
{children}
<div
role="row"
aria-rowindex={logicalRow.logicalIndex + 1}
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={rovingTargetId === logicalRow.id ? 0 : -1}
className="flex"
style={{ height }}
>
{children}
</div>
{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.
<div
key={row.id}
role="row"
aria-rowindex={row.logicalIndex + 1}
aria-level={row.level}
data-property-group={group}
data-timeline-logical-row-id={row.id}
className="sr-only"
>
<div
role="rowheader"
aria-colindex={1}
aria-owns={timelineLogicalRowCellId(lanesId, row.id, "header")}
>
{group}
</div>
<div
role="gridcell"
aria-colindex={2}
aria-owns={timelineLogicalRowCellId(lanesId, row.id, "content")}
>
{keyframeCount} keyframes, {easeCount} ease controls
</div>
</div>
);
})}
</div>
);
}
@@ -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<string>;
/** 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
@@ -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", () => {
@@ -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<string> = new Set<TimelineNavigationKey>([
"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 | number>): 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;
@@ -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;
@@ -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 | number>): 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));
}
@@ -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<typeof createTimelineClipIndex>[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<HTMLDivElement | null>;
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 };
}
@@ -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<HTMLDivElement>(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 (
<div
ref={(node) => {
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 && <div data-timeline-focus-id={mountedId} tabIndex={-1} />}
</div>
);
}
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(<Harness />));
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(<Harness mountedId={clipId} />));
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(<Harness mountedId={clipId} />));
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(<Harness mountedId={clipId} />));
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(<Harness />));
const scroll = host.firstElementChild as HTMLDivElement;
const querySelector = vi.spyOn(scroll, "querySelector");
await act(async () => root.render(<Harness />));
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(<Harness mountedId={clipId} projectId="project-b" />));
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(<Harness />));
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(<Harness />));
const collapsedRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
await act(async () => root.render(<Harness logicalRows={collapsedRows} mountedId={rowId} />));
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(<Harness logicalRows={[...rows, nextRow]} />));
await act(async () => root.render(<Harness logicalRows={[nextRow]} mountedId={nextRowId} />));
expect(usePlayerStore.getState().timelineFocus?.id).toBe(nextRowId);
expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(nextRowId);
});
});
@@ -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<HTMLDivElement | null>;
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<HTMLElement>(
`[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<ResolvedFocus | null>(() => {
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,
};
}
@@ -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<HTMLDivElement>(null);
const keyboard = useTimelineKeyboardActor({
logicalRows,
focusedTargetId,
rowGeometry: createTimelineRowGeometry(
[...new Set(logicalRows.map((row) => row.physicalTrackKey))],
rowHeights,
),
scrollRef,
onToggleRow,
});
return (
<div ref={scrollRef} onFocus={keyboard.onFocus} onKeyDown={keyboard.onKeyDown}>
{logicalRows
.flatMap((row) => [row, ...row.items])
.map((target) =>
target.kind === "row" ? (
<div
key={target.id}
data-timeline-focus-id={target.id}
tabIndex={target.id === keyboard.rovingTargetId ? 0 : -1}
>
{target.id}
<button data-native-control={target.id} type="button">
Action
</button>
</div>
) : (
<button
key={target.id}
data-timeline-focus-id={target.id}
tabIndex={target.id === keyboard.rovingTargetId ? 0 : -1}
>
{target.id}
</button>
),
)}
</div>
);
}
function renderHarness(props: React.ComponentProps<typeof Harness> = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => root.render(<Harness {...props} />));
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<HTMLElement>("[data-native-control]")].every(
(el) => el.tabIndex === 0,
),
).toBe(true);
const target = host.querySelector<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-timeline-focus-id="track-1"]')!;
const clip = host.querySelector<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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());
});
});
@@ -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<HTMLDivElement | null>;
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<HTMLElement>(
"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<number, number>,
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<number, number>();
for (const row of logicalRows) {
counts.set(row.physicalTrackKey, (counts.get(row.physicalTrackKey) ?? 0) + 1);
}
return counts;
}, [logicalRows]);
const onFocus = useCallback(
(event: FocusEvent<HTMLElement>) => {
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<HTMLElement>) => {
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 };
}
@@ -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<HTMLDivElement | null>;
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
layout: { displayTrackOrder: readonly number[]; rowGeometry: TimelineRowGeometry };
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
elements: readonly TimelineElement[];
pixelsPerSecond: number;
contentOrigin: number;
allowHorizontal: boolean;
viewport: TimelineScrollViewportSnapshot;
sessionEpoch: number;
draggedRowKey?: number;
resizingElementIds?: readonly string[];
clipContextMenuRowKey?: number;
keyframeContextMenuRowKey?: number;
lastScrollLeftRef: RefObject<number>;
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,
};
}
@@ -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<string, number>();
const selectedElementIds = new Set<string>();
const expandedClipIds = new Set<string>();
const gsapAnimations = new Map();
function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]> }) {
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<readonly TimelineLogicalRow[]> = [];
act(() => root.render(<Harness snapshots={snapshots} />));
const first = snapshots.at(-1);
act(() => usePlayerStore.setState({ requestedSeekTime: 1 }));
expect(snapshots.at(-1)).toBe(first);
act(() => root.unmount());
});
});
@@ -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<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedClipIds: ReadonlySet<string>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
}
/** 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,
],
);
}
@@ -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<HTMLDivElement>(null);
useTimelineRevealClip({
scrollRef,
elements: [element],
rowGeometry: geometry,
pixelsPerSecond: 100,
contentOrigin: 32,
allowHorizontal: true,
deferFocusUntilViewportUpdate,
focusedElementId,
viewportVersion: version,
sessionEpoch: 1,
});
return (
<div
ref={(node) => {
scrollRef.current = node;
if (node) {
Object.defineProperty(node, "clientWidth", { configurable: true, value: width });
Object.defineProperty(node, "clientHeight", { configurable: true, value: height });
}
}}
>
{mounted && <div data-el-id="hero" tabIndex={-1} />}
</div>
);
}
async function renderHarness(root: Root, props: HarnessProps): Promise<void> {
await act(async () => root.render(<Harness {...props} />));
}
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());
});
});
@@ -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<HTMLDivElement | null>;
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,
]);
}
@@ -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<HTMLElement>("[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<HTMLElement>("[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<TimelineDomFocusPin>();
const [domFocusedRowKey, setDomFocusedRowKey] = useState<number>();
const onTimelineFocus = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
setDomFocusPin(getTimelineDomFocusPin(event.target));
setDomFocusedRowKey(getFocusedTimelineRowKey(event.target));
}, []);
const onTimelineBlur = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
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 },
};
}