fix(studio): open the path node menu on arc waypoints

Right-clicking a motionPath waypoint in the preview overlay opened Chrome's
own context menu on top of the editor: the handler returned before
preventDefault for every node that was not an x/y keyframe. Both node kinds
now open Studio's menu. A waypoint has no percentage of its own, so Move to
Playhead is hidden and Delete acts on the path index, matching the hover x
badge; Delete is withheld entirely on a two-anchor arc, where the writer
refuses the removal and the entry would silently do nothing.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-29 03:44:31 +02:00
parent 659e22656e
commit adb7de5358
3 changed files with 92 additions and 30 deletions
@@ -66,4 +66,25 @@ describe("KeyframeDiamondContextMenu", () => {
expect(onDelete).toHaveBeenCalledWith("box", target);
expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target);
});
// An arc waypoint on a two-anchor path cannot be dropped on its own, so the
// caller withholds onDelete rather than offering an entry that does nothing.
it("hides the single-node delete when no handler is given", () => {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
act(() =>
root.render(
<KeyframeDiamondContextMenu state={state} onClose={() => {}} onDeleteAll={vi.fn()} />,
),
);
const labels = Array.from(document.body.querySelectorAll("button")).map(
(button) => button.textContent,
);
expect(labels).toEqual(["Delete All Keyframes"]);
act(() => root.unmount());
host.remove();
});
});
@@ -19,7 +19,10 @@ export interface KeyframeDiamondContextMenuState {
interface KeyframeDiamondContextMenuProps {
state: KeyframeDiamondContextMenuState;
onClose: () => void;
onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
/** Omitted where this node cannot be deleted on its own (see the arc-waypoint
* floor in removeMotionPathPointInScript): an entry that silently no-ops is
* worse than no entry. */
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAll: (element: TimelineElement) => void;
/** Retime the keyframe to the current playhead, preserving its value + ease. */
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
@@ -43,7 +46,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
};
const menuWidth = 200;
const menuHeight = onMoveToPlayhead ? 100 : 70;
// Measured off the rendered rows, so the flip-up test below stays right as
// optional entries drop out.
const menuHeight = 10 + (1 + (onMoveToPlayhead ? 1 : 0) + (onDelete ? 1 : 0)) * 30;
const overflowY = state.y + menuHeight - window.innerHeight;
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
@@ -71,16 +76,18 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
)}
{/* Delete */}
<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"
onClick={() => {
onDelete(state.elementId, keyframe);
onClose();
}}
>
Delete Keyframe
</button>
{onDelete && (
<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"
onClick={() => {
onDelete(state.elementId, keyframe);
onClose();
}}
>
Delete Keyframe
</button>
)}
<button
type="button"