diff --git a/docs/studio/shortcuts.mdx b/docs/studio/shortcuts.mdx index b225da279..0acc350cd 100644 --- a/docs/studio/shortcuts.mdx +++ b/docs/studio/shortcuts.mdx @@ -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 | diff --git a/docs/studio/timeline.mdx b/docs/studio/timeline.mdx index 126ba369f..b24ed5de7 100644 --- a/docs/studio/timeline.mdx +++ b/docs/studio/timeline.mdx @@ -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. diff --git a/packages/studio/src/components/sidebar/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx index e6eaeca96..6ebf6fcb9 100644 --- a/packages/studio/src/components/sidebar/AssetCard.tsx +++ b/packages/studio/src/components/sidebar/AssetCard.tsx @@ -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, ], diff --git a/packages/studio/src/components/sidebar/AudioRow.tsx b/packages/studio/src/components/sidebar/AudioRow.tsx index 5df7d5980..7b0fff0a2 100644 --- a/packages/studio/src/components/sidebar/AudioRow.tsx +++ b/packages/studio/src/components/sidebar/AudioRow.tsx @@ -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, ], diff --git a/packages/studio/src/hooks/useStudioTestHooks.test.tsx b/packages/studio/src/hooks/useStudioTestHooks.test.tsx index b1da619c9..e76531c45 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.test.tsx +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -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, diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx index 415b72c09..ba17174a1 100644 --- a/packages/studio/src/player/components/LayerDisclosureRow.tsx +++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx @@ -44,6 +44,8 @@ export function LayerDisclosureRow({ > ); }); diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx index 20481874e..c1ddaf917 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx @@ -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( + , + ); + }); + + 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, @@ -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('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('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 = {}) => ({ 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("[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 4117e7ac4..1f41a4881 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -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({ { 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` : ""}`} > { + 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 ( +
+ 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} + /> +
+ ); +} diff --git a/packages/studio/src/player/components/TimelineDiamondConnectors.tsx b/packages/studio/src/player/components/TimelineDiamondConnectors.tsx index 22b8a3f1e..0312a4791 100644 --- a/packages/studio/src/player/components/TimelineDiamondConnectors.tsx +++ b/packages/studio/src/player/components/TimelineDiamondConnectors.tsx @@ -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 (
void; }) { @@ -151,7 +177,9 @@ function SegmentEaseControl({ +
+ ) : ( + + ), + )} + + ); +} + +function renderHarness(props: React.ComponentProps = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render()); + return { host, root }; +} + +function key(target: Element, value: string, init: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: value, + ...init, + }); + act(() => target.dispatchEvent(event)); + return event; +} + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.setState({ timelineFocus: null, timelineFocusNonce: 0 }); +}); + +describe("useTimelineKeyboardActor", () => { + it("exposes exactly one roving target and persists focused logical identity", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-2" }); + expect(host.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1); + expect(host.querySelectorAll("[data-native-control]")).toHaveLength(3); + expect( + [...host.querySelectorAll("[data-native-control]")].every( + (el) => el.tabIndex === 0, + ), + ).toBe(true); + const target = host.querySelector('[data-timeline-focus-id="track-3"]')!; + act(() => target.focus()); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3"); + act(() => root.unmount()); + }); + + it("requests navigation focus without clicking or seeking", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-1" }); + const target = host.querySelector('[data-timeline-focus-id="clip-1"]')!; + const click = vi.fn(); + target.addEventListener("click", click); + const event = key(target, "ArrowDown"); + expect(event.defaultPrevented).toBe(true); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-2"); + expect(usePlayerStore.getState().requestedSeekTime).toBeNull(); + expect(click).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("supports modified timeline boundaries and viewport-sized paging", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-2" }); + const viewport = host.firstElementChild as HTMLDivElement; + Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 48 }); + const target = host.querySelector('[data-timeline-focus-id="clip-2"]')!; + key(target, "End", { metaKey: true }); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3"); + key(target, "PageUp"); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-1"); + act(() => root.unmount()); + }); + + it("sizes paging around off-screen focus instead of the current viewport", () => { + const logicalRows: readonly TimelineLogicalRow[] = [ + rows[0]!, + { + ...rows[0]!, + id: "property-1", + logicalIndex: 1, + level: 2, + parentId: "track-1", + expandable: false, + items: [], + }, + { + ...rows[0]!, + id: "property-2", + logicalIndex: 2, + level: 2, + parentId: "track-1", + expandable: false, + items: [], + }, + { ...rows[1]!, logicalIndex: 3 }, + { ...rows[2]!, logicalIndex: 4 }, + ]; + const { host, root } = renderHarness({ + focusedTargetId: "track-3", + logicalRows, + rowHeights: [104, 48, 48], + }); + const viewport = host.firstElementChild as HTMLDivElement; + Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 47 }); + const target = host.querySelector('[data-timeline-focus-id="track-3"]')!; + key(target, "PageUp"); + expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-2"); + act(() => root.unmount()); + }); + + it("supports APG disclosure arrows plus Enter and Space", () => { + const onToggleRow = vi.fn(); + const { host, root } = renderHarness({ focusedTargetId: "track-1", onToggleRow }); + const row = host.querySelector('[data-timeline-focus-id="track-1"]')!; + const clip = host.querySelector('[data-timeline-focus-id="clip-1"]')!; + const nativeClick = vi.fn(); + clip.addEventListener("click", nativeClick); + expect(key(row, "ArrowRight").defaultPrevented).toBe(true); + expect(onToggleRow).toHaveBeenCalledWith(rows[0]); + expect(key(row, " ").defaultPrevented).toBe(true); + expect(onToggleRow).toHaveBeenCalledWith(rows[0]); + const enter = key(clip, "Enter"); + expect(enter.defaultPrevented).toBe(false); + // dispatchEvent does not synthesize a browser default action, so model it explicitly. + if (!enter.defaultPrevented) act(() => clip.click()); + expect(nativeClick).toHaveBeenCalledOnce(); + expect(onToggleRow).toHaveBeenCalledTimes(2); + act(() => root.unmount()); + }); + + it("collapses expanded rows and leaves native header controls to the browser", () => { + const onToggleRow = vi.fn(); + const expandedRows = [{ ...rows[0]!, expanded: true }, ...rows.slice(1)]; + const { host, root } = renderHarness({ + focusedTargetId: "track-1", + logicalRows: expandedRows, + onToggleRow, + }); + const row = host.querySelector('[data-timeline-focus-id="track-1"]')!; + expect(key(row, "ArrowLeft").defaultPrevented).toBe(true); + expect(onToggleRow).toHaveBeenCalledWith(expandedRows[0]); + + usePlayerStore.setState({ timelineFocus: null, timelineFocusNonce: 0 }); + const control = host.querySelector('[data-native-control="track-1"]')!; + const nativeClick = vi.fn(); + control.addEventListener("click", nativeClick); + act(() => control.focus()); + expect(usePlayerStore.getState().timelineFocus).toBeNull(); + const enter = key(control, "Enter"); + expect(enter.defaultPrevented).toBe(false); + // dispatchEvent does not synthesize a browser default action, so model it explicitly. + if (!enter.defaultPrevented) act(() => control.click()); + expect(nativeClick).toHaveBeenCalledOnce(); + expect(onToggleRow).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); + + it("dispatches the existing scoped context-menu callback", () => { + const { host, root } = renderHarness({ focusedTargetId: "clip-1" }); + const target = host.querySelector('[data-timeline-focus-id="clip-1"]')!; + const context = vi.fn((event: Event) => event.preventDefault()); + target.addEventListener("contextmenu", context); + key(target, "F10", { shiftKey: true }); + expect(context).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.ts b/packages/studio/src/player/components/useTimelineKeyboardActor.ts new file mode 100644 index 000000000..3969a5f84 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineKeyboardActor.ts @@ -0,0 +1,147 @@ +import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineRowGeometry } from "./timelineLayout"; +import { + isTimelineNavigationKey, + locateTimelineLogicalTarget, + resolveTimelineNavigationTarget, + type TimelineLogicalRow, +} from "./timelineKeyboardNavigation"; + +interface TimelineKeyboardActorInput { + logicalRows: readonly TimelineLogicalRow[]; + focusedTargetId: string | null; + rowGeometry: TimelineRowGeometry; + scrollRef: RefObject; + onToggleRow: (target: TimelineLogicalRow) => void; +} + +function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null { + if (!(event.target instanceof Element)) return null; + // Header actions stay native Tab stops because they have no row-level shortcut. + // The nearest interactive ancestor wins so their events never masquerade as row events. + const target = event.target.closest( + "button, input, select, textarea, a[href], [contenteditable], [data-timeline-focus-id]", + ); + return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null; +} + +function viewportPageSize( + logicalRowCountByTrack: ReadonlyMap, + focusedTrackKey: number, + geometry: TimelineRowGeometry, + viewport: HTMLDivElement | null, +): number { + if (!viewport || logicalRowCountByTrack.size === 0) return 1; + const focusedRow = geometry.getRowIndex(focusedTrackKey); + const first = Math.max( + 0, + focusedRow >= 0 ? focusedRow : Math.floor(geometry.getRowFromY(viewport.scrollTop)), + ); + const pageTop = focusedRow >= 0 ? geometry.getRowTop(focusedRow) : viewport.scrollTop; + // Stay just inside the viewport so an exact row boundary does not count the next row. + const last = Math.min( + geometry.rowKeys.length - 1, + Math.floor(geometry.getRowFromY(pageTop + Math.max(0, viewport.clientHeight - 0.001))), + ); + let count = 0; + for (let index = first; index <= last; index += 1) { + count += logicalRowCountByTrack.get(geometry.rowKeys[index]!) ?? 0; + } + return Math.max(1, count); +} + +function openContextMenu(target: HTMLElement): void { + const bounds = target.getBoundingClientRect(); + target.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: bounds.left + bounds.width / 2, + clientY: bounds.top + bounds.height / 2, + }), + ); +} + +/** The timeline's sole keyboard actor; controls only describe their logical identity. */ +export function useTimelineKeyboardActor({ + logicalRows, + focusedTargetId, + rowGeometry, + scrollRef, + onToggleRow, +}: TimelineKeyboardActorInput) { + const rovingTargetId = + (focusedTargetId && locateTimelineLogicalTarget(logicalRows, focusedTargetId)?.target.id) ?? + logicalRows[0]?.id ?? + null; + const logicalRowCountByTrack = useMemo(() => { + const counts = new Map(); + for (const row of logicalRows) { + counts.set(row.physicalTrackKey, (counts.get(row.physicalTrackKey) ?? 0) + 1); + } + return counts; + }, [logicalRows]); + + const onFocus = useCallback( + (event: FocusEvent) => { + const id = eventTarget(event)?.dataset.timelineFocusId; + if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id); + }, + // ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice. + [focusedTargetId], + ); + + const onKeyDown = useCallback( + // One handler owns navigation, context-menu, and disclosure keyboard semantics. + // fallow-ignore-next-line complexity + (event: KeyboardEvent) => { + const targetElement = eventTarget(event); + const id = targetElement?.dataset.timelineFocusId; + if (!targetElement || !id) return; + const located = locateTimelineLogicalTarget(logicalRows, id); + if (!located) return; + + if (isTimelineNavigationKey(event.key)) { + if ( + located.target.kind === "row" && + ((event.key === "ArrowRight" && located.target.expandable && !located.target.expanded) || + (event.key === "ArrowLeft" && located.target.expandable && located.target.expanded)) + ) { + event.preventDefault(); + onToggleRow(located.target); + return; + } + const next = resolveTimelineNavigationTarget(logicalRows, id, event.key, { + pageSize: viewportPageSize( + logicalRowCountByTrack, + located.row.physicalTrackKey, + rowGeometry, + scrollRef.current, + ), + timelineBoundary: event.ctrlKey || event.metaKey, + }); + event.preventDefault(); + if (next && next.id !== id) usePlayerStore.getState().requestTimelineFocus(next.id); + return; + } + if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) { + event.preventDefault(); + openContextMenu(targetElement); + return; + } + if ( + (event.key !== "Enter" && event.key !== " ") || + located.target.kind !== "row" || + !located.target.expandable + ) { + return; + } + event.preventDefault(); + onToggleRow(located.target); + }, + [logicalRowCountByTrack, logicalRows, onToggleRow, rowGeometry, scrollRef], + ); + + return { rovingTargetId, onFocus, onKeyDown }; +} diff --git a/packages/studio/src/player/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts new file mode 100644 index 000000000..36f6de3db --- /dev/null +++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts @@ -0,0 +1,80 @@ +import type { RefObject } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { TimelineElement } from "../store/playerStore"; +import type { TimelineRowGeometry } from "./timelineLayout"; +import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; +import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator"; +import { usePlayerStore } from "../store/playerStore"; +import { useTimelineLogicalRows } from "./useTimelineLogicalRows"; +import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization"; + +interface TimelineLogicalFocusInput { + scrollRef: RefObject; + tracks: readonly (readonly [number, readonly TimelineElement[]])[]; + layout: { displayTrackOrder: readonly number[]; rowGeometry: TimelineRowGeometry }; + laneCounts: ReadonlyMap; + selectedElementId: string | null; + selectedElementIds: ReadonlySet; + gsapAnimations: ReadonlyMap; + elements: readonly TimelineElement[]; + pixelsPerSecond: number; + contentOrigin: number; + allowHorizontal: boolean; + viewport: TimelineScrollViewportSnapshot; + sessionEpoch: number; + draggedRowKey?: number; + resizingElementIds?: readonly string[]; + clipContextMenuRowKey?: number; + keyframeContextMenuRowKey?: number; + lastScrollLeftRef: RefObject; + syncScrollViewport: (element: HTMLDivElement) => void; +} + +export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { + const expandedClipIds = usePlayerStore((state) => state.expandedClipIds); + const projectId = usePlayerStore((state) => state.timelineProjectId); + const logicalRows = useTimelineLogicalRows({ + tracks: input.tracks, + displayTrackOrder: input.layout.displayTrackOrder, + laneCounts: input.laneCounts, + selectedElementId: input.selectedElementId, + selectedElementIds: input.selectedElementIds, + expandedClipIds, + gsapAnimations: input.gsapAnimations, + }); + const focus = useTimelineFocusCoordinator({ + scrollRef: input.scrollRef, + logicalRows, + elements: input.elements, + rowGeometry: input.layout.rowGeometry, + pixelsPerSecond: input.pixelsPerSecond, + contentOrigin: input.contentOrigin, + allowHorizontal: input.allowHorizontal, + viewportVersion: input.viewport, + projectId, + sessionEpoch: input.sessionEpoch, + syncScrollViewport: input.syncScrollViewport, + }); + const rows = useTimelineRowVirtualization({ + scrollRef: input.scrollRef, + viewport: input.viewport, + rowGeometry: input.layout.rowGeometry, + sessionEpoch: input.sessionEpoch, + elements: input.elements, + selectedElementId: input.selectedElementId, + focusedRowKey: focus.focusedRowKey, + draggedRowKey: input.draggedRowKey, + resizingElementIds: input.resizingElementIds, + clipContextMenuRowKey: input.clipContextMenuRowKey, + keyframeContextMenuRowKey: input.keyframeContextMenuRowKey, + lastScrollLeftRef: input.lastScrollLeftRef, + syncScrollViewport: input.syncScrollViewport, + }); + return { + logicalRows, + ...focus, + rowVirtualizationActive: rows.enabled, + virtualRows: rows.virtualRows, + timelineFocusProps: rows.timelineFocusProps, + }; +} diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx new file mode 100644 index 000000000..f20f2699a --- /dev/null +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; +import { useTimelineLogicalRows } from "./useTimelineLogicalRows"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const tracks = Array.from( + { length: 1_000 }, + (_, track) => + [ + track, + [{ id: `clip-${track}`, tag: "div", track, start: track, duration: 1 }], + ] as const satisfies readonly [number, readonly TimelineElement[]], +); +const displayTrackOrder = tracks.map(([track]) => track); +const laneCounts = new Map(); +const selectedElementIds = new Set(); +const expandedClipIds = new Set(); +const gsapAnimations = new Map(); + +function Harness({ snapshots }: { snapshots: Array }) { + usePlayerStore((state) => state.requestedSeekTime); + const logicalRows = useTimelineLogicalRows({ + tracks, + displayTrackOrder, + laneCounts, + selectedElementId: null, + selectedElementIds, + expandedClipIds, + gsapAnimations, + }); + snapshots.push(logicalRows); + return null; +} + +afterEach(() => usePlayerStore.getState().reset()); + +describe("useTimelineLogicalRows", () => { + it("preserves the dense logical model across an unrelated store update", () => { + const host = document.createElement("div"); + const root = createRoot(host); + const snapshots: Array = []; + act(() => root.render()); + const first = snapshots.at(-1); + + act(() => usePlayerStore.setState({ requestedSeekTime: 1 })); + + expect(snapshots.at(-1)).toBe(first); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.ts b/packages/studio/src/player/components/useTimelineLogicalRows.ts new file mode 100644 index 000000000..eabe5d642 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineLogicalRows.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { TimelineElement } from "../store/playerStore"; +import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation"; + +interface TimelineLogicalRowsInput { + tracks: readonly (readonly [number, readonly TimelineElement[]])[]; + displayTrackOrder: readonly number[]; + laneCounts: ReadonlyMap; + selectedElementId: string | null; + selectedElementIds: ReadonlySet; + expandedClipIds: ReadonlySet; + gsapAnimations: ReadonlyMap; +} + +/** Shared by rendering and focus coordination; stable input refs preserve memo identity. */ +export function useTimelineLogicalRows({ + tracks, + displayTrackOrder, + laneCounts, + selectedElementId, + selectedElementIds, + expandedClipIds, + gsapAnimations, +}: TimelineLogicalRowsInput) { + return useMemo( + () => + buildTimelineLogicalRows({ + tracks, + displayTrackOrder, + laneCounts, + selectedElementId, + selectedElementIds, + expandedClipIds, + gsapAnimations, + }), + [ + displayTrackOrder, + expandedClipIds, + gsapAnimations, + laneCounts, + selectedElementId, + selectedElementIds, + tracks, + ], + ); +} diff --git a/packages/studio/src/player/components/useTimelineRevealClip.test.tsx b/packages/studio/src/player/components/useTimelineRevealClip.test.tsx deleted file mode 100644 index 9afc4ec21..000000000 --- a/packages/studio/src/player/components/useTimelineRevealClip.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act, useRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; -import type { TimelineElement } from "../store/playerStore"; -import { usePlayerStore } from "../store/playerStore"; -import { createTimelineRowGeometry } from "./timelineLayout"; -import { useTimelineRevealClip } from "./useTimelineRevealClip"; - -Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - -const element: TimelineElement = { - id: "hero", - tag: "div", - start: 20, - duration: 2, - track: 1, -}; -const geometry = createTimelineRowGeometry([1], [48]); - -function createHarnessRoot() { - const host = document.createElement("div"); - document.body.append(host); - return { host, root: createRoot(host) }; -} - -interface HarnessProps { - mounted: boolean; - version: number; - width?: number; - height?: number; - deferFocusUntilViewportUpdate?: boolean; - focusedElementId?: string; -} - -function Harness({ - mounted, - version, - width = 300, - height = 100, - deferFocusUntilViewportUpdate = false, - focusedElementId, -}: HarnessProps) { - const scrollRef = useRef(null); - useTimelineRevealClip({ - scrollRef, - elements: [element], - rowGeometry: geometry, - pixelsPerSecond: 100, - contentOrigin: 32, - allowHorizontal: true, - deferFocusUntilViewportUpdate, - focusedElementId, - viewportVersion: version, - sessionEpoch: 1, - }); - return ( -
{ - scrollRef.current = node; - if (node) { - Object.defineProperty(node, "clientWidth", { configurable: true, value: width }); - Object.defineProperty(node, "clientHeight", { configurable: true, value: height }); - } - }} - > - {mounted &&
} -
- ); -} - -async function renderHarness(root: Root, props: HarnessProps): Promise { - await act(async () => root.render()); -} - -afterEach(() => { - usePlayerStore.getState().reset(); - document.body.replaceChildren(); -}); - -describe("useTimelineRevealClip", () => { - it("scrolls from model coordinates, then consumes only after the clip mounts", async () => { - const { host, root } = createHarnessRoot(); - usePlayerStore.getState().requestClipReveal("hero"); - - await renderHarness(root, { mounted: false, version: 0 }); - const scroll = host.firstElementChild as HTMLDivElement; - expect(scroll.scrollLeft).toBe(1_944); - expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero"); - - await renderHarness(root, { mounted: true, version: 1 }); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); - expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero"); - - scroll.scrollLeft = 0; - await act(async () => usePlayerStore.getState().requestClipReveal("hero")); - await renderHarness(root, { mounted: true, version: 2 }); - expect(scroll.scrollLeft).toBe(1_944); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); - await act(async () => root.unmount()); - }); - - it("consumes an invalid target without scrolling", async () => { - const { root } = createHarnessRoot(); - usePlayerStore.getState().requestClipReveal("missing"); - await renderHarness(root, { mounted: false, version: 0 }); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); - await act(async () => root.unmount()); - }); - - it("keeps a reveal pending through zero-size viewport and virtualized focus handoff", async () => { - const { host, root } = createHarnessRoot(); - usePlayerStore.getState().requestClipReveal("hero"); - - await renderHarness(root, { mounted: true, version: 0, width: 0, height: 0 }); - const scroll = host.firstElementChild as HTMLDivElement; - expect(scroll.scrollLeft).toBe(0); - expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero"); - expect(document.activeElement?.getAttribute("data-el-id")).not.toBe("hero"); - - await renderHarness(root, { - mounted: true, - version: 1, - deferFocusUntilViewportUpdate: true, - }); - expect(scroll.scrollLeft).toBe(1_944); - expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero"); - await renderHarness(root, { - mounted: true, - version: 2, - deferFocusUntilViewportUpdate: true, - focusedElementId: "hero", - }); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); - expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero"); - await act(async () => root.unmount()); - }); -}); diff --git a/packages/studio/src/player/components/useTimelineRevealClip.ts b/packages/studio/src/player/components/useTimelineRevealClip.ts deleted file mode 100644 index cffa13e9c..000000000 --- a/packages/studio/src/player/components/useTimelineRevealClip.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { TimelineElement } from "../store/playerStore"; -import { usePlayerStore } from "../store/playerStore"; -import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -import { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout"; -import { computeRevealScroll } from "./timelineRevealScroll"; - -interface UseTimelineRevealClipInput { - scrollRef: React.RefObject; - elements: readonly TimelineElement[]; - rowGeometry: TimelineRowGeometry; - pixelsPerSecond: number; - contentOrigin: number; - allowHorizontal: boolean; - deferFocusUntilViewportUpdate: boolean; - focusedElementId?: string; - viewportVersion: unknown; - sessionEpoch: number; -} - -function escapeSelectorValue(value: string): string { - return typeof CSS !== "undefined" && typeof CSS.escape === "function" - ? CSS.escape(value) - : value.replace(/["\\]/g, "\\$&"); -} - -function scrollToTimelineElement( - container: HTMLDivElement, - element: TimelineElement, - row: number, - rowGeometry: TimelineRowGeometry, - pixelsPerSecond: number, - contentOrigin: number, - allowHorizontal: boolean, -): boolean { - const clipLeft = contentOrigin + element.start * pixelsPerSecond; - const target = computeRevealScroll({ - scrollLeft: container.scrollLeft, - scrollTop: container.scrollTop, - viewportWidth: container.clientWidth, - viewportHeight: container.clientHeight, - clipLeft, - clipRight: clipLeft + Math.max(element.duration * pixelsPerSecond, 4), - clipTop: rowGeometry.getRowTop(row) + CLIP_Y, - clipBottom: rowGeometry.getRowTop(row) + rowGeometry.getRowHeight(row) - CLIP_Y, - stickyLeft: contentOrigin, - stickyTop: RULER_H, - allowHorizontal, - }); - if (target.left !== null) container.scrollLeft = target.left; - if (target.top !== null) container.scrollTop = target.top; - const didScroll = target.left !== null || target.top !== null; - if (didScroll) container.dispatchEvent(new Event("scroll")); - return didScroll; -} - -function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean { - const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`); - if (!(clip instanceof HTMLElement)) return false; - const alreadyHighlighted = clip.hasAttribute("data-reveal-highlight"); - clip.setAttribute("data-reveal-highlight", "true"); - clip.focus({ preventScroll: true }); - if (document.activeElement !== clip) { - clip.removeAttribute("data-reveal-highlight"); - return false; - } - if (!alreadyHighlighted) { - clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), { - once: true, - }); - } - return true; -} - -function focusAndConsumeReveal( - container: HTMLDivElement, - request: { elementId: string; nonce: number }, - deferUntilFocusPin: boolean, - focusedElementId?: string, -): void { - if (!focusRevealedElement(container, request.elementId)) return; - if (deferUntilFocusPin && focusedElementId !== request.elementId) return; - if (usePlayerStore.getState().clipRevealRequest === request) { - usePlayerStore.getState().clearClipRevealRequest(); - } -} - -function resolveRevealTarget( - elements: readonly TimelineElement[], - rowGeometry: TimelineRowGeometry, - elementId: string, -): { element: TimelineElement; row: number } | null { - const element = elements.find((candidate) => getTimelineElementIdentity(candidate) === elementId); - if (!element) return null; - const row = rowGeometry.getRowIndex(element.track); - return row < 0 ? null : { element, row }; -} - -function shouldScrollReveal( - previous: { request: { elementId: string; nonce: number }; sessionEpoch: number } | null, - request: { elementId: string; nonce: number }, - sessionEpoch: number, -): boolean { - return previous?.request !== request || previous.sessionEpoch !== sessionEpoch; -} - -/** Coordinate-first reveal; the request remains pinned until its clip mounts. */ -export function useTimelineRevealClip({ - scrollRef, - elements, - rowGeometry, - pixelsPerSecond, - contentOrigin, - allowHorizontal, - deferFocusUntilViewportUpdate, - focusedElementId, - viewportVersion, - sessionEpoch, -}: UseTimelineRevealClipInput): void { - const revealRequest = usePlayerStore((state) => state.clipRevealRequest); - const scrolledRequestRef = useRef<{ - request: { elementId: string; nonce: number }; - sessionEpoch: number; - } | null>(null); - - useEffect(() => { - if (!revealRequest) { - scrolledRequestRef.current = null; - return; - } - const target = resolveRevealTarget(elements, rowGeometry, revealRequest.elementId); - if (!target) { - usePlayerStore.getState().clearClipRevealRequest(); - return; - } - const container = scrollRef.current; - if (!container) return; - if (container.clientWidth <= 0 || container.clientHeight <= 0) return; - - if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) { - scrolledRequestRef.current = { request: revealRequest, sessionEpoch }; - const didScroll = scrollToTimelineElement( - container, - target.element, - target.row, - rowGeometry, - pixelsPerSecond, - contentOrigin, - allowHorizontal, - ); - // Keep the reveal pin alive until the scroll snapshot catches up. If the - // request were consumed here, horizontal windowing could unmount and - // recreate the focused clip between the programmatic scroll and the next - // viewport publication. - if (didScroll && deferFocusUntilViewportUpdate) return; - } - - // Focus is the durable row/clip pin. Do not consume the reveal pin until - // the focus listener has published that replacement, or windowing can - // briefly unmount and recreate the element between the two owners. - focusAndConsumeReveal( - container, - revealRequest, - deferFocusUntilViewportUpdate, - focusedElementId, - ); - }, [ - allowHorizontal, - contentOrigin, - deferFocusUntilViewportUpdate, - elements, - focusedElementId, - pixelsPerSecond, - revealRequest, - rowGeometry, - scrollRef, - sessionEpoch, - viewportVersion, - ]); -} diff --git a/packages/studio/src/player/components/useTimelineRowVirtualization.ts b/packages/studio/src/player/components/useTimelineRowVirtualization.ts index 23297d435..239663155 100644 --- a/packages/studio/src/player/components/useTimelineRowVirtualization.ts +++ b/packages/studio/src/player/components/useTimelineRowVirtualization.ts @@ -20,9 +20,9 @@ interface UseTimelineRowVirtualizationInput { viewport: TimelineScrollViewportSnapshot; rowGeometry: TimelineRowGeometry; sessionEpoch: number; - elements: TimelineElement[]; + elements: readonly TimelineElement[]; selectedElementId: string | null; - revealElementId: string | null; + focusedRowKey?: number; draggedRowKey?: number; resizingElementIds?: readonly string[]; clipContextMenuRowKey?: number; @@ -31,19 +31,12 @@ interface UseTimelineRowVirtualizationInput { syncScrollViewport: (element: HTMLDivElement, isScrolling?: boolean) => void; } -interface TimelineDomFocusPin { - readonly rowKey?: number; - readonly elementId?: string; -} - -function getTimelineDomFocusPin(target: EventTarget | null): TimelineDomFocusPin | undefined { +function getFocusedTimelineRowKey(target: EventTarget | null): number | undefined { if (!(target instanceof Element)) return undefined; const value = target.closest("[data-timeline-row-key]")?.dataset.timelineRowKey; - const parsedRowKey = value === undefined ? undefined : Number(value); - const rowKey = - parsedRowKey !== undefined && Number.isFinite(parsedRowKey) ? parsedRowKey : undefined; - const elementId = target.closest("[data-el-id]")?.dataset.elId; - return rowKey === undefined && elementId === undefined ? undefined : { rowKey, elementId }; + if (value === undefined) return undefined; + const rowKey = Number(value); + return Number.isFinite(rowKey) ? rowKey : undefined; } export function useTimelineRowVirtualization({ @@ -53,7 +46,7 @@ export function useTimelineRowVirtualization({ sessionEpoch, elements, selectedElementId, - revealElementId, + focusedRowKey, draggedRowKey, resizingElementIds, clipContextMenuRowKey, @@ -62,21 +55,17 @@ export function useTimelineRowVirtualization({ syncScrollViewport, }: UseTimelineRowVirtualizationInput) { const enabled = STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED; - const [domFocusPin, setDomFocusPin] = useState(); + const [domFocusedRowKey, setDomFocusedRowKey] = useState(); const onTimelineFocus = useCallback((event: ReactFocusEvent) => { - setDomFocusPin(getTimelineDomFocusPin(event.target)); + setDomFocusedRowKey(getFocusedTimelineRowKey(event.target)); }, []); const onTimelineBlur = useCallback((event: ReactFocusEvent) => { - setDomFocusPin(getTimelineDomFocusPin(event.relatedTarget)); + setDomFocusedRowKey(getFocusedTimelineRowKey(event.relatedTarget)); }, []); - const focusIdentity = useMemo( + const selectedIdentity = useMemo( () => resolveTimelineFocusIdentity(elements, selectedElementId), [elements, selectedElementId], ); - const revealIdentity = useMemo( - () => resolveTimelineFocusIdentity(elements, revealElementId), - [elements, revealElementId], - ); const resizingRowKeys = useMemo( () => resizingElementIds @@ -89,16 +78,18 @@ export function useTimelineRowVirtualization({ [ draggedRowKey, ...resizingRowKeys, - revealIdentity?.rowKey, + selectedIdentity?.rowKey, + focusedRowKey, clipContextMenuRowKey, keyframeContextMenuRowKey, ].filter((rowKey): rowKey is number => rowKey !== undefined), [ clipContextMenuRowKey, draggedRowKey, + focusedRowKey, keyframeContextMenuRowKey, resizingRowKeys, - revealIdentity, + selectedIdentity, ], ); const virtualRows = useTimelineVirtualRows({ @@ -108,7 +99,7 @@ export function useTimelineRowVirtualization({ rowGeometry, sessionEpoch, pinnedRowKeys, - focusedRowKey: domFocusPin?.rowKey ?? focusIdentity?.rowKey, + focusedRowKey: domFocusedRowKey ?? focusedRowKey, }); const previousLayoutRef = useRef(rowGeometry); @@ -141,7 +132,6 @@ export function useTimelineRowVirtualization({ return { enabled, virtualRows, - focusedElementId: domFocusPin?.elementId, timelineFocusProps: { onFocus: onTimelineFocus, onBlur: onTimelineBlur }, }; } diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 799e3c388..d03b4238c 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -492,29 +492,36 @@ describe("usePlayerStore", () => { }); }); - describe("clipRevealRequest", () => { - it("starts null and carries the requested element id", () => { - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); - usePlayerStore.getState().requestClipReveal("el-1"); - expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1"); - }); + describe("timelineFocus", () => { + it("stamps project scope and carries the requested logical id", () => { + usePlayerStore.getState().beginTimelineSession("project-a"); + usePlayerStore.getState().requestTimelineFocus("clip:el-1"); + expect(usePlayerStore.getState().timelineFocus).toMatchObject({ + id: "clip:el-1", + projectId: "project-a", + sessionEpoch: usePlayerStore.getState().timelineSessionEpoch, + }); + const store = usePlayerStore.getState(); + store.requestTimelineFocus("clip:el-1"); + const first = usePlayerStore.getState().timelineFocus; + if (!first) throw new Error("expected timeline focus request"); + store.clearTimelineFocus(first.nonce); + store.reset(); + store.requestTimelineFocus("clip:el-1"); + const second = usePlayerStore.getState().timelineFocus; + expect(second?.nonce).toBe(first.nonce + 1); - it("bumps the nonce on repeat requests for the same clip", () => { - usePlayerStore.getState().requestClipReveal("el-1"); - const first = usePlayerStore.getState().clipRevealRequest; - usePlayerStore.getState().requestClipReveal("el-1"); - const second = usePlayerStore.getState().clipRevealRequest; - expect(second?.nonce).not.toBe(first?.nonce); - }); - - it("clears via clearClipRevealRequest and on reset", () => { - usePlayerStore.getState().requestClipReveal("el-1"); - usePlayerStore.getState().clearClipRevealRequest(); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); - - usePlayerStore.getState().requestClipReveal("el-2"); - usePlayerStore.getState().reset(); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); + store.beginTimelineSession("project-a"); + store.requestTimelineFocus("clip:el-1"); + const stale = usePlayerStore.getState().timelineFocus; + if (!stale) throw new Error("expected timeline focus request"); + store.requestTimelineFocus("clip:el-2"); + const replacement = usePlayerStore.getState().timelineFocus; + if (!replacement) throw new Error("expected replacement timeline focus request"); + store.clearTimelineFocus(stale.nonce); + expect(usePlayerStore.getState().timelineFocus).toBe(replacement); + store.beginTimelineSession("project-b"); + expect(usePlayerStore.getState().timelineFocus).toBeNull(); }); }); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 7686e7a87..349f9a445 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -10,6 +10,7 @@ import { } from "../../utils/studioUiPreferences"; import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom"; import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice"; +import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState"; export type { KeyframeCacheEntry } from "./keyframeSlice"; export { liveTime } from "./liveTime"; @@ -221,15 +222,10 @@ interface PlayerState extends KeyframeSlice { requestSeek: (time: number) => void; clearSeekRequest: () => void; - /** - * Request the timeline to scroll a clip into view (e.g. clicking an - * already-added asset card in the sidebar). Consumed and cleared by - * useTimelineRevealClip. The nonce makes repeat requests for the same - * clip observable so a second click re-reveals after the user scrolls away. - */ - clipRevealRequest: { elementId: string; nonce: number } | null; - requestClipReveal: (elementId: string) => void; - clearClipRevealRequest: () => void; + timelineFocus: TimelineFocusRequest | null; + timelineFocusNonce: number; + requestTimelineFocus: (id: string) => void; + clearTimelineFocus: (nonce: number) => void; lintFindingsByElement: Map; setLintFindingsByElement: (map: Map) => void; @@ -301,8 +297,8 @@ export function createTimelineResetState() { focusedEaseSegment: null, selectedElementIds: new Set(), requestedSeekTime: null, - clipRevealRequest: null, lintFindingsByElement: new Map(), + timelineFocus: null, keyframeCache: new Map(), gsapAnimations: new Map(), beatAnalysis: null, @@ -375,12 +371,23 @@ export const usePlayerStore = create((set, get) => ({ requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), - clipRevealRequest: null, - requestClipReveal: (elementId) => - set((s) => ({ - clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 }, - })), - clearClipRevealRequest: () => set({ clipRevealRequest: null }), + timelineFocus: null, + timelineFocusNonce: 0, + requestTimelineFocus: (id) => + set((s) => { + const nonce = s.timelineFocusNonce + 1; + return { + timelineFocusNonce: nonce, + timelineFocus: createTimelineFocusRequest( + id, + s.timelineProjectId, + s.timelineSessionEpoch, + nonce, + ), + }; + }), + clearTimelineFocus: (nonce) => + set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)), lintFindingsByElement: new Map(), setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }), diff --git a/packages/studio/src/player/store/timelineFocusState.ts b/packages/studio/src/player/store/timelineFocusState.ts new file mode 100644 index 000000000..739d5ad05 --- /dev/null +++ b/packages/studio/src/player/store/timelineFocusState.ts @@ -0,0 +1,15 @@ +export interface TimelineFocusRequest { + id: string; + projectId: string | null; + sessionEpoch: number; + nonce: number; +} + +export function createTimelineFocusRequest( + id: string, + projectId: string | null, + sessionEpoch: number, + nonce: number, +): TimelineFocusRequest { + return { id, projectId, sessionEpoch, nonce }; +}