diff --git a/packages/studio/src/components/sidebar/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx index e6eaeca96c..6ebf6fcb91 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 5df7d59801..7b0fff0a2c 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/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index e3aebcf01d..48907a06d4 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -41,9 +41,9 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; import { useTimelineTicks } from "./useTimelineTicks"; import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; -import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization"; import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; +import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus"; export { generateTicks, @@ -127,7 +127,6 @@ export const Timeline = memo(function Timeline({ const timelineReady = usePlayerStore((s) => s.timelineReady); const selectedElementId = usePlayerStore((s) => s.selectedElementId); const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); - const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest); const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); const hasKeyframedClips = useMemo( @@ -183,7 +182,6 @@ export const Timeline = memo(function Timeline({ const ppsRef = useRef(100); const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; - // Declared before the fitPps derivation so the edit-pin wrappers can close over it. const fitPpsRef = useRef(100); const { @@ -277,34 +275,6 @@ export const Timeline = memo(function Timeline({ expandedElements.length, displayLayout.totalH, ]); - const { enabled: rowVirtualizationActive, virtualRows } = useTimelineRowVirtualization({ - scrollRef, - viewport, - rowGeometry: displayLayout.rowGeometry, - sessionEpoch, - elements: expandedElements, - selectedElementId, - revealElementId: clipRevealRequest?.elementId ?? null, - draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined, - resizingRowKey: resizingClip?.element.track, - clipContextMenuRowKey: clipContextMenu?.element.track, - keyframeContextMenuRowKey: kfContextMenu?.element.track, - lastScrollLeftRef, - syncScrollViewport, - }); - const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); - const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); - const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = - useTimelineKeyframeHandlers({ - expandedElements, - keyframeCache, - onSelectElement, - onSeek, - setSelectedElementId, - setKfContextMenu, - toggleSelectedKeyframe, - }); - const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } = useTimelineGeometry({ viewportWidth: viewport.clientWidth, @@ -321,6 +291,41 @@ export const Timeline = memo(function Timeline({ lastScrollLeftRef, contentOrigin, }); + const { logicalRows, pinnedElementId, rowVirtualizationActive, virtualRows } = + useTimelineLogicalFocus({ + scrollRef, + tracks, + layout: displayLayout, + laneCounts, + selectedElementId, + selectedElementIds, + gsapAnimations, + elements: expandedElements, + pixelsPerSecond: pps, + contentOrigin, + allowHorizontal: zoomMode === "manual", + viewport, + sessionEpoch, + draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined, + resizingRowKey: resizingClip?.element.track, + clipContextMenuRowKey: clipContextMenu?.element.track, + keyframeContextMenuRowKey: kfContextMenu?.element.track, + lastScrollLeftRef, + syncScrollViewport, + }); + const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); + const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = + useTimelineKeyframeHandlers({ + expandedElements, + keyframeCache, + onSelectElement, + onSeek, + setSelectedElementId, + setKfContextMenu, + toggleSelectedKeyframe, + }); + const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({ tracks, viewport, @@ -332,15 +337,10 @@ export const Timeline = memo(function Timeline({ resizingElementIds: resizingClip?.groupPreview?.map((change) => change.key) ?? (resizingClip ? [resizingClip.element.key ?? resizingClip.element.id] : undefined), - revealElementId: clipRevealRequest?.elementId, + focusedElementId: pinnedElementId, focusedEaseElementId: focusedEaseSegment?.elementId, clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id, keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id, - scrollRef, - elements: expandedElements, - rowGeometry: displayLayout.rowGeometry, - allowHorizontalReveal: zoomMode === "manual", - sessionEpoch, }); useTimelineActiveClips({ scrollRef, @@ -476,8 +476,7 @@ export const Timeline = memo(function Timeline({ onDragLeave={assetDrop.handleAssetDragLeave} onDrop={assetDrop.handleAssetDrop} onPointerDown={(e) => { - // Let interactive controls (keyframe nav/toggle, caret, inputs) handle - // their own clicks — scrubbing here would preventDefault and eat them. + // Interactive controls own their clicks; scrubbing would preventDefault and eat them. if (e.target instanceof Element && e.target.closest("button, input, select, a")) return; if (splitAllAtPointer(e)) return; handlePointerDown(e); @@ -505,6 +504,7 @@ export const Timeline = memo(function Timeline({ rowHeights={displayLayout.displayRowHeights} rowGeometry={displayLayout.rowGeometry} virtualRows={virtualRows} + logicalRows={logicalRows} rowsVirtualized={rowVirtualizationActive} clipIndex={clipIndex} renderTimeRange={renderTimeRange} diff --git a/packages/studio/src/player/components/Timeline.virtualization.test.tsx b/packages/studio/src/player/components/Timeline.virtualization.test.tsx index 0d6a7fcadc..e52aaf0118 100644 --- a/packages/studio/src/player/components/Timeline.virtualization.test.tsx +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -166,6 +166,8 @@ describe("Timeline row virtualization", () => { usePlayerStore.setState({ duration: 1_000, timelineReady: true, + timelineProjectId: "project-a", + timelineSessionEpoch: 4, zoomMode: "manual", manualZoomPercent: 2_000, selectedElementId: "clip-490", @@ -215,12 +217,15 @@ describe("Timeline row virtualization", () => { expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull(); expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100); - await act(async () => usePlayerStore.getState().requestClipReveal("clip-300")); + const { timelineClipFocusId } = await import("./timelineNavigationIdentity"); + await act(async () => + usePlayerStore.getState().requestTimelineFocus(timelineClipFocusId("clip-300")), + ); await act(async () => { await new Promise((resolve) => requestAnimationFrame(() => resolve())); }); await act(async () => {}); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); + expect(usePlayerStore.getState().timelineFocus?.id).toBe(timelineClipFocusId("clip-300")); expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300"); expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull(); diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 40410b565f..5ad9cedbe4 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore"; import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme"; import type { TimelineEditCapabilities } from "./timelineEditing"; import { isAudioTimelineElement } from "../../utils/timelineInspector"; +import { timelineClipFocusId } from "./timelineNavigationIdentity"; interface TimelineClipProps { el: TimelineElement; @@ -89,6 +90,7 @@ export const TimelineClip = memo(function TimelineClip({
s.expandedClipIds); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); - const { logicalRows, logicalRowsByTrack } = useMemo(() => { - const rows = buildTimelineLogicalRows({ - tracks, - displayTrackOrder, - laneCounts, - selectedElementId, - selectedElementIds, - expandedClipIds: STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : new Set(), - gsapAnimations, - }); + const logicalRowsByTrack = useMemo(() => { const byTrack = new Map(); - for (const logicalRow of rows) { + for (const logicalRow of logicalRows) { const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? []; trackRows.push(logicalRow); byTrack.set(logicalRow.physicalTrackKey, trackRows); } - return { logicalRows: rows, logicalRowsByTrack: byTrack }; - }, [ - displayTrackOrder, - expandedClipIds, - gsapAnimations, - laneCounts, - selectedElementId, - selectedElementIds, - tracks, - ]); + return byTrack; + }, [logicalRows]); const toggleClipExpandedTracked = (key: string) => { const willExpand = !expandedClipIds.has(key); trackStudioKeyframeLaneExpand({ expanded: willExpand }); diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.tsx index 9ab04786ea..f6037f5c52 100644 --- a/packages/studio/src/player/components/TimelinePropertyLanes.tsx +++ b/packages/studio/src/player/components/TimelinePropertyLanes.tsx @@ -128,6 +128,7 @@ export function TimelinePropertyLanes({ key={group} id={timelineLogicalRowCellId(timelinePropertyRowId(elementId, group), "content")} data-property-group={group} + data-timeline-element-id={elementId} data-timeline-property-lane="" data-timeline-lane-top={getTimelineLaneTop(laneIndex)} className="absolute" diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index e6f44326b4..a88e1a5e71 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -397,6 +397,7 @@ function PropertyGroupHeaderRow({ onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onSeek?: (time: number) => void; }) { + const elementId = expandedElement.key ?? expandedElement.id; const { navigation, values, label, toggleTarget } = resolveLaneHeaderState( lane, currentTime, @@ -408,10 +409,10 @@ function PropertyGroupHeaderRow({ return (
diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index c86e98ee96..b2d88354be 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -56,6 +56,7 @@ export interface TimelineLogicalRow { logicalIndex: number; level: 1 | 2; parentId: string | null; + elementId: string | null; expandable: boolean; expanded: boolean; propertyGroup?: PropertyGroupName; @@ -201,6 +202,7 @@ export function buildTimelineLogicalRows({ logicalIndex: rows.length, level: 1, parentId: null, + elementId: null, expandable: lanes.length > 0, expanded, items: clipItems(trackId, elements), @@ -215,6 +217,7 @@ export function buildTimelineLogicalRows({ logicalIndex: rows.length, level: 2, parentId: trackId, + elementId: activeId, expandable: false, expanded: false, propertyGroup: lane.group, @@ -225,7 +228,7 @@ export function buildTimelineLogicalRows({ return rows; } -function locateTarget(rows: readonly TimelineLogicalRow[], id: string) { +export function locateTimelineLogicalTarget(rows: readonly TimelineLogicalRow[], id: string) { for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { const row = rows[rowIndex]!; if (row.id === id) return { row, rowIndex, itemIndex: -1, target: row }; @@ -252,7 +255,7 @@ export function resolveTimelineNavigationTarget( key: TimelineNavigationKey, options: TimelineNavigationOptions = {}, ): TimelineLogicalTarget | null { - const current = locateTarget(rows, currentId); + const current = locateTimelineLogicalTarget(rows, currentId); if (!current) return null; const { row, rowIndex, itemIndex, target } = current; @@ -289,26 +292,26 @@ export function resolveTimelineFocusFallback( nextRows: readonly TimelineLogicalRow[], currentId: string, ): TimelineLogicalTarget | null { - const unchanged = locateTarget(nextRows, currentId); + const unchanged = locateTimelineLogicalTarget(nextRows, currentId); if (unchanged) return unchanged.target; - const previous = locateTarget(previousRows, currentId); + const previous = locateTimelineLogicalTarget(previousRows, currentId); if (!previous) return null; if (previous.itemIndex >= 0) { for (let index = previous.itemIndex - 1; index >= 0; index -= 1) { - const candidate = locateTarget(nextRows, previous.row.items[index]!.id); + const candidate = locateTimelineLogicalTarget(nextRows, previous.row.items[index]!.id); if (candidate) return candidate.target; } for (let index = previous.itemIndex + 1; index < previous.row.items.length; index += 1) { - const candidate = locateTarget(nextRows, previous.row.items[index]!.id); + const candidate = locateTimelineLogicalTarget(nextRows, previous.row.items[index]!.id); if (candidate) return candidate.target; } } - const survivingRow = locateTarget(nextRows, previous.row.id); + const survivingRow = locateTimelineLogicalTarget(nextRows, previous.row.id); if (survivingRow) return survivingRow.target; if (previous.row.parentId) { - const parent = locateTarget(nextRows, previous.row.parentId); + const parent = locateTimelineLogicalTarget(nextRows, previous.row.parentId); if (parent) return parent.target; } return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null; diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts index 808d02436e..b173dc2217 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts @@ -1,10 +1,7 @@ -import { useMemo, type RefObject } from "react"; +import { useMemo } from "react"; import { createTimelineClipIndex } from "../lib/timelineClipIndex"; -import type { TimelineElement } from "../store/playerStore"; import { getTimelineRenderTimeRange } from "./timelineViewportGeometry"; -import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; -import { useTimelineRevealClip } from "./useTimelineRevealClip"; interface UseTimelineClipRenderWindowInput { tracks: Parameters[0]; @@ -15,15 +12,10 @@ interface UseTimelineClipRenderWindowInput { selectedElementId?: string; draggedElementId?: string; resizingElementIds?: readonly string[]; - revealElementId?: string; + focusedElementId?: string; focusedEaseElementId?: string; clipContextMenuElementId?: string; keyframeContextMenuElementId?: string; - scrollRef: RefObject; - elements: readonly TimelineElement[]; - rowGeometry: TimelineRowGeometry; - allowHorizontalReveal: boolean; - sessionEpoch: number; } export function useTimelineClipRenderWindow({ @@ -35,15 +27,10 @@ export function useTimelineClipRenderWindow({ selectedElementId, draggedElementId, resizingElementIds, - revealElementId, + focusedElementId, focusedEaseElementId, clipContextMenuElementId, keyframeContextMenuElementId, - scrollRef, - elements, - rowGeometry, - allowHorizontalReveal, - sessionEpoch, }: UseTimelineClipRenderWindowInput) { const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]); const renderTimeRange = useMemo( @@ -57,7 +44,7 @@ export function useTimelineClipRenderWindow({ selectedElementId, draggedElementId, ...(resizingElementIds ?? []), - revealElementId, + focusedElementId, focusedEaseElementId, clipContextMenuElementId, keyframeContextMenuElementId, @@ -69,19 +56,9 @@ export function useTimelineClipRenderWindow({ focusedEaseElementId, keyframeContextMenuElementId, resizingElementIds, - revealElementId, + focusedElementId, selectedElementId, ], ); - useTimelineRevealClip({ - scrollRef, - elements, - rowGeometry, - pixelsPerSecond, - contentOrigin, - allowHorizontal: allowHorizontalReveal, - viewportVersion: viewport, - sessionEpoch, - }); return { clipIndex, renderTimeRange, pinnedClipIdentities }; } diff --git a/packages/studio/src/player/components/useTimelineFocusCoordinator.test.tsx b/packages/studio/src/player/components/useTimelineFocusCoordinator.test.tsx new file mode 100644 index 0000000000..b4951207fb --- /dev/null +++ b/packages/studio/src/player/components/useTimelineFocusCoordinator.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom + +import React, { act, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { usePlayerStore } from "../store/playerStore"; +import { createTimelineRowGeometry } from "./timelineLayout"; +import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; +import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity"; +import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const element = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 }; +const clipId = timelineClipFocusId("hero"); +const rowId = timelineTrackRowId(1); +const geometry = createTimelineRowGeometry([1], [48]); +const rows: readonly TimelineLogicalRow[] = [ + { + id: rowId, + kind: "row", + physicalTrackKey: 1, + logicalIndex: 0, + level: 1, + parentId: null, + elementId: null, + expandable: false, + expanded: false, + items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }], + }, +]; + +function Harness({ + mountedId, + logicalRows = rows, + projectId = "project-a", +}: { + mountedId?: string; + logicalRows?: readonly TimelineLogicalRow[]; + projectId?: string; +}) { + const scrollRef = useRef(null); + const focus = useTimelineFocusCoordinator({ + scrollRef, + logicalRows, + elements: [element], + rowGeometry: geometry, + pixelsPerSecond: 100, + contentOrigin: 32, + allowHorizontal: true, + viewportVersion: logicalRows, + projectId, + sessionEpoch: 1, + syncScrollViewport: () => {}, + }); + return ( +
{ + scrollRef.current = node; + if (node) { + Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 }); + Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 }); + } + }} + data-focus={`${focus.focusedRowKey}:${focus.pinnedElementId}`} + > + {mountedId &&
} +
+ ); +} + +let host: HTMLDivElement; +let root: Root; +beforeEach(() => { + usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(() => { + act(() => root.unmount()); + usePlayerStore.getState().reset(); + document.body.replaceChildren(); +}); + +describe("useTimelineFocusCoordinator", () => { + it("pins and scrolls from model coordinates until mount, then permits repeat reveal", async () => { + usePlayerStore.getState().requestTimelineFocus(clipId); + await act(async () => root.render()); + const scroll = host.firstElementChild as HTMLDivElement; + expect(scroll.scrollLeft).toBe(1_944); + expect(scroll.dataset.focus).toBe("1:hero"); + expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId); + + await act(async () => root.render()); + const firstNonce = usePlayerStore.getState().timelineFocus?.nonce; + expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId); + expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId); + + scroll.scrollLeft = 0; + await act(async () => usePlayerStore.getState().requestTimelineFocus(clipId)); + await act(async () => root.render()); + expect(scroll.scrollLeft).toBe(1_944); + expect(usePlayerStore.getState().timelineFocus?.nonce).toBe((firstNonce ?? 0) + 1); + }); + + it("focuses the latest request when it replaces an unmounted request", async () => { + usePlayerStore.getState().requestTimelineFocus(rowId); + usePlayerStore.getState().requestTimelineFocus(clipId); + + await act(async () => root.render()); + expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId); + }); + + it("ignores stale scope and never queries outside its own viewport", async () => { + usePlayerStore.getState().requestTimelineFocus(clipId); + await act(async () => root.render()); + expect(document.activeElement).not.toBe(host.querySelector("[data-timeline-focus-id]")); + expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId); + + const externalTarget = document.createElement("div"); + externalTarget.dataset.timelineFocusId = clipId; + externalTarget.tabIndex = -1; + document.body.append(externalTarget); + await act(async () => root.render()); + expect(document.activeElement).not.toBe(externalTarget); + }); + + it("persists a deterministic parent-row fallback when a focused clip disappears", async () => { + usePlayerStore.getState().requestTimelineFocus(clipId); + await act(async () => root.render()); + + const collapsedRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }]; + await act(async () => root.render()); + expect(usePlayerStore.getState().timelineFocus?.id).toBe(rowId); + expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(rowId); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineFocusCoordinator.ts b/packages/studio/src/player/components/useTimelineFocusCoordinator.ts new file mode 100644 index 0000000000..b8141e871e --- /dev/null +++ b/packages/studio/src/player/components/useTimelineFocusCoordinator.ts @@ -0,0 +1,211 @@ +import { useEffect, useLayoutEffect, useRef, type RefObject } from "react"; +import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineFocusRequest } from "../store/timelineFocusState"; +import type { TimelineRowGeometry } from "./timelineLayout"; +import { CLIP_Y, RULER_H } from "./timelineLayout"; +import { + locateTimelineLogicalTarget, + resolveTimelineFocusFallback, + type TimelineLogicalRow, + type TimelineLogicalTarget, +} from "./timelineKeyboardNavigation"; +import { computeRevealScroll } from "./timelineRevealScroll"; + +interface TimelineFocusCoordinatorInput { + scrollRef: RefObject; + logicalRows: readonly TimelineLogicalRow[]; + elements: readonly TimelineElement[]; + rowGeometry: TimelineRowGeometry; + pixelsPerSecond: number; + contentOrigin: number; + allowHorizontal: boolean; + viewportVersion: unknown; + projectId: string | null; + sessionEpoch: number; + syncScrollViewport: (element: HTMLDivElement) => void; +} + +export interface TimelineFocusCoordinatorState { + focusedTargetId: string | null; + focusedRowKey: number | undefined; + pinnedElementId: string | undefined; +} + +interface ResolvedFocus { + target: TimelineLogicalTarget; + row: TimelineLogicalRow; +} + +function isCurrentRequest( + request: TimelineFocusRequest | null, + projectId: string | null, + sessionEpoch: number, +): request is TimelineFocusRequest { + return ( + request !== null && request.projectId === projectId && request.sessionEpoch === sessionEpoch + ); +} + +function focusElement(container: HTMLDivElement, targetId: string): boolean { + const target = [...container.querySelectorAll("[data-timeline-focus-id]")].find( + (candidate) => candidate.dataset.timelineFocusId === targetId, + ); + if (!target) return false; + if (target.ownerDocument.activeElement === target) return true; + target.setAttribute("data-reveal-highlight", "true"); + target.focus({ preventScroll: true }); + if (target.ownerDocument.activeElement !== target) { + target.removeAttribute("data-reveal-highlight"); + return false; + } + target.addEventListener("blur", () => target.removeAttribute("data-reveal-highlight"), { + once: true, + }); + return true; +} + +function scrollToTarget( + container: HTMLDivElement, + resolution: ResolvedFocus, + elements: readonly TimelineElement[], + rowGeometry: TimelineRowGeometry, + pixelsPerSecond: number, + contentOrigin: number, + allowHorizontal: boolean, +): boolean { + const rowIndex = rowGeometry.getRowIndex(resolution.row.physicalTrackKey); + if (rowIndex < 0) return false; + const elementId = + resolution.target.kind === "row" ? resolution.row.elementId : resolution.target.elementId; + const element = elementId + ? elements.find((candidate) => (candidate.key ?? candidate.id) === elementId) + : undefined; + const pointTime = resolution.target.kind === "row" ? null : resolution.target.time; + const left = element && resolution.target.kind === "clip" ? element.start : pointTime; + const right = + element && resolution.target.kind === "clip" ? element.start + element.duration : pointTime; + const rowTop = rowGeometry.getRowTop(rowIndex); + const target = computeRevealScroll({ + scrollLeft: container.scrollLeft, + scrollTop: container.scrollTop, + viewportWidth: container.clientWidth, + viewportHeight: container.clientHeight, + clipLeft: contentOrigin + (left ?? 0) * pixelsPerSecond, + clipRight: contentOrigin + (right ?? 0) * pixelsPerSecond, + clipTop: rowTop + CLIP_Y, + clipBottom: rowTop + rowGeometry.getRowHeight(rowIndex) - CLIP_Y, + stickyLeft: contentOrigin, + stickyTop: RULER_H, + allowHorizontal: allowHorizontal && left !== null, + }); + if (target.left !== null) container.scrollLeft = target.left; + if (target.top !== null) container.scrollTop = target.top; + return target.left !== null || target.top !== null; +} + +/** Model-first focus actor; mounting is a consequence of its returned pins. */ +export function useTimelineFocusCoordinator({ + scrollRef, + logicalRows, + elements, + rowGeometry, + pixelsPerSecond, + contentOrigin, + allowHorizontal, + viewportVersion, + projectId, + sessionEpoch, + syncScrollViewport, +}: TimelineFocusCoordinatorInput): TimelineFocusCoordinatorState { + const request = usePlayerStore((state) => state.timelineFocus); + const previousRowsRef = useRef(logicalRows); + const resolvedRef = useRef<{ nonce: number; id: string } | null>(null); + const appliedRef = useRef<{ nonce: number; id: string } | null>(null); + let resolution: ResolvedFocus | null = null; + + if (isCurrentRequest(request, projectId, sessionEpoch)) { + if (resolvedRef.current?.nonce !== request.nonce) { + resolvedRef.current = { nonce: request.nonce, id: request.id }; + } + const resolvedId = resolvedRef.current.id; + let located = locateTimelineLogicalTarget(logicalRows, resolvedId); + if (!located) { + const fallback = resolveTimelineFocusFallback( + previousRowsRef.current, + logicalRows, + resolvedId, + ); + if (fallback) { + resolvedRef.current = { nonce: request.nonce, id: fallback.id }; + located = locateTimelineLogicalTarget(logicalRows, fallback.id); + } + } + if (located) resolution = { target: located.target, row: located.row }; + } else { + resolvedRef.current = null; + } + + useLayoutEffect(() => { + previousRowsRef.current = logicalRows; + }, [logicalRows]); + + useEffect(() => { + if (!isCurrentRequest(request, projectId, sessionEpoch)) return; + if (!resolution) { + usePlayerStore.getState().clearTimelineFocus(request.nonce); + return; + } + if (resolution.target.id !== request.id) { + usePlayerStore.getState().requestTimelineFocus(resolution.target.id); + return; + } + if ( + appliedRef.current?.nonce === request.nonce && + appliedRef.current.id === resolution.target.id + ) { + return; + } + const container = scrollRef.current; + if (!container) return; + if ( + scrollToTarget( + container, + resolution, + elements, + rowGeometry, + pixelsPerSecond, + contentOrigin, + allowHorizontal, + ) + ) { + syncScrollViewport(container); + } + if (!focusElement(container, resolution.target.id)) return; + appliedRef.current = { nonce: request.nonce, id: resolution.target.id }; + }, [ + allowHorizontal, + contentOrigin, + elements, + pixelsPerSecond, + projectId, + request, + resolution, + rowGeometry, + scrollRef, + sessionEpoch, + syncScrollViewport, + viewportVersion, + ]); + + const pinnedElementId = resolution + ? resolution.target.kind === "row" + ? (resolution.row.elementId ?? undefined) + : resolution.target.elementId + : undefined; + return { + focusedTargetId: resolution?.target.id ?? null, + focusedRowKey: resolution?.row.physicalTrackKey, + pinnedElementId, + }; +} diff --git a/packages/studio/src/player/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts new file mode 100644 index 0000000000..0bdc489ecb --- /dev/null +++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts @@ -0,0 +1,92 @@ +import { useMemo, type RefObject } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { TimelineElement } from "../store/playerStore"; +import type { TimelineRowGeometry } from "./timelineLayout"; +import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation"; +import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; +import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator"; +import { usePlayerStore } from "../store/playerStore"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; +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; + resizingRowKey?: number; + 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 = useMemo( + () => + buildTimelineLogicalRows({ + tracks: input.tracks, + displayTrackOrder: input.layout.displayTrackOrder, + laneCounts: input.laneCounts, + selectedElementId: input.selectedElementId, + selectedElementIds: input.selectedElementIds, + expandedClipIds: STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : new Set(), + gsapAnimations: input.gsapAnimations, + }), + [ + expandedClipIds, + input.gsapAnimations, + input.laneCounts, + input.selectedElementId, + input.selectedElementIds, + input.tracks, + input.layout.displayTrackOrder, + ], + ); + 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, + resizingRowKey: input.resizingRowKey, + clipContextMenuRowKey: input.clipContextMenuRowKey, + keyframeContextMenuRowKey: input.keyframeContextMenuRowKey, + lastScrollLeftRef: input.lastScrollLeftRef, + syncScrollViewport: input.syncScrollViewport, + }); + return { + logicalRows, + ...focus, + rowVirtualizationActive: rows.enabled, + virtualRows: rows.virtualRows, + }; +} 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 43b812d3be..0000000000 --- a/packages/studio/src/player/components/useTimelineRevealClip.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act, useRef } 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 { 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 Harness({ mounted, version }: { mounted: boolean; version: number }) { - const scrollRef = useRef(null); - useTimelineRevealClip({ - scrollRef, - elements: [element], - rowGeometry: geometry, - pixelsPerSecond: 100, - contentOrigin: 32, - allowHorizontal: true, - viewportVersion: version, - sessionEpoch: 1, - }); - return ( -
{ - scrollRef.current = node; - if (node) { - Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 }); - Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 }); - } - }} - > - {mounted &&
} -
- ); -} - -afterEach(() => { - usePlayerStore.getState().reset(); - document.body.replaceChildren(); -}); - -describe("useTimelineRevealClip", () => { - it("scrolls from model coordinates, then consumes only after the clip mounts", async () => { - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - usePlayerStore.getState().requestClipReveal("hero"); - - await act(async () => root.render()); - const scroll = host.firstElementChild as HTMLDivElement; - expect(scroll.scrollLeft).toBe(1_944); - expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero"); - - await act(async () => root.render()); - 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 act(async () => root.render()); - 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 host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - usePlayerStore.getState().requestClipReveal("missing"); - await act(async () => root.render()); - expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); - 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 b507c15047..0000000000 --- a/packages/studio/src/player/components/useTimelineRevealClip.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { TimelineElement } from "../store/playerStore"; -import { usePlayerStore } from "../store/playerStore"; -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; - 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, -): void { - 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; - if (target.left !== null || target.top !== null) container.dispatchEvent(new Event("scroll")); -} - -function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean { - const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`); - if (!(clip instanceof HTMLElement)) return false; - clip.setAttribute("data-reveal-highlight", "true"); - clip.focus({ preventScroll: true }); - if (document.activeElement !== clip) { - clip.removeAttribute("data-reveal-highlight"); - return false; - } - clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), { - once: true, - }); - return true; -} - -function resolveRevealTarget( - elements: readonly TimelineElement[], - rowGeometry: TimelineRowGeometry, - elementId: string, -): { element: TimelineElement; row: number } | null { - const element = elements.find((candidate) => (candidate.key ?? candidate.id) === 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, - 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 (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) { - scrolledRequestRef.current = { request: revealRequest, sessionEpoch }; - scrollToTimelineElement( - container, - target.element, - target.row, - rowGeometry, - pixelsPerSecond, - contentOrigin, - allowHorizontal, - ); - } - - if (!focusRevealedElement(container, revealRequest.elementId)) return; - if (usePlayerStore.getState().clipRevealRequest === revealRequest) { - usePlayerStore.getState().clearClipRevealRequest(); - } - }, [ - allowHorizontal, - contentOrigin, - elements, - 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 5bebda0861..cae1cf0f64 100644 --- a/packages/studio/src/player/components/useTimelineRowVirtualization.ts +++ b/packages/studio/src/player/components/useTimelineRowVirtualization.ts @@ -14,9 +14,9 @@ interface UseTimelineRowVirtualizationInput { viewport: TimelineScrollViewportSnapshot; rowGeometry: TimelineRowGeometry; sessionEpoch: number; - elements: TimelineElement[]; + elements: readonly TimelineElement[]; selectedElementId: string | null; - revealElementId: string | null; + focusedRowKey?: number; draggedRowKey?: number; resizingRowKey?: number; clipContextMenuRowKey?: number; @@ -40,7 +40,7 @@ export function useTimelineRowVirtualization({ sessionEpoch, elements, selectedElementId, - revealElementId, + focusedRowKey, draggedRowKey, resizingRowKey, clipContextMenuRowKey, @@ -69,29 +69,27 @@ export function useTimelineRowVirtualization({ scroll.removeEventListener("focusout", handleFocusOut); }; }, [scrollRef, sessionEpoch]); - const focusIdentity = useMemo( + const selectedIdentity = useMemo( () => resolveTimelineFocusIdentity(elements, selectedElementId), [elements, selectedElementId], ); - const revealIdentity = useMemo( - () => resolveTimelineFocusIdentity(elements, revealElementId), - [elements, revealElementId], - ); const pinnedRowKeys = useMemo( () => [ draggedRowKey, resizingRowKey, - revealIdentity?.rowKey, + selectedIdentity?.rowKey, + focusedRowKey, clipContextMenuRowKey, keyframeContextMenuRowKey, ].filter((rowKey): rowKey is number => rowKey !== undefined), [ clipContextMenuRowKey, draggedRowKey, + focusedRowKey, keyframeContextMenuRowKey, resizingRowKey, - revealIdentity, + selectedIdentity, ], ); const virtualRows = useTimelineVirtualRows({ @@ -101,7 +99,7 @@ export function useTimelineRowVirtualization({ rowGeometry, sessionEpoch, pinnedRowKeys, - focusedRowKey: domFocusedRowKey ?? focusIdentity?.rowKey, + focusedRowKey: domFocusedRowKey ?? focusedRowKey, }); const previousLayoutRef = useRef(rowGeometry); diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 8adfd77eeb..bc5d588cac 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -490,29 +490,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"); - }); - - 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(); + 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); - 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 510af7792d..a3207fd258 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -5,6 +5,7 @@ import type { ClipManifestClip } from "../lib/playbackTypes"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { computePinnedZoomPercent } from "../components/timelineZoom"; import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice"; +import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState"; export type { KeyframeCacheEntry } from "./keyframeSlice"; export { liveTime } from "./liveTime"; @@ -219,15 +220,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; @@ -294,7 +290,7 @@ function createTimelineResetState() { expandedClipIds: new Set(), selectedElementIds: new Set(), focusedEaseSegment: null, - clipRevealRequest: null, + timelineFocus: null, keyframeCache: new Map(), gsapAnimations: new Map(), beatAnalysis: null, @@ -364,12 +360,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 0000000000..739d5ad052 --- /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 }; +}