From 4e0c02f4c0f712c1efd21353610a0ac8ff7a6096 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 18 Jul 2026 21:37:18 +0200 Subject: [PATCH] fix(studio): scope timeline context targets --- .../nle/useTimelineEditCallbacks.test.tsx | 88 ++++++++++++- .../nle/useTimelineEditCallbacks.ts | 7 +- .../src/hooks/useGsapKeyframeOps.test.tsx | 40 +++++- .../studio/src/hooks/useGsapKeyframeOps.ts | 13 +- .../KeyframeDiamondContextMenu.test.tsx | 53 ++++++++ .../components/KeyframeDiamondContextMenu.tsx | 6 +- .../studio/src/player/components/Timeline.tsx | 20 +-- .../components/TimelineOverlays.test.ts | 54 ++++++++ .../player/components/TimelineOverlays.tsx | 121 ++++++++++++++++-- .../player/components/timelineCallbacks.ts | 2 +- .../useTimelineKeyframeHandlers.test.tsx | 37 +++++- .../components/useTimelineKeyframeHandlers.ts | 3 +- .../components/useTrackGapMenu.test.tsx | 64 +++++++++ .../src/player/components/useTrackGapMenu.ts | 41 ++++-- 14 files changed, 495 insertions(+), 54 deletions(-) create mode 100644 packages/studio/src/player/components/KeyframeDiamondContextMenu.test.tsx create mode 100644 packages/studio/src/player/components/TimelineOverlays.test.ts create mode 100644 packages/studio/src/player/components/useTrackGapMenu.test.tsx diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx index 5248aa0717..b1b18ceca1 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx @@ -266,21 +266,101 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => { }); it("deletes all keyframes through the clicked non-selected element's identity", async () => { - const { circle, selection } = arrangeClickedCircle(); + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + sourceFile: "scenes/main.html", + }; + const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" }; + const scaleAnimation: GsapAnimation = { + ...otherKeyframedAnimation, + id: "circle-to-0-scale", + properties: {}, + propertyGroup: "scale", + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { scale: 1 } }, + { percentage: 100, properties: { scale: 2 } }, + ], + }, + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([ + ["scenes/main.html#circle", [otherKeyframedAnimation, scaleAnimation]], + ]), + }); + mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection); const view = renderCallbacks(); await act(async () => { - view.callbacks.onDeleteAllKeyframes?.(circle); + view.callbacks.onDeleteAllKeyframes?.(circle, scaleAnimation.id); await Promise.resolve(); }); expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith( - otherKeyframedAnimation.id, - selection, + scaleAnimation.id, + circleSelection, ); view.unmount(); }); + it("does not delete a different lane when an explicit animation identity is stale", async () => { + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + sourceFile: "scenes/main.html", + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([["scenes/main.html#circle", [otherKeyframedAnimation]]]), + }); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteAllKeyframes?.(circle, "missing-animation-id"); + await Promise.resolve(); + }); + + expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("does not delete a different keyframe when its explicit animation identity is stale", () => { + const view = renderCallbacks(); + + act(() => { + view.callbacks.onDeleteKeyframe?.("box", 100, "position", 100, "missing-animation-id"); + }); + + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("does not move a different keyframe when its explicit animation identity is stale", async () => { + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onMoveKeyframeToPlayhead?.( + element, + 100, + "position", + 100, + "missing-animation-id", + ); + await Promise.resolve(); + }); + + expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled(); + view.unmount(); + }); + it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => { const { circle, selection } = arrangeClickedCircle(); const view = renderCallbacks(); diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index 3787586746..23666aa057 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -193,12 +193,15 @@ export function useTimelineEditCallbacks({ onSplitElement: handleTimelineElementSplit, onRazorSplit: handleRazorSplit, onRazorSplitAll: handleRazorSplitAll, - onDeleteAllKeyframes: (element) => { + onDeleteAllKeyframes: (element, animationId) => { // Hold the element where it is (collapse keyframes to a static set) rather // than deleting the whole animation — deleting strands a stale GSAP base // that the next drag adds to, flinging the element off-screen. const elementKey = getTimelineElementIdentity(element); - const anim = resolveElementAnimations(elementKey).find((animation) => animation.keyframes); + const animations = resolveElementAnimations(elementKey); + const anim = animationId + ? animations.find((animation) => animation.id === animationId) + : animations.find((animation) => animation.keyframes); if (!anim) return; void buildDomSelectionForTimelineElement(element).then((selection) => { if (selection) handleGsapRemoveAllKeyframes(anim.id, selection); diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx b/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx index 6017353d08..09d403c678 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx +++ b/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx @@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { KeyframeCacheEntry } from "../player/store/playerStore"; +import { usePlayerStore } from "../player/store/playerStore"; import { useGsapKeyframeOps } from "./useGsapKeyframeOps"; type HookApi = ReturnType; @@ -30,6 +32,7 @@ function successfulCommitMutation() { function renderKeyframeOps(over: { commitMutation: (...args: unknown[]) => Promise; + commitMutationSafely?: (...args: unknown[]) => Promise; trackGsapSaveFailure: (...args: unknown[]) => void; }) { const captured: { api: HookApi | null } = { api: null }; @@ -41,7 +44,7 @@ function renderKeyframeOps(over: { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles commitMutation: over.commitMutation as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles - commitMutationSafely: (() => {}) as any, + commitMutationSafely: (over.commitMutationSafely ?? (async () => {})) as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles trackGsapSaveFailure: over.trackGsapSaveFailure as any, sdkSession: null, @@ -203,6 +206,41 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => { ); }); + it("lets the successful commit refresh own delete-all cache invalidation", async () => { + let finishCommit: (() => void) | undefined; + const commitMutationSafely = vi.fn( + () => + new Promise((resolve) => { + finishCommit = resolve; + }), + ); + const api = renderKeyframeOps({ + commitMutation: successfulCommitMutation(), + commitMutationSafely, + trackGsapSaveFailure: vi.fn(), + }); + const cached: KeyframeCacheEntry = { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }; + usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) }); + + const pending = api.removeAllKeyframes(selection, "box-to-0-position"); + expect(commitMutationSafely).toHaveBeenCalledWith( + selection, + { type: "remove-all-keyframes", animationId: "box-to-0-position" }, + { label: "Remove all keyframes", softReload: true }, + ); + expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached); + + finishCommit?.(); + await pending; + expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached); + }); + it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => { const commitMutation = successfulCommitMutation(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() }); diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index b2a9ee3ba9..c24d4fd488 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -15,11 +15,7 @@ import { } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; -import { - clearKeyframeCacheForElement, - readKeyframeSnapshot, - writeKeyframeCache, -} from "./gsapKeyframeCacheHelpers"; +import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; import type { CommitMutation, CommitMutationOptions, @@ -335,11 +331,6 @@ export function useGsapKeyframeOps({ const removeAllKeyframes = useCallback( async (selection: DomEditSelection, animationId: string) => { const targetPath = selection.sourceFile || activeCompPath || "index.html"; - // remove-all-keyframes collapses the tween to a static hold and the commit - // path doesn't return parsed animations, so the keyframe cache is never - // refreshed — clear it here so the timeline diamonds disappear immediately. - const elementId = selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null; - if (elementId) clearKeyframeCacheForElement(targetPath, elementId); if (sdkSession && sdkDeps) { const handled = await sdkGsapRemoveAllKeyframesPersist( targetPath, @@ -350,7 +341,7 @@ export function useGsapKeyframeOps({ ); if (cutoverCommittedOrThrow(handled)) return; } - commitMutationSafely( + await commitMutationSafely( selection, { type: "remove-all-keyframes", animationId }, { label: "Remove all keyframes", softReload: true }, diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.test.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.test.tsx new file mode 100644 index 0000000000..f1317d84af --- /dev/null +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("KeyframeDiamondContextMenu", () => { + it("deletes all keyframes from the animation that opened the menu", () => { + const element: TimelineElement = { + id: "box", + tag: "div", + start: 0, + duration: 2, + track: 0, + }; + const onDeleteAll = vi.fn(); + const root = createRoot(document.createElement("div")); + + act(() => { + root.render( + , + ); + }); + + const deleteAll = [...document.querySelectorAll("button")].find( + (button) => button.textContent === "Delete All Keyframes", + ); + act(() => deleteAll?.click()); + + expect(onDeleteAll).toHaveBeenCalledExactlyOnceWith(element, "box-scale"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 1e8275511b..fef7d1ab49 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; @@ -25,7 +27,7 @@ interface KeyframeDiamondContextMenuProps { tweenPercentage?: number, animationId?: string, ) => void; - onDeleteAll: (element: TimelineElement) => void; + onDeleteAll: (element: TimelineElement, animationId?: string) => void; onChangeEase?: (elementId: string, percentage: number, ease: string) => void; onCopyProperties?: (elementId: string, percentage: number) => void; /** Retime the keyframe to the current playhead, preserving its value + ease. */ @@ -103,7 +105,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe type="button" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" onClick={() => { - onDeleteAll(state.element); + onDeleteAll(state.element, state.animationId); onClose(); }} > diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 7b93b36bf1..e3aebcf01d 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"; @@ -148,19 +148,12 @@ export const Timeline = memo(function Timeline({ const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); const shiftHeld = useTimelineShiftModifier(); - 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( @@ -559,7 +552,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({ {activeTool === "razor" && razorGuideX !== null && } { + 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 449a968149..de8f4bf626 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={(elId) => onDeleteAllKeyframes?.(elId)} - onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)} + onDelete={(...args) => { + if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return; + onDeleteKeyframe?.(...args); + }} + onDeleteAll={(_element, animationId) => { + const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch); + if (element) onDeleteAllKeyframes?.(element, animationId); + }} + 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/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 1060a4cedf..5d490ac762 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -78,7 +78,7 @@ export interface TimelineEditCallbacks { tweenPercentage?: number, animationId?: string, ) => void; - onDeleteAllKeyframes?: (element: TimelineElement) => void; + onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; onMoveKeyframeToPlayhead?: ( element: TimelineElement, 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 ( +