mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
fix(studio): scope timeline context targets (#2708)
This commit is contained in:
@@ -317,17 +317,44 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deletes all keyframes through the clicked non-selected element's identity", async () => {
|
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();
|
const view = renderCallbacks();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
view.callbacks.onDeleteAllKeyframes?.(circle, scaleAnimation.id);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
|
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
|
||||||
otherKeyframedAnimation.id,
|
scaleAnimation.id,
|
||||||
selection,
|
circleSelection,
|
||||||
);
|
);
|
||||||
view.unmount();
|
view.unmount();
|
||||||
});
|
});
|
||||||
@@ -360,6 +387,19 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|||||||
view.unmount();
|
view.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not delete a different lane when an explicit animation identity is stale", async () => {
|
||||||
|
const { circle } = arrangeClickedCircle();
|
||||||
|
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("aborts every mutation when the clicked element resolves no selection", async () => {
|
it("aborts every mutation when the clicked element resolves no selection", async () => {
|
||||||
const { circle } = arrangeClickedCircle();
|
const { circle } = arrangeClickedCircle();
|
||||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
|
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
|
||||||
@@ -391,6 +431,40 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|||||||
view.unmount();
|
view.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not delete a different keyframe when its explicit animation identity is stale", () => {
|
||||||
|
const view = renderCallbacks();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
view.callbacks.onDeleteKeyframe?.("box", {
|
||||||
|
percentage: 100,
|
||||||
|
propertyGroup: "position",
|
||||||
|
tweenPercentage: 100,
|
||||||
|
animationId: "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, {
|
||||||
|
percentage: 100,
|
||||||
|
propertyGroup: "position",
|
||||||
|
tweenPercentage: 100,
|
||||||
|
animationId: "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 () => {
|
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
|
||||||
const { circle, selection } = arrangeClickedCircle();
|
const { circle, selection } = arrangeClickedCircle();
|
||||||
const view = renderCallbacks();
|
const view = renderCallbacks();
|
||||||
|
|||||||
@@ -189,17 +189,22 @@ export function useTimelineEditCallbacks({
|
|||||||
onSplitElement: handleTimelineElementSplit,
|
onSplitElement: handleTimelineElementSplit,
|
||||||
onRazorSplit: handleRazorSplit,
|
onRazorSplit: handleRazorSplit,
|
||||||
onRazorSplitAll: handleRazorSplitAll,
|
onRazorSplitAll: handleRazorSplitAll,
|
||||||
onDeleteAllKeyframes: (element) => {
|
onDeleteAllKeyframes: (element, animationId) => {
|
||||||
// Hold the element where it is (collapse keyframes to a static set) rather
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
||||||
// than deleting the whole animation — deleting strands a stale GSAP base
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
||||||
// that the next drag adds to, flinging the element off-screen.
|
// that the next drag adds to, flinging the element off-screen.
|
||||||
const elementKey = getTimelineElementIdentity(element);
|
const elementKey = getTimelineElementIdentity(element);
|
||||||
// Every keyframed tween on the layer, not just the first: a layer with
|
// An explicit animation id scopes the delete to the lane whose menu was
|
||||||
// position AND opacity keyframes left the second one keyframed, so
|
// opened; without one this is the layer-wide action, and that means
|
||||||
// "Delete All Keyframes" visibly did half the job.
|
// EVERY keyframed tween, not just the first. A layer with position AND
|
||||||
const anims = resolveElementAnimations(elementKey).filter(
|
// opacity keyframes used to leave the second one keyframed, so "Delete
|
||||||
(animation) => animation.keyframes,
|
// All Keyframes" visibly did half the job. A stale id matches nothing
|
||||||
);
|
// and deletes nothing, which is the point: it never falls back to a
|
||||||
|
// lane the user did not click.
|
||||||
|
const animations = resolveElementAnimations(elementKey);
|
||||||
|
const anims = animationId
|
||||||
|
? animations.filter((animation) => animation.id === animationId)
|
||||||
|
: animations.filter((animation) => animation.keyframes);
|
||||||
if (anims.length === 0) return;
|
if (anims.length === 0) return;
|
||||||
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
|
|||||||
@@ -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;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||||
|
import { usePlayerStore } from "../player/store/playerStore";
|
||||||
import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
|
import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
|
||||||
|
|
||||||
type HookApi = ReturnType<typeof useGsapKeyframeOps>;
|
type HookApi = ReturnType<typeof useGsapKeyframeOps>;
|
||||||
@@ -30,6 +32,7 @@ function successfulCommitMutation() {
|
|||||||
|
|
||||||
function renderKeyframeOps(over: {
|
function renderKeyframeOps(over: {
|
||||||
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
||||||
|
commitMutationSafely?: (...args: unknown[]) => Promise<void>;
|
||||||
trackGsapSaveFailure: (...args: unknown[]) => void;
|
trackGsapSaveFailure: (...args: unknown[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const captured: { api: HookApi | null } = { api: null };
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
||||||
commitMutation: over.commitMutation as any,
|
commitMutation: over.commitMutation as any,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
||||||
trackGsapSaveFailure: over.trackGsapSaveFailure as any,
|
trackGsapSaveFailure: over.trackGsapSaveFailure as any,
|
||||||
sdkSession: null,
|
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<void>((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 () => {
|
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
||||||
const commitMutation = successfulCommitMutation();
|
const commitMutation = successfulCommitMutation();
|
||||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
||||||
|
|||||||
@@ -15,12 +15,7 @@ import {
|
|||||||
} from "../utils/sdkCutover";
|
} from "../utils/sdkCutover";
|
||||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||||
import { idFromSelector } from "./gsapShared";
|
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
|
||||||
import {
|
|
||||||
clearKeyframeCacheForElement,
|
|
||||||
readKeyframeSnapshot,
|
|
||||||
writeKeyframeCache,
|
|
||||||
} from "./gsapKeyframeCacheHelpers";
|
|
||||||
import type {
|
import type {
|
||||||
CommitMutation,
|
CommitMutation,
|
||||||
CommitMutationOptions,
|
CommitMutationOptions,
|
||||||
@@ -336,11 +331,6 @@ export function useGsapKeyframeOps({
|
|||||||
const removeAllKeyframes = useCallback(
|
const removeAllKeyframes = useCallback(
|
||||||
async (selection: DomEditSelection, animationId: string) => {
|
async (selection: DomEditSelection, animationId: string) => {
|
||||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
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 ?? idFromSelector(selection.selector);
|
|
||||||
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
|
|
||||||
if (sdkSession && sdkDeps) {
|
if (sdkSession && sdkDeps) {
|
||||||
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
||||||
targetPath,
|
targetPath,
|
||||||
@@ -351,7 +341,7 @@ export function useGsapKeyframeOps({
|
|||||||
);
|
);
|
||||||
if (cutoverCommittedOrThrow(handled)) return;
|
if (cutoverCommittedOrThrow(handled)) return;
|
||||||
}
|
}
|
||||||
commitMutationSafely(
|
await commitMutationSafely(
|
||||||
selection,
|
selection,
|
||||||
{ type: "remove-all-keyframes", animationId },
|
{ type: "remove-all-keyframes", animationId },
|
||||||
{ label: "Remove all keyframes", softReload: true },
|
{ label: "Remove all keyframes", softReload: true },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import {
|
import {
|
||||||
KeyframeDiamondContextMenu,
|
KeyframeDiamondContextMenu,
|
||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement;
|
const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement;
|
||||||
|
|
||||||
const state: KeyframeDiamondContextMenuState = {
|
const state: KeyframeDiamondContextMenuState = {
|
||||||
@@ -87,4 +91,15 @@ describe("KeyframeDiamondContextMenu", () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
host.remove();
|
host.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The layer-wide delete takes every keyframed tween. Opened from a diamond it
|
||||||
|
// has to stay on that diamond's own lane, or right-clicking the opacity lane
|
||||||
|
// silently clears position too.
|
||||||
|
it("deletes all keyframes from the animation that opened the menu", () => {
|
||||||
|
const onDeleteAll = vi.fn();
|
||||||
|
|
||||||
|
clickMenuItem("Delete All Keyframes", { onDeleteAll });
|
||||||
|
|
||||||
|
expect(onDeleteAll).toHaveBeenCalledExactlyOnceWith(element, "box-to-1-position");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|||||||
export interface KeyframeDiamondContextMenuState {
|
export interface KeyframeDiamondContextMenuState {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
/** Timeline project session that created this portaled target. */
|
||||||
|
sessionEpoch?: number;
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
@@ -23,7 +25,7 @@ interface KeyframeDiamondContextMenuProps {
|
|||||||
* floor in removeMotionPathPointInScript): an entry that silently no-ops is
|
* floor in removeMotionPathPointInScript): an entry that silently no-ops is
|
||||||
* worse than no entry. */
|
* worse than no entry. */
|
||||||
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||||
onDeleteAll: (element: TimelineElement) => void;
|
onDeleteAll: (element: TimelineElement, animationId?: string) => void;
|
||||||
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
||||||
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|||||||
type="button"
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
onDeleteAll(state.element);
|
onDeleteAll(state.element, state.animationId);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { TimelineEmptyState } from "./TimelineEmptyState";
|
|||||||
import { TimelineCanvas } from "./TimelineCanvas";
|
import { TimelineCanvas } from "./TimelineCanvas";
|
||||||
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||||
import { TimelineOverlays } from "./TimelineOverlays";
|
import { TimelineOverlays, type ClipContextMenuState } from "./TimelineOverlays";
|
||||||
import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
||||||
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
||||||
import { useTimelineGeometry } from "./useTimelineGeometry";
|
import { useTimelineGeometry } from "./useTimelineGeometry";
|
||||||
@@ -141,11 +141,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
const shiftHeld = useTimelineShiftModifier();
|
const shiftHeld = useTimelineShiftModifier();
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
|
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
|
||||||
const [clipContextMenu, setClipContextMenu] = useState<{
|
const [clipContextMenu, setClipContextMenu] = useState<ClipContextMenuState | null>(null);
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
element: TimelineElement;
|
|
||||||
} | null>(null);
|
|
||||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
containerRef.current = el;
|
containerRef.current = el;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -557,7 +553,12 @@ export const Timeline = memo(function Timeline({
|
|||||||
setSelectedElementId(el.key ?? el.id);
|
setSelectedElementId(el.key ?? el.id);
|
||||||
onSelectElement?.(el);
|
onSelectElement?.(el);
|
||||||
dismissGapMenu();
|
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) => {
|
onContextMenuLane={(e, track, time) => {
|
||||||
if (draggedClip?.started || resizingClip) return;
|
if (draggedClip?.started || resizingClip) return;
|
||||||
@@ -568,6 +569,8 @@ export const Timeline = memo(function Timeline({
|
|||||||
{activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
|
{activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
|
||||||
</div>
|
</div>
|
||||||
<TimelineOverlays
|
<TimelineOverlays
|
||||||
|
elements={expandedElements}
|
||||||
|
elementsRef={expandedElementsRef}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
showShortcutHint={showShortcutHint}
|
showShortcutHint={showShortcutHint}
|
||||||
showPopover={showPopover}
|
showPopover={showPopover}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useEffect, type MutableRefObject } from "react";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
import type { TimelineTheme } from "./timelineTheme";
|
import type { TimelineTheme } from "./timelineTheme";
|
||||||
import type { TimelineRangeSelection } from "./timelineEditing";
|
import type { TimelineRangeSelection } from "./timelineEditing";
|
||||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||||
@@ -11,10 +13,11 @@ import { ClipContextMenu } from "./ClipContextMenu";
|
|||||||
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
||||||
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
||||||
|
|
||||||
interface ClipContextMenuState {
|
export interface ClipContextMenuState {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
|
sessionEpoch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolved model for the empty-lane-space (track gap) context menu. */
|
/** Resolved model for the empty-lane-space (track gap) context menu. */
|
||||||
@@ -28,6 +31,8 @@ interface TrackGapContextMenuState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TimelineOverlaysProps {
|
interface TimelineOverlaysProps {
|
||||||
|
elements: readonly TimelineElement[];
|
||||||
|
elementsRef: MutableRefObject<readonly TimelineElement[]>;
|
||||||
theme: TimelineTheme;
|
theme: TimelineTheme;
|
||||||
showShortcutHint: boolean;
|
showShortcutHint: boolean;
|
||||||
showPopover: boolean;
|
showPopover: boolean;
|
||||||
@@ -52,10 +57,49 @@ interface TimelineOverlaysProps {
|
|||||||
onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
|
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 timeline's floating overlays, rendered as siblings above the scroll area:
|
||||||
// the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
|
// the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
|
||||||
// and the clip context menu.
|
// and the clip context menu.
|
||||||
export function TimelineOverlays({
|
export function TimelineOverlays({
|
||||||
|
elements,
|
||||||
|
elementsRef,
|
||||||
theme,
|
theme,
|
||||||
showShortcutHint,
|
showShortcutHint,
|
||||||
showPopover,
|
showPopover,
|
||||||
@@ -79,6 +123,39 @@ export function TimelineOverlays({
|
|||||||
onCloseAllTrackGaps,
|
onCloseAllTrackGaps,
|
||||||
onHoverGapAction,
|
onHoverGapAction,
|
||||||
}: TimelineOverlaysProps) {
|
}: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showShortcutHint && !showPopover && !rangeSelection && (
|
{showShortcutHint && !showPopover && !rangeSelection && (
|
||||||
@@ -98,29 +175,45 @@ export function TimelineOverlays({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{kfContextMenu && (
|
{kfContextMenu && keyframeElement && (
|
||||||
<KeyframeDiamondContextMenu
|
<KeyframeDiamondContextMenu
|
||||||
state={kfContextMenu}
|
state={{ ...kfContextMenu, element: keyframeElement }}
|
||||||
onClose={() => setKfContextMenu(null)}
|
onClose={() => setKfContextMenu(null)}
|
||||||
onDelete={(elId, keyframe) => onDeleteKeyframe?.(elId, keyframe)}
|
onDelete={(...args) => {
|
||||||
onDeleteAll={(element) => onDeleteAllKeyframes?.(element)}
|
if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return;
|
||||||
|
onDeleteKeyframe?.(...args);
|
||||||
|
}}
|
||||||
|
onDeleteAll={(_element, animationId) => {
|
||||||
|
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
||||||
|
if (element) onDeleteAllKeyframes?.(element, animationId);
|
||||||
|
}}
|
||||||
onMoveToPlayhead={
|
onMoveToPlayhead={
|
||||||
onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined
|
onMoveKeyframeToPlayhead
|
||||||
|
? (_element, ...args) => {
|
||||||
|
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
||||||
|
if (element) onMoveKeyframeToPlayhead(element, ...args);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{clipContextMenu && (
|
{clipContextMenu && clipElement && (
|
||||||
<ClipContextMenu
|
<ClipContextMenu
|
||||||
x={clipContextMenu.x}
|
x={clipContextMenu.x}
|
||||||
y={clipContextMenu.y}
|
y={clipContextMenu.y}
|
||||||
element={clipContextMenu.element}
|
element={clipElement}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
onClose={() => setClipContextMenu(null)}
|
onClose={() => setClipContextMenu(null)}
|
||||||
onSplit={(el, time) => onSplitElement?.(el, time)}
|
onSplit={(_element, time) => {
|
||||||
onDelete={(el) => {
|
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
||||||
|
if (element) onSplitElement?.(element, time);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
||||||
|
if (!element) return;
|
||||||
pinZoomBeforeEdit();
|
pinZoomBeforeEdit();
|
||||||
onDeleteElement?.(el);
|
onDeleteElement?.(element);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ export interface TimelineEditCallbacks {
|
|||||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||||
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
||||||
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||||
onDeleteAllKeyframes?: (element: TimelineElement) => void;
|
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
|
||||||
|
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||||
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||||
/** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
|
/** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
|
||||||
* is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
|
* is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
usePlayerStore.setState({ focusedEaseSegment: null });
|
usePlayerStore.setState({ focusedEaseSegment: null, timelineSessionEpoch: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,4 +126,39 @@ describe("useTimelineKeyframeHandlers", () => {
|
|||||||
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
|
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
|
||||||
act(() => root.unmount());
|
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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -550,12 +550,13 @@ export function useTimelineKeyframeHandlers({
|
|||||||
setKfContextMenu({
|
setKfContextMenu({
|
||||||
x: e.clientX + 4,
|
x: e.clientX + 4,
|
||||||
y: e.clientY + 2,
|
y: e.clientY + 2,
|
||||||
|
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
||||||
|
element: el,
|
||||||
elementId: elId,
|
elementId: elId,
|
||||||
percentage: target.percentage,
|
percentage: target.percentage,
|
||||||
tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
|
tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
|
||||||
propertyGroup: target.propertyGroup,
|
propertyGroup: target.propertyGroup,
|
||||||
animationId: target.animationId,
|
animationId: target.animationId,
|
||||||
element: el,
|
|
||||||
currentEase: kf?.ease ?? kfData?.ease,
|
currentEase: kf?.ease ?? kfData?.ease,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||||
|
import { useTrackGapMenu } from "./useTrackGapMenu";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const lane: TimelineElement[] = [
|
||||||
|
{ id: "first", tag: "div", start: 0, duration: 1, track: 0 },
|
||||||
|
{ id: "second", tag: "div", start: 3, duration: 1, track: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
usePlayerStore.getState().reset();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useTrackGapMenu", () => {
|
||||||
|
it("does not commit an anchor captured in a previous project session", () => {
|
||||||
|
usePlayerStore.setState({ elements: lane, timelineSessionEpoch: 1 });
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const onMoveElement = vi.fn();
|
||||||
|
const onMoveElements = vi.fn();
|
||||||
|
let api: ReturnType<typeof useTrackGapMenu> | null = null;
|
||||||
|
|
||||||
|
function getApi(): ReturnType<typeof useTrackGapMenu> {
|
||||||
|
if (!api) throw new Error("gap menu harness did not render");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
api = useTrackGapMenu({
|
||||||
|
tracks: [[0, lane]],
|
||||||
|
expandedElementsRef: { current: lane },
|
||||||
|
trackOrderRef: { current: [0] },
|
||||||
|
onMoveElement,
|
||||||
|
onMoveElements,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => root.render(<Probe />));
|
||||||
|
act(() => getApi().openGapMenu({ x: 10, y: 20, track: 0, time: 2 }));
|
||||||
|
expect(getApi().gapMenuModel).not.toBeNull();
|
||||||
|
const staleCloseTrackGap = getApi().closeTrackGap;
|
||||||
|
const staleCloseAllTrackGaps = getApi().closeAllTrackGaps;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
usePlayerStore.setState({ timelineSessionEpoch: 2 });
|
||||||
|
staleCloseTrackGap();
|
||||||
|
staleCloseAllTrackGaps();
|
||||||
|
});
|
||||||
|
expect(getApi().gapMenuModel).toBeNull();
|
||||||
|
|
||||||
|
expect(onMoveElement).not.toHaveBeenCalled();
|
||||||
|
expect(onMoveElements).not.toHaveBeenCalled();
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState, type MutableRefObject } from "react";
|
import { useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
|
||||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||||
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ interface TrackGapMenuAnchor {
|
|||||||
y: number;
|
y: number;
|
||||||
track: number;
|
track: number;
|
||||||
time: number;
|
time: number;
|
||||||
|
sessionEpoch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gap strips to paint on one lane while a menu row is hovered. */
|
/** Gap strips to paint on one lane while a menu row is hovered. */
|
||||||
@@ -32,9 +33,9 @@ export interface TrackGapHighlight {
|
|||||||
/**
|
/**
|
||||||
* Track-gap context menu (right-click on empty lane space) — state, the
|
* Track-gap context menu (right-click on empty lane space) — state, the
|
||||||
* derived menu model, and the two commit actions. Extracted from Timeline.tsx
|
* derived menu model, and the two commit actions. Extracted from Timeline.tsx
|
||||||
* as a cohesive unit (600-line studio cap); behavior identical.
|
* as a cohesive unit (600-line studio cap).
|
||||||
*
|
*
|
||||||
* Only the ANCHOR (and the hovered row) is state; the menu model (gap under
|
* Only the session-stamped ANCHOR (and the hovered row) is state; the menu model (gap under
|
||||||
* the pointer, compaction, movability) derives from live `tracks` so an open
|
* the pointer, compaction, movability) derives from live `tracks` so an open
|
||||||
* menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
|
* menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
|
||||||
* paints while "Close gap" / "Close all gaps" is hovered — derives the same
|
* paints while "Close gap" / "Close all gaps" is hovered — derives the same
|
||||||
@@ -55,12 +56,16 @@ export function useTrackGapMenu({
|
|||||||
onMoveElements: DragCommitDeps["onMoveElements"];
|
onMoveElements: DragCommitDeps["onMoveElements"];
|
||||||
}) {
|
}) {
|
||||||
const updateElement = usePlayerStore((s) => s.updateElement);
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
||||||
|
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
||||||
const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
|
const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
|
||||||
const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
|
const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
|
||||||
|
|
||||||
const gapMenuLaneElements = useMemo(
|
const gapMenuLaneElements = useMemo(
|
||||||
() => (gapContextMenu ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? []) : null),
|
() =>
|
||||||
[gapContextMenu, tracks],
|
gapContextMenu?.sessionEpoch === sessionEpoch
|
||||||
|
? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? [])
|
||||||
|
: null,
|
||||||
|
[gapContextMenu, sessionEpoch, tracks],
|
||||||
);
|
);
|
||||||
const gapMenuModel = useMemo(() => {
|
const gapMenuModel = useMemo(() => {
|
||||||
if (!gapContextMenu || !gapMenuLaneElements) return null;
|
if (!gapContextMenu || !gapMenuLaneElements) return null;
|
||||||
@@ -99,7 +104,12 @@ export function useTrackGapMenu({
|
|||||||
}, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
|
}, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
|
||||||
|
|
||||||
const closeTrackGap = useCallback(() => {
|
const closeTrackGap = useCallback(() => {
|
||||||
if (!gapContextMenu || !gapMenuLaneElements) return;
|
if (
|
||||||
|
!gapContextMenu ||
|
||||||
|
!gapMenuLaneElements ||
|
||||||
|
gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
|
||||||
|
)
|
||||||
|
return;
|
||||||
commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
|
commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
|
||||||
elements: expandedElementsRef.current,
|
elements: expandedElementsRef.current,
|
||||||
trackOrder: trackOrderRef.current,
|
trackOrder: trackOrderRef.current,
|
||||||
@@ -117,7 +127,12 @@ export function useTrackGapMenu({
|
|||||||
onMoveElements,
|
onMoveElements,
|
||||||
]);
|
]);
|
||||||
const closeAllTrackGaps = useCallback(() => {
|
const closeAllTrackGaps = useCallback(() => {
|
||||||
if (!gapMenuLaneElements) return;
|
if (
|
||||||
|
!gapContextMenu ||
|
||||||
|
!gapMenuLaneElements ||
|
||||||
|
gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
|
||||||
|
)
|
||||||
|
return;
|
||||||
commitCloseAllTrackGaps(gapMenuLaneElements, {
|
commitCloseAllTrackGaps(gapMenuLaneElements, {
|
||||||
elements: expandedElementsRef.current,
|
elements: expandedElementsRef.current,
|
||||||
trackOrder: trackOrderRef.current,
|
trackOrder: trackOrderRef.current,
|
||||||
@@ -126,6 +141,7 @@ export function useTrackGapMenu({
|
|||||||
onMoveElements,
|
onMoveElements,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
gapContextMenu,
|
||||||
gapMenuLaneElements,
|
gapMenuLaneElements,
|
||||||
expandedElementsRef,
|
expandedElementsRef,
|
||||||
trackOrderRef,
|
trackOrderRef,
|
||||||
@@ -134,15 +150,22 @@ export function useTrackGapMenu({
|
|||||||
onMoveElements,
|
onMoveElements,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
|
const openGapMenu = useCallback((anchor: Omit<TrackGapMenuAnchor, "sessionEpoch">) => {
|
||||||
setHoveredGapAction(null);
|
setHoveredGapAction(null);
|
||||||
setGapContextMenu(anchor);
|
setGapContextMenu({
|
||||||
|
...anchor,
|
||||||
|
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
const dismissGapMenu = useCallback(() => {
|
const dismissGapMenu = useCallback(() => {
|
||||||
setHoveredGapAction(null);
|
setHoveredGapAction(null);
|
||||||
setGapContextMenu(null);
|
setGapContextMenu(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gapContextMenu && gapContextMenu.sessionEpoch !== sessionEpoch) dismissGapMenu();
|
||||||
|
}, [dismissGapMenu, gapContextMenu, sessionEpoch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gapMenuModel,
|
gapMenuModel,
|
||||||
gapHighlight,
|
gapHighlight,
|
||||||
|
|||||||
Reference in New Issue
Block a user