Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 10 additions & 10 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -140,19 +140,12 @@ export const Timeline = memo(function Timeline({
const isDragging = useRef(false);
const shiftHeld = useTimelineShiftModifier();
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);

const [showPopover, setShowPopover] = useState(false);
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
const [clipContextMenu, setClipContextMenu] = useState<{
x: number;
y: number;
element: TimelineElement;
} | null>(null);

const [clipContextMenu, setClipContextMenu] = useState<ClipContextMenuState | null>(null);
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el;
}, []);

const lastScrollLeftRef = useRef(0);

const effectiveDuration = useMemo(
Expand Down Expand Up @@ -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;
Expand All @@ -570,6 +568,8 @@ export const Timeline = memo(function Timeline({
)}
</div>
<TimelineOverlays
elements={expandedElements}
elementsRef={expandedElementsRef}
theme={theme}
showShortcutHint={showShortcutHint}
showPopover={showPopover}
Expand Down
54 changes: 54 additions & 0 deletions packages/studio/src/player/components/TimelineOverlays.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { resolveTimelineContextElement } from "./TimelineOverlays";

const captured: TimelineElement = {
id: "child",
key: "parent::child",
tag: "div",
start: 1,
duration: 2,
track: 3,
};

describe("resolveTimelineContextElement", () => {
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();
});
});
121 changes: 109 additions & 12 deletions packages/studio/src/player/components/TimelineOverlays.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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. */
Expand All @@ -28,6 +31,8 @@ interface TrackGapContextMenuState {
}

interface TimelineOverlaysProps {
elements: readonly TimelineElement[];
elementsRef: MutableRefObject<readonly TimelineElement[]>;
theme: TimelineTheme;
showShortcutHint: boolean;
showPopover: boolean;
Expand All @@ -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,
Expand All @@ -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 && (
Expand All @@ -102,17 +179,32 @@ export function TimelineOverlays({
/>
)}

{kfContextMenu && (
{kfContextMenu && keyframeElement && (
<KeyframeDiamondContextMenu
state={kfContextMenu}
state={{ ...kfContextMenu, element: keyframeElement }}
onClose={() => 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) {
Expand All @@ -122,17 +214,22 @@ export function TimelineOverlays({
/>
)}

{clipContextMenu && (
{clipContextMenu && clipElement && (
<ClipContextMenu
x={clipContextMenu.x}
y={clipContextMenu.y}
element={clipContextMenu.element}
element={clipElement}
currentTime={currentTime}
onClose={() => 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);
}}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 (
<button
type="button"
onContextMenu={(event) => onContextMenuKeyframe(event, ELEMENT.id, TARGET)}
/>
);
}

const root = mountReactHarness(<Harness />);
const button = document.querySelector("button");
expect(button).not.toBeNull();
act(() => {
button?.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, clientX: 10, clientY: 20 }),
);
});

expect(setKfContextMenu).toHaveBeenCalledWith(
expect.objectContaining({ elementId: ELEMENT.id, sessionEpoch: 4, x: 14, y: 22 }),
);
act(() => root.unmount());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export function useTimelineKeyframeHandlers({
setKfContextMenu({
x: e.clientX + 4,
y: e.clientY + 2,
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
element: el,
elementId: elId,
percentage: target.percentage,
Expand Down
Loading
Loading