mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +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:
@@ -39,6 +39,24 @@ implemented by the current Studio player, canvas, and timeline.
|
||||
|
||||
The selected area decides what a shared shortcut does. For example, arrow keys nudge a selected canvas element; without a nudgeable selection they step through frames.
|
||||
|
||||
## Timeline navigation
|
||||
|
||||
Tab into the timeline before using these commands. Studio keeps one logical timeline target in the Tab order and moves that target without requiring every row or clip to stay mounted.
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| Left / Right arrow | Move to the previous or next target in the focused row |
|
||||
| Up / Down arrow | Move to the nearest target at the same time in the previous or next logical row |
|
||||
| Home / End | Move to the start or end of the focused row |
|
||||
| Command/Ctrl + Home / End | Move to the first or last logical row |
|
||||
| Page Up / Page Down | Move by one visible page of logical rows |
|
||||
| Enter / Space | Expand or collapse keyframe property lanes on a focused track row |
|
||||
| Context Menu or Shift + F10 | Open the focused target's context menu when available |
|
||||
|
||||
On an expandable track row, Right arrow expands collapsed keyframe lanes and Left arrow collapses expanded lanes. From a keyframe property row, Left arrow returns to its parent track row. When no disclosure or parent action applies, those arrows continue moving within the row.
|
||||
|
||||
If the destination is outside the virtualized viewport, Studio scrolls it into view and restores focus after it mounts.
|
||||
|
||||
## Keyframes and recording
|
||||
|
||||
| Shortcut | Action |
|
||||
|
||||
@@ -49,6 +49,18 @@ Zoom in for keyframes and short edits. Zoom out to understand the complete seque
|
||||
|
||||
Use the playhead for the exact edit moment. Use playback and frame stepping to check what happens immediately before and after it.
|
||||
|
||||
Tab into the timeline to reach its current logical focus target. Use Left and Right to move within that row. Use Up and Down to move to the nearest target at the same time in the previous or next row.
|
||||
|
||||
Home and End move to the start or end of the focused row. Hold Command or Ctrl to move to the first or last logical row. Page Up and Page Down move by one visible page of rows.
|
||||
|
||||
On an expandable track row, Right expands collapsed keyframe property lanes and Left collapses expanded lanes. From a keyframe property row, Left returns to its parent track row. Enter or Space toggles the same lanes. Use the Context Menu key or Shift + F10 to open the focused target's menu when it has one.
|
||||
|
||||
The timeline keeps only one logical timeline target in the Tab order. Native header controls, such as visibility and keyframe actions, remain separate Tab stops. When the logical target is outside the virtualized viewport, Studio keeps it mounted, scrolls it into view, and then restores focus.
|
||||
|
||||
Screen readers receive the timeline as a treegrid. Track rows announce their level and expanded state. Clips and keyframes announce their names, times, and selection state. Easing controls announce their names and times.
|
||||
|
||||
See [Keyboard shortcuts](/studio/shortcuts#timeline-navigation) for the complete command table.
|
||||
|
||||
## Work with beats
|
||||
|
||||
Beat markers provide timing landmarks, especially for music-driven work. Snap edits to them when the audio should drive the cut; ignore them when the story or voiceover needs a different rhythm.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
|
||||
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
||||
import { ContextMenu } from "./AssetContextMenu";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
||||
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
||||
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
||||
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
|
||||
@@ -133,7 +134,7 @@ export function AssetCard({
|
||||
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
|
||||
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
||||
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
||||
@@ -158,7 +159,7 @@ export function AssetCard({
|
||||
const clipKey = clip.key ?? clip.id;
|
||||
setSelectedElementId(clipKey);
|
||||
// Scroll the timeline so the selected clip is actually visible.
|
||||
requestClipReveal(clipKey);
|
||||
requestTimelineFocus(timelineClipFocusId(clipKey));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -171,7 +172,7 @@ export function AssetCard({
|
||||
asset,
|
||||
projectId,
|
||||
setSelectedElementId,
|
||||
requestClipReveal,
|
||||
requestTimelineFocus,
|
||||
setPreviewAsset,
|
||||
clearPreviewAsset,
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
||||
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
||||
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
||||
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
||||
|
||||
export function AudioRow({
|
||||
projectId,
|
||||
@@ -43,7 +44,7 @@ export function AudioRow({
|
||||
// CapCut-style click behavior: drag-threshold gate.
|
||||
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
|
||||
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
||||
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
||||
@@ -67,7 +68,7 @@ export function AudioRow({
|
||||
const clipKey = clip.key ?? clip.id;
|
||||
setSelectedElementId(clipKey);
|
||||
// Scroll the timeline so the selected clip is actually visible.
|
||||
requestClipReveal(clipKey);
|
||||
requestTimelineFocus(timelineClipFocusId(clipKey));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -80,7 +81,7 @@ export function AudioRow({
|
||||
asset,
|
||||
projectId,
|
||||
setSelectedElementId,
|
||||
requestClipReveal,
|
||||
requestTimelineFocus,
|
||||
setPreviewAsset,
|
||||
clearPreviewAsset,
|
||||
],
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("timeline performance fixture", () => {
|
||||
usePlayerStore.setState({
|
||||
isPlaying: true,
|
||||
requestedSeekTime: 42,
|
||||
clipRevealRequest: { elementId: "stale", nonce: 7 },
|
||||
timelineFocus: { id: "stale", projectId: null, sessionEpoch: 0, nonce: 7 },
|
||||
clipManifest: [],
|
||||
lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe("timeline performance fixture", () => {
|
||||
expect(usePlayerStore.getState()).toMatchObject({
|
||||
isPlaying: false,
|
||||
requestedSeekTime: null,
|
||||
clipRevealRequest: null,
|
||||
timelineFocus: null,
|
||||
clipManifest: null,
|
||||
duration: 600,
|
||||
timelineReady: true,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -492,29 +492,36 @@ describe("usePlayerStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipRevealRequest", () => {
|
||||
it("starts null and carries the requested element id", () => {
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
|
||||
});
|
||||
describe("timelineFocus", () => {
|
||||
it("stamps project scope and carries the requested logical id", () => {
|
||||
usePlayerStore.getState().beginTimelineSession("project-a");
|
||||
usePlayerStore.getState().requestTimelineFocus("clip:el-1");
|
||||
expect(usePlayerStore.getState().timelineFocus).toMatchObject({
|
||||
id: "clip:el-1",
|
||||
projectId: "project-a",
|
||||
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
||||
});
|
||||
const store = usePlayerStore.getState();
|
||||
store.requestTimelineFocus("clip:el-1");
|
||||
const first = usePlayerStore.getState().timelineFocus;
|
||||
if (!first) throw new Error("expected timeline focus request");
|
||||
store.clearTimelineFocus(first.nonce);
|
||||
store.reset();
|
||||
store.requestTimelineFocus("clip:el-1");
|
||||
const second = usePlayerStore.getState().timelineFocus;
|
||||
expect(second?.nonce).toBe(first.nonce + 1);
|
||||
|
||||
it("bumps the nonce on repeat requests for the same clip", () => {
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
const first = usePlayerStore.getState().clipRevealRequest;
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
const second = usePlayerStore.getState().clipRevealRequest;
|
||||
expect(second?.nonce).not.toBe(first?.nonce);
|
||||
});
|
||||
|
||||
it("clears via clearClipRevealRequest and on reset", () => {
|
||||
usePlayerStore.getState().requestClipReveal("el-1");
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
|
||||
usePlayerStore.getState().requestClipReveal("el-2");
|
||||
usePlayerStore.getState().reset();
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
store.beginTimelineSession("project-a");
|
||||
store.requestTimelineFocus("clip:el-1");
|
||||
const stale = usePlayerStore.getState().timelineFocus;
|
||||
if (!stale) throw new Error("expected timeline focus request");
|
||||
store.requestTimelineFocus("clip:el-2");
|
||||
const replacement = usePlayerStore.getState().timelineFocus;
|
||||
if (!replacement) throw new Error("expected replacement timeline focus request");
|
||||
store.clearTimelineFocus(stale.nonce);
|
||||
expect(usePlayerStore.getState().timelineFocus).toBe(replacement);
|
||||
store.beginTimelineSession("project-b");
|
||||
expect(usePlayerStore.getState().timelineFocus).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../utils/studioUiPreferences";
|
||||
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
|
||||
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
|
||||
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
||||
|
||||
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
||||
export { liveTime } from "./liveTime";
|
||||
@@ -221,15 +222,10 @@ interface PlayerState extends KeyframeSlice {
|
||||
requestSeek: (time: number) => void;
|
||||
clearSeekRequest: () => void;
|
||||
|
||||
/**
|
||||
* Request the timeline to scroll a clip into view (e.g. clicking an
|
||||
* already-added asset card in the sidebar). Consumed and cleared by
|
||||
* useTimelineRevealClip. The nonce makes repeat requests for the same
|
||||
* clip observable so a second click re-reveals after the user scrolls away.
|
||||
*/
|
||||
clipRevealRequest: { elementId: string; nonce: number } | null;
|
||||
requestClipReveal: (elementId: string) => void;
|
||||
clearClipRevealRequest: () => void;
|
||||
timelineFocus: TimelineFocusRequest | null;
|
||||
timelineFocusNonce: number;
|
||||
requestTimelineFocus: (id: string) => void;
|
||||
clearTimelineFocus: (nonce: number) => void;
|
||||
|
||||
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
|
||||
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
|
||||
@@ -301,8 +297,8 @@ export function createTimelineResetState() {
|
||||
focusedEaseSegment: null,
|
||||
selectedElementIds: new Set<string>(),
|
||||
requestedSeekTime: null,
|
||||
clipRevealRequest: null,
|
||||
lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
|
||||
timelineFocus: null,
|
||||
keyframeCache: new Map<string, KeyframeCacheEntry>(),
|
||||
gsapAnimations: new Map<string, GsapAnimation[]>(),
|
||||
beatAnalysis: null,
|
||||
@@ -375,12 +371,23 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
requestSeek: (time) => set({ requestedSeekTime: time }),
|
||||
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
||||
|
||||
clipRevealRequest: null,
|
||||
requestClipReveal: (elementId) =>
|
||||
set((s) => ({
|
||||
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
|
||||
})),
|
||||
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
|
||||
timelineFocus: null,
|
||||
timelineFocusNonce: 0,
|
||||
requestTimelineFocus: (id) =>
|
||||
set((s) => {
|
||||
const nonce = s.timelineFocusNonce + 1;
|
||||
return {
|
||||
timelineFocusNonce: nonce,
|
||||
timelineFocus: createTimelineFocusRequest(
|
||||
id,
|
||||
s.timelineProjectId,
|
||||
s.timelineSessionEpoch,
|
||||
nonce,
|
||||
),
|
||||
};
|
||||
}),
|
||||
clearTimelineFocus: (nonce) =>
|
||||
set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)),
|
||||
|
||||
lintFindingsByElement: new Map(),
|
||||
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface TimelineFocusRequest {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
sessionEpoch: number;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
export function createTimelineFocusRequest(
|
||||
id: string,
|
||||
projectId: string | null,
|
||||
sessionEpoch: number,
|
||||
nonce: number,
|
||||
): TimelineFocusRequest {
|
||||
return { id, projectId, sessionEpoch, nonce };
|
||||
}
|
||||
Reference in New Issue
Block a user