mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user