From 34ffbb9dd027fb43ac88ac8e7aa7c178690e4cee Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 21:43:36 +0200 Subject: [PATCH] feat(studio): add timeline keyboard controls --- .../player/components/LayerDisclosureRow.tsx | 1 + .../studio/src/player/components/Timeline.tsx | 4 +- .../Timeline.virtualization.test.tsx | 2 + .../player/components/TimelineClip.test.tsx | 19 +- .../src/player/components/TimelineClip.tsx | 16 +- .../components/TimelineClipDiamonds.test.tsx | 98 ++++++++++- .../components/TimelineClipDiamonds.tsx | 99 ++++++++--- .../player/components/TimelineLaneTypes.ts | 1 + .../src/player/components/TimelineLanes.tsx | 112 +++++++----- .../components/TimelinePropertyLanes.test.tsx | 2 +- .../components/TimelinePropertyLanes.tsx | 5 + .../player/components/TimelineTrackHeader.tsx | 7 +- .../player/components/TimelineTrackRow.tsx | 4 +- .../components/timelineKeyboardNavigation.ts | 2 +- .../useTimelineKeyboardActor.test.tsx | 166 ++++++++++++++++++ .../components/useTimelineKeyboardActor.ts | 109 ++++++++++++ 16 files changed, 555 insertions(+), 92 deletions(-) create mode 100644 packages/studio/src/player/components/useTimelineKeyboardActor.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineKeyboardActor.ts diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx index 2535ee2e09..6bd8ef6af1 100644 --- a/packages/studio/src/player/components/LayerDisclosureRow.tsx +++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx @@ -28,6 +28,7 @@ export function LayerDisclosureRow({ > ); }); diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx index 507ac156a7..96ba6bd9c7 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx @@ -39,7 +39,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(() => { @@ -58,14 +58,56 @@ function renderDiamonds(onClickKeyframe = vi.fn()) { 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( + , + ); + }); + + const before = host.querySelectorAll('[data-keyframe-outside-clip="before"]'); + const after = host.querySelector('[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) { usePlayerStore.setState({ elements: [RETIME_ELEMENT] }); const host = createTimelineHost(); @@ -156,6 +198,27 @@ 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('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(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('button[title="50%"]')!; + act(() => + diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })), + ); + expect(onShiftClickKeyframe).toHaveBeenCalledWith("clip-1", 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; @@ -654,10 +717,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(); @@ -695,6 +757,7 @@ describe("TimelineClipDiamonds", () => { const renderSegmentLane = (lastAmbiguous: boolean) => { const host = createTimelineHost(); const root = createRoot(host); + const onSelectSegment = vi.fn(); const kf = (percentage: number, extra: Record = {}) => ({ percentage, tweenPercentage: percentage, @@ -727,13 +790,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)", () => { @@ -746,8 +811,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("[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("[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()); }); diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 3e0f312df2..4a5001e4f6 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -11,6 +11,7 @@ import { beginTimelineKeyframeRetime, type TimelineKeyframeRetimeHandle, } from "./useTimelineKeyframeHandlers"; +import { timelineEaseFocusId, timelineKeyframeFocusId } from "./timelineNavigationIdentity"; export interface TimelineDiamondKeyframe { percentage: number; @@ -42,7 +43,10 @@ interface TimelineClipDiamondsProps { isSelected: boolean; currentPercentage: number; elementId: string; + clipStart?: number; + clipDuration?: number; selectedKeyframes: ReadonlySet; + rovingTargetId?: string | null; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; @@ -82,12 +86,10 @@ interface TimelineDiamondLaneProps extends Omit< } 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. -const KF_MIN_PCT = -5; -const KF_MAX_PCT = 105; + +function keyframeTimeLabel(clipStart: number, clipDuration: number, percentage: number): string { + return `${Number((clipStart + (clipDuration * percentage) / 100).toFixed(2))}s`; +} function keyframeTarget( keyframe: TimelineDiamondKeyframe, @@ -113,7 +115,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ isSelected, currentPercentage, elementId, + clipStart = 0, + clipDuration = 0, selectedKeyframes, + rovingTargetId = null, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, @@ -163,16 +168,33 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ ? Math.round(clipHeightPx * 0.45) : Math.round(LANE_H * DIAMOND_RATIO); const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2; - const sorted = keyframesData.keyframes - .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT) - .sort((a, b) => a.percentage - b.percentage); + // Keep authored keyframes outside the element's visible clip window. Hiding + // them made the lane's count disagree with its diamonds and left users unable + // to inspect/remove the state that still affects the clip when 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)); + const keyframeCenterX = (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; + }; // Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs // the whole row to bound the dragged diamond between its immediate siblings. const sortedClipPcts = sorted.map((k) => k.percentage); - const sortedCenterXs = sorted.map((keyframe) => - Math.max(0, Math.min(clipWidthPx, (keyframe.percentage / 100) * clipWidthPx)), - ); + const sortedCenterXs = sorted.map((keyframe) => keyframeCenterX(keyframe)); const markerMetrics = sortedCenterXs.map((centerX, index) => { + const keyframe = sorted[index]!; + if (keyframe.percentage < 0 || keyframe.percentage > 100) { + return { hitWidth: diamondSize, visualSize: diamondSize }; + } const previousGap = index > 0 ? centerX - sortedCenterXs[index - 1]! : Infinity; const nextGap = index < sortedCenterXs.length - 1 ? sortedCenterXs[index + 1]! - centerX : Infinity; @@ -217,22 +239,25 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ // so there is no tween to target. const target = keyframeTarget(kf, true); const ease = kf.ease ?? globalEase; + const focusId = timelineEaseFocusId(elementId, target); return ( -
+ {connectorWidth > 0 && ( +
+ )} {onSelectSegment && kf.animationId !== undefined && (
{ const target = keyframeTarget(kf, groupAware); + const focusId = timelineKeyframeFocusId(elementId, target); const kfKey = timelineKeyframeSelectionKey(elementId, target); // While dragging this diamond, render it at the live preview clip-%. const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage; @@ -297,7 +325,8 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ // 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 marker = markerMetrics[i]!; - const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2; + const boundary = kf.percentage < 0 ? "before" : kf.percentage > 100 ? "after" : null; + const leftPx = keyframeCenterX(kf, renderPct) - marker.hitWidth / 2; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; const isHighlighted = isKfSelected || atPlayhead; @@ -350,10 +379,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ key={`${i}-${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 } + 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, top: centerY, @@ -375,6 +409,13 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ onPointerDown={onPointerDown} onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined} onPointerUp={onPointerUp} + onClick={(e) => { + if (e.detail !== 0) return; + e.stopPropagation(); + suppressNextClick(); + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); + }} onPointerCancel={ canDrag ? (e) => { @@ -388,7 +429,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ e.stopPropagation(); onContextMenuKeyframe?.(e, target); }} - title={`${kf.percentage}%`} + title={`${kf.percentage}%${boundary ? ` · ${boundary} clip` : ""}`} > { + if (row.elementId) toggleClipExpandedTracked(row.elementId); + }, + }); return (
{ @@ -190,6 +203,7 @@ export function TimelineLanes({ virtualized={rowsVirtualized} background={rowBackground} borderColor={theme.rowBorder} + rovingTargetId={keyboard.rovingTargetId} >
setHoveredClip(clipKey)} onHoverEnd={() => setHoveredClip(null)} onResizeStart={ @@ -466,41 +476,55 @@ export function TimelineLanes({ renderClipContent, renderClipOverlay, )} - {STUDIO_KEYFRAMES_ENABLED && - !showsLanes && - keyframeCache?.get(elementKey) && ( - 0 - ? ((currentTime - previewElement.start) / - previewElement.duration) * - 100 - : 0 - } - elementId={elementKey} - selectedKeyframes={selectedKeyframes} - onClickKeyframe={(pct) => - onClickKeyframe?.(previewElement, { percentage: pct }) - } - onShiftClickKeyframe={(elId, pct) => - onShiftClickKeyframe?.(elId, { percentage: pct }) - } - onContextMenuKeyframe={(e, elId, pct) => - onContextMenuKeyframe?.(e, elId, { percentage: pct }) - } - onMoveKeyframe={onMoveKeyframe} - onSelectSegment={onSelectSegment} - suppressClickRef={suppressClickRef} - /> - )} ); + const compactDiamonds = STUDIO_KEYFRAMES_ENABLED && + !showsLanes && + keyframeCache?.get(elementKey) && ( +
+ 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * + 100 + : 0 + } + elementId={elementKey} + clipStart={previewElement.start} + clipDuration={previewElement.duration} + selectedKeyframes={selectedKeyframes} + rovingTargetId={keyboard.rovingTargetId} + onClickKeyframe={(pct) => + onClickKeyframe?.(previewElement, { percentage: pct }) + } + onShiftClickKeyframe={(elId, pct) => + onShiftClickKeyframe?.(elId, { percentage: pct }) + } + onContextMenuKeyframe={(e, elId, pct) => + onContextMenuKeyframe?.(e, elId, { percentage: pct }) + } + onMoveKeyframe={onMoveKeyframe} + onSelectSegment={onSelectSegment} + suppressClickRef={suppressClickRef} + /> +
+ ); const propertyLanes = showsLanes && ( onSelectSegment?.(elementKey, target)} onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)} onShiftClickKeyframe={(target) => @@ -539,7 +564,7 @@ export function TimelineLanes({ suppressClickRef={suppressClickRef} /> ); - if (!isPassenger) return [clip, propertyLanes]; + if (!isPassenger) return [clip, compactDiamonds, propertyLanes]; return (
{clip} + {compactDiamonds} {propertyLanes}
); diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.test.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.test.tsx index 200f25d8c1..d50934da3b 100644 --- a/packages/studio/src/player/components/TimelinePropertyLanes.test.tsx +++ b/packages/studio/src/player/components/TimelinePropertyLanes.test.tsx @@ -320,7 +320,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()); }); diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.tsx index f6037f5c52..c2706d0753 100644 --- a/packages/studio/src/player/components/TimelinePropertyLanes.tsx +++ b/packages/studio/src/player/components/TimelinePropertyLanes.tsx @@ -22,6 +22,7 @@ export interface TimelinePropertyLanesProps { currentPercentage: number; elementId: string; selectedKeyframes: ReadonlySet; + rovingTargetId?: string | null; onSelectSegment?: (target: TimelineKeyframeTarget) => void; onClickKeyframe?: (target: TimelineKeyframeTarget) => void; onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void; @@ -110,6 +111,7 @@ export function TimelinePropertyLanes({ currentPercentage, elementId, selectedKeyframes, + rovingTargetId = null, onSelectSegment, onClickKeyframe, onShiftClickKeyframe, @@ -150,7 +152,10 @@ export function TimelinePropertyLanes({ isSelected={isSelected} currentPercentage={currentPercentage} elementId={elementId} + clipStart={clipStart} + clipDuration={clipDuration} selectedKeyframes={selectedKeyframes} + rovingTargetId={rovingTargetId} onSelectSegment={onSelectSegment} onClickKeyframe={onClickKeyframe} onShiftClickKeyframe={onShiftClickKeyframe} diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index a88e1a5e71..46c37342b9 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -41,6 +41,7 @@ interface TimelineTrackHeaderProps { isAudioTrack: boolean; isActive: boolean; isHovered: boolean; + rovingTargetId?: string | null; theme: TimelineTheme; onToggleClipExpanded: () => void; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; @@ -379,6 +380,7 @@ function PropertyGroupHeaderRow({ onToggleTrackHidden, onTogglePropertyGroupKeyframe, onSeek, + rovingTargetId = null, }: { lane: TimelinePropertyLane; laneIndex: number; @@ -396,6 +398,7 @@ function PropertyGroupHeaderRow({ onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onSeek?: (time: number) => void; + rovingTargetId: string | null; }) { const elementId = expandedElement.key ?? expandedElement.id; const { navigation, values, label, toggleTarget } = resolveLaneHeaderState( @@ -412,7 +415,7 @@ function PropertyGroupHeaderRow({ id={timelineLogicalRowCellId(timelinePropertyRowId(elementId, lane.group), "header")} data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)} data-timeline-element-id={elementId} - tabIndex={-1} + 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 px-1.5 text-[10px] text-white/65" @@ -484,6 +487,7 @@ export function TimelineTrackHeader({ onToggleTrackHidden, onTogglePropertyGroupKeyframe, onSeek, + rovingTargetId = null, }: TimelineTrackHeaderProps) { const [hoveredGroup, setHoveredGroup] = useState(null); const clipPercentage = keyframeClip @@ -550,6 +554,7 @@ export function TimelineTrackHeader({ onToggleTrackHidden={onToggleTrackHidden} onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} onSeek={onSeek} + rovingTargetId={rovingTargetId} /> ))} diff --git a/packages/studio/src/player/components/TimelineTrackRow.tsx b/packages/studio/src/player/components/TimelineTrackRow.tsx index 582db57fda..b60f11eb03 100644 --- a/packages/studio/src/player/components/TimelineTrackRow.tsx +++ b/packages/studio/src/player/components/TimelineTrackRow.tsx @@ -12,6 +12,7 @@ interface TimelineTrackRowProps { virtualized: boolean; background: string; borderColor: string; + rovingTargetId?: string | null; children: ReactNode; } @@ -26,6 +27,7 @@ export function TimelineTrackRow({ virtualized, background, borderColor, + rovingTargetId = null, children, }: TimelineTrackRowProps) { return ( @@ -49,7 +51,7 @@ export function TimelineTrackRow({ aria-expanded={logicalRow.expandable ? logicalRow.expanded : undefined} data-timeline-logical-row-id={logicalRow.id} data-timeline-focus-id={logicalRow.id} - tabIndex={-1} + tabIndex={rovingTargetId === logicalRow.id ? 0 : -1} className="flex" style={{ height }} > diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index b2d88354be..62cad46a36 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -202,7 +202,7 @@ export function buildTimelineLogicalRows({ logicalIndex: rows.length, level: 1, parentId: null, - elementId: null, + elementId: activeId, expandable: lanes.length > 0, expanded, items: clipItems(trackId, elements), diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.test.tsx b/packages/studio/src/player/components/useTimelineKeyboardActor.test.tsx new file mode 100644 index 0000000000..6a4f64cb1e --- /dev/null +++ b/packages/studio/src/player/components/useTimelineKeyboardActor.test.tsx @@ -0,0 +1,166 @@ +// @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; + onToggleRow?: (target: TimelineLogicalRow) => void; +} + +function Harness({ focusedTargetId = null, onToggleRow = vi.fn() }: HarnessProps) { + const scrollRef = useRef(null); + const keyboard = useTimelineKeyboardActor({ + logicalRows: rows, + focusedTargetId, + rowGeometry: createTimelineRowGeometry([1, 2, 3], [48, 48, 48]), + scrollRef, + onToggleRow, + }); + return ( +
+ {rows + .flatMap((row) => [row, ...row.items]) + .map((target) => ( + + ))} +
+ ); +} + +function renderHarness(props: React.ComponentProps = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render()); + return { host, root }; +} + +function key(target: Element, value: string, init: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: value, + ...init, + }); + act(() => target.dispatchEvent(event)); + return event; +} + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.setState({ timelineFocus: null, timelineFocusNonce: 0 }); +}); + +describe("useTimelineKeyboardActor", () => { + it("exposes exactly one roving target and persists focused logical identity", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-2" }); + expect(host.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + const target = host.querySelector('[data-timeline-focus-id="track-3"]')!; + act(() => target.focus()); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3"); + act(() => root.unmount()); + }); + + it("requests navigation focus without clicking or seeking", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-1" }); + const target = host.querySelector('[data-timeline-focus-id="clip-1"]')!; + const click = vi.fn(); + target.addEventListener("click", click); + const event = key(target, "ArrowDown"); + expect(event.defaultPrevented).toBe(true); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-2"); + expect(usePlayerStore.getState().requestedSeekTime).toBeNull(); + expect(click).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("supports modified timeline boundaries and viewport-sized paging", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-2" }); + const viewport = host.firstElementChild as HTMLDivElement; + Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 48 }); + const target = host.querySelector('[data-timeline-focus-id="clip-2"]')!; + key(target, "End", { metaKey: true }); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3"); + key(target, "PageUp"); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-1"); + act(() => root.unmount()); + }); + + it("toggles expandable rows but leaves native control activation to the control", () => { + const onToggleRow = vi.fn(); + const { host, root } = renderHarness({ focusedTargetId: "track-1", onToggleRow }); + const row = host.querySelector('[data-timeline-focus-id="track-1"]')!; + const clip = host.querySelector('[data-timeline-focus-id="clip-1"]')!; + expect(key(row, " ").defaultPrevented).toBe(true); + expect(onToggleRow).toHaveBeenCalledWith(rows[0]); + expect(key(clip, "Enter").defaultPrevented).toBe(false); + expect(onToggleRow).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); + + it("dispatches the existing scoped context-menu callback", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-1" }); + const target = host.querySelector('[data-timeline-focus-id="clip-1"]')!; + const context = vi.fn((event: Event) => event.preventDefault()); + target.addEventListener("contextmenu", context); + key(target, "F10", { shiftKey: true }); + expect(context).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.ts b/packages/studio/src/player/components/useTimelineKeyboardActor.ts new file mode 100644 index 0000000000..9fe6fdfcf6 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineKeyboardActor.ts @@ -0,0 +1,109 @@ +import { useCallback, type FocusEvent, type KeyboardEvent, type RefObject } from "react"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineRowGeometry } from "./timelineLayout"; +import { + isTimelineNavigationKey, + locateTimelineLogicalTarget, + resolveTimelineNavigationTarget, + type TimelineLogicalRow, +} from "./timelineKeyboardNavigation"; + +interface TimelineKeyboardActorInput { + logicalRows: readonly TimelineLogicalRow[]; + focusedTargetId: string | null; + rowGeometry: TimelineRowGeometry; + scrollRef: RefObject; + onToggleRow: (target: TimelineLogicalRow) => void; +} + +function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null { + if (!(event.target instanceof Element)) return null; + const target = event.target.closest("[data-timeline-focus-id]"); + return target && event.currentTarget.contains(target) ? target : null; +} + +function viewportPageSize( + rows: readonly TimelineLogicalRow[], + geometry: TimelineRowGeometry, + viewport: HTMLDivElement | null, +): number { + if (!viewport || rows.length === 0) return 1; + const first = Math.max(0, Math.floor(geometry.getRowFromY(viewport.scrollTop))); + const last = Math.min( + geometry.rowKeys.length - 1, + Math.floor(geometry.getRowFromY(viewport.scrollTop + viewport.clientHeight)), + ); + const visibleTracks = new Set(geometry.rowKeys.slice(first, last + 1)); + return Math.max(1, rows.filter((row) => visibleTracks.has(row.physicalTrackKey)).length); +} + +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 onFocus = useCallback( + (event: FocusEvent) => { + const id = eventTarget(event)?.dataset.timelineFocusId; + if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id); + }, + [focusedTargetId], + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + const targetElement = eventTarget(event); + const id = targetElement?.dataset.timelineFocusId; + if (!targetElement || !id) return; + const located = locateTimelineLogicalTarget(logicalRows, id); + if (!located) return; + + if (isTimelineNavigationKey(event.key)) { + const next = resolveTimelineNavigationTarget(logicalRows, id, event.key, { + pageSize: viewportPageSize(logicalRows, 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); + }, + [logicalRows, onToggleRow, rowGeometry, scrollRef], + ); + + return { rovingTargetId, onFocus, onKeyDown }; +}