diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 1e8275511b..8d2251bb30 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -6,6 +6,8 @@ import type { TimelineElement } from "../store/playerStore"; export interface KeyframeDiamondContextMenuState { x: number; y: number; + /** Timeline project session that created this portaled target. */ + sessionEpoch?: number; element: TimelineElement; elementId: string; percentage: number; diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index e52d18a56a..57979ca8d3 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -12,7 +12,7 @@ import { TimelineEmptyState } from "./TimelineEmptyState"; import { TimelineCanvas } from "./TimelineCanvas"; import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; -import { TimelineOverlays } from "./TimelineOverlays"; +import { TimelineOverlays, type ClipContextMenuState } from "./TimelineOverlays"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; @@ -140,19 +140,12 @@ export const Timeline = memo(function Timeline({ const isDragging = useRef(false); const shiftHeld = useTimelineShiftModifier(); const [razorGuideX, setRazorGuideX] = useState(null); - const [showPopover, setShowPopover] = useState(false); const [kfContextMenu, setKfContextMenu] = useState(null); - const [clipContextMenu, setClipContextMenu] = useState<{ - x: number; - y: number; - element: TimelineElement; - } | null>(null); - + const [clipContextMenu, setClipContextMenu] = useState(null); const setContainerRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el; }, []); - const lastScrollLeftRef = useRef(0); const effectiveDuration = useMemo( @@ -550,7 +543,12 @@ export const Timeline = memo(function Timeline({ setSelectedElementId(el.key ?? el.id); onSelectElement?.(el); dismissGapMenu(); - setClipContextMenu({ x: e.clientX, y: e.clientY, element: el }); + setClipContextMenu({ + x: e.clientX, + y: e.clientY, + element: el, + sessionEpoch: usePlayerStore.getState().timelineSessionEpoch, + }); }} onContextMenuLane={(e, track, time) => { if (draggedClip?.started || resizingClip) return; @@ -570,6 +568,8 @@ export const Timeline = memo(function Timeline({ )} { + it("returns the current expanded model instead of the captured snapshot", () => { + const current = { ...captured, start: 4, track: 7 }; + + expect( + resolveTimelineContextElement({ + capturedElement: captured, + targetSessionEpoch: 2, + sessionEpoch: 2, + selectedElementId: "parent::child", + elements: [current], + }), + ).toBe(current); + }); + + it("resolves synthetic expanded children that are absent from raw store elements", () => { + expect( + resolveTimelineContextElement({ + capturedElement: captured, + targetSessionEpoch: 2, + sessionEpoch: 2, + selectedElementId: "parent::child", + elements: [captured], + }), + ).toBe(captured); + }); + + it("rejects stale sessions, changed selection, and removed elements", () => { + const input = { + capturedElement: captured, + targetSessionEpoch: 2, + sessionEpoch: 2, + selectedElementId: "parent::child", + elements: [captured], + }; + + expect(resolveTimelineContextElement({ ...input, sessionEpoch: 3 })).toBeNull(); + expect(resolveTimelineContextElement({ ...input, selectedElementId: "other" })).toBeNull(); + expect(resolveTimelineContextElement({ ...input, elements: [] })).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/TimelineOverlays.tsx b/packages/studio/src/player/components/TimelineOverlays.tsx index 76a309a4e4..347a1b7bcb 100644 --- a/packages/studio/src/player/components/TimelineOverlays.tsx +++ b/packages/studio/src/player/components/TimelineOverlays.tsx @@ -1,4 +1,6 @@ +import { useEffect, type MutableRefObject } from "react"; import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; import type { TimelineTheme } from "./timelineTheme"; import type { TimelineRangeSelection } from "./timelineEditing"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; @@ -11,10 +13,11 @@ import { ClipContextMenu } from "./ClipContextMenu"; import { TrackGapContextMenu } from "./TrackGapContextMenu"; import { TimelineShortcutHint } from "./TimelineShortcutHint"; -interface ClipContextMenuState { +export interface ClipContextMenuState { x: number; y: number; element: TimelineElement; + sessionEpoch: number; } /** Resolved model for the empty-lane-space (track gap) context menu. */ @@ -28,6 +31,8 @@ interface TrackGapContextMenuState { } interface TimelineOverlaysProps { + elements: readonly TimelineElement[]; + elementsRef: MutableRefObject; theme: TimelineTheme; showShortcutHint: boolean; showPopover: boolean; @@ -54,10 +59,49 @@ interface TimelineOverlaysProps { onHoverGapAction: (action: "close-gap" | "close-all" | null) => void; } +interface TimelineContextTargetInput { + capturedElement: TimelineElement; + targetSessionEpoch: number | undefined; + sessionEpoch: number; + selectedElementId: string | null; + elements: readonly TimelineElement[]; +} + +/** The captured project session and current selection jointly own a context target. */ +export function resolveTimelineContextElement({ + capturedElement, + targetSessionEpoch, + sessionEpoch, + selectedElementId, + elements, +}: TimelineContextTargetInput): TimelineElement | null { + const identity = capturedElement.key ?? capturedElement.id; + if (targetSessionEpoch !== sessionEpoch) return null; + if (selectedElementId !== identity) return null; + return elements.find((element) => (element.key ?? element.id) === identity) ?? null; +} + +function readTimelineContextElement( + capturedElement: TimelineElement, + targetSessionEpoch: number | undefined, + elements: readonly TimelineElement[], +): TimelineElement | null { + const state = usePlayerStore.getState(); + return resolveTimelineContextElement({ + capturedElement, + targetSessionEpoch, + sessionEpoch: state.timelineSessionEpoch, + selectedElementId: state.selectedElementId, + elements, + }); +} + // The timeline's floating overlays, rendered as siblings above the scroll area: // the shortcut hint, the range-edit popover, the keyframe-diamond context menu, // and the clip context menu. export function TimelineOverlays({ + elements, + elementsRef, theme, showShortcutHint, showPopover, @@ -83,6 +127,39 @@ export function TimelineOverlays({ onCloseAllTrackGaps, onHoverGapAction, }: TimelineOverlaysProps) { + const selectedElementId = usePlayerStore((state) => state.selectedElementId); + const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch); + const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch; + const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch; + const keyframeElement = kfContextMenu + ? resolveTimelineContextElement({ + capturedElement: kfContextMenu.element, + targetSessionEpoch: kfTargetSessionEpoch, + sessionEpoch, + selectedElementId, + elements, + }) + : null; + const clipElement = clipContextMenu + ? resolveTimelineContextElement({ + capturedElement: clipContextMenu.element, + targetSessionEpoch: clipTargetSessionEpoch, + sessionEpoch, + selectedElementId, + elements, + }) + : null; + const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) => + readTimelineContextElement(element, targetSessionEpoch, elementsRef.current); + + useEffect(() => { + if (kfContextMenu && !keyframeElement) setKfContextMenu(null); + }, [keyframeElement, kfContextMenu, setKfContextMenu]); + + useEffect(() => { + if (clipContextMenu && !clipElement) setClipContextMenu(null); + }, [clipContextMenu, clipElement, setClipContextMenu]); + return ( <> {showShortcutHint && !showPopover && !rangeSelection && ( @@ -102,17 +179,32 @@ export function TimelineOverlays({ /> )} - {kfContextMenu && ( + {kfContextMenu && keyframeElement && ( setKfContextMenu(null)} - onDelete={(...args) => onDeleteKeyframe?.(...args)} - onDeleteAll={(element) => onDeleteAllKeyframes?.(element)} - onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)} + onDelete={(...args) => { + if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return; + onDeleteKeyframe?.(...args); + }} + onDeleteAll={() => { + const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch); + if (element) onDeleteAllKeyframes?.(element); + }} + onChangeEase={(elId, pct, ease) => { + if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return; + onChangeKeyframeEase?.(elId, pct, ease); + }} onMoveToPlayhead={ - onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined + onMoveKeyframeToPlayhead + ? (_element, ...args) => { + const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch); + if (element) onMoveKeyframeToPlayhead(element, ...args); + } + : undefined } onCopyProperties={(elId, pct) => { + if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return; const kfData = keyframeCache.get(elId); const kf = kfData?.keyframes.find((k) => k.percentage === pct); if (kf) { @@ -122,17 +214,22 @@ export function TimelineOverlays({ /> )} - {clipContextMenu && ( + {clipContextMenu && clipElement && ( setClipContextMenu(null)} - onSplit={(el, time) => onSplitElement?.(el, time)} - onDelete={(el) => { + onSplit={(_element, time) => { + const element = readCurrentElement(clipElement, clipTargetSessionEpoch); + if (element) onSplitElement?.(element, time); + }} + onDelete={() => { + const element = readCurrentElement(clipElement, clipTargetSessionEpoch); + if (!element) return; pinZoomBeforeEdit(); - onDeleteElement?.(el); + onDeleteElement?.(element); }} /> )} diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx index f1060933a8..f2cdca96a5 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx @@ -45,7 +45,7 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = { afterEach(() => { document.body.innerHTML = ""; vi.restoreAllMocks(); - usePlayerStore.setState({ focusedEaseSegment: null }); + usePlayerStore.setState({ focusedEaseSegment: null, timelineSessionEpoch: 0 }); }); describe("useTimelineKeyframeHandlers", () => { @@ -144,4 +144,39 @@ describe("useTimelineKeyframeHandlers", () => { expect(onSeek).toHaveBeenCalledExactlyOnceWith(2); act(() => root.unmount()); }); + + it("scopes a keyframe context target to the opening timeline session", () => { + const setKfContextMenu = vi.fn(); + usePlayerStore.setState({ timelineSessionEpoch: 4 }); + + function Harness() { + const { onContextMenuKeyframe } = useTimelineKeyframeHandlers({ + expandedElements: [ELEMENT], + keyframeCache: new Map(), + setSelectedElementId: vi.fn(), + setKfContextMenu, + toggleSelectedKeyframe: vi.fn(), + }); + return ( +