Skip to content

Commit 802df15

Browse files
committed
feat(studio): canvas context menu and z-order actions (unwired)
What: CanvasContextMenu (right-click menu for canvas selections) and canvasContextMenuZOrder (tie-aware bring-forward/send-backward z-order patch computation) with its test suite. Shipped unwired. Why: the z-order rules are the substance; mounting is one line in the later overlay swap. How: new files, compiled against current main. Nothing mounts the menu yet, so .fallowrc.jsonc gains TEMP(studio-dnd) entries (entry registration + ignoreExports) — removed by the app-shell swap PR that wires everything. Test plan: bunx vitest run canvasContextMenuZOrder.test.ts; tsc --noEmit; fallow audit clean.
1 parent 34fba38 commit 802df15

5 files changed

Lines changed: 1115 additions & 0 deletions

File tree

.fallowrc.jsonc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
"packages/studio/src/hooks/gsapTargetCache.ts",
5454
// Preview helper consumed dynamically from the studio iframe bridge.
5555
"packages/studio/src/hooks/gsapRuntimePreview.ts",
56+
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
57+
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
58+
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
5659
],
5760
"ignorePatterns": [
5861
"docs/**",
@@ -90,6 +93,11 @@
9093
"packages/cli/src/cloud/_gen/**",
9194
],
9295
"ignoreExports": [
96+
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
97+
{
98+
"file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
99+
"exports": ["readEffectiveZIndex"],
100+
},
93101
// drawElementService is the bottom of the fast-capture Graphite stack
94102
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
95103
// a per-PR audit diffing against the merge base sees these exports as
@@ -433,6 +441,13 @@
433441
// complexity pre-dates the computed-timeline work. Exempted at file level
434442
// rather than refactored as scope creep.
435443
"ignore": [
444+
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
445+
// removed by studio-dnd/pr22 when the final config lands.
446+
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
447+
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
448+
"packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
449+
"packages/studio/src/player/components/timelineCollision.test.ts",
450+
"packages/studio/src/player/components/timelineStackingSync.test.ts",
436451
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
437452
// patch*InTag pre-date this PR; only the PatchOperation type gained two
438453
// optional fields, but the line-shift fingerprint re-flags the inherited
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// @vitest-environment happy-dom
2+
import React, { act } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6+
import { CanvasContextMenu } from "./CanvasContextMenu";
7+
import type { DomEditSelection } from "./domEditing";
8+
9+
installReactActEnvironment();
10+
11+
let host: HTMLDivElement;
12+
let root: Root | null = null;
13+
14+
beforeEach(() => {
15+
host = document.createElement("div");
16+
document.body.append(host);
17+
});
18+
19+
afterEach(() => {
20+
act(() => root?.unmount());
21+
root = null;
22+
document.body.innerHTML = "";
23+
});
24+
25+
function renderMenu(props: {
26+
selection: DomEditSelection;
27+
onApplyZIndex?: () => void;
28+
onDelete?: (selection: DomEditSelection) => void;
29+
}) {
30+
root = createRoot(host);
31+
act(() => {
32+
root!.render(
33+
React.createElement(CanvasContextMenu, {
34+
x: 10,
35+
y: 10,
36+
selection: props.selection,
37+
onClose: () => {},
38+
onApplyZIndex: props.onApplyZIndex,
39+
onDelete: props.onDelete,
40+
}),
41+
);
42+
});
43+
}
44+
45+
/** All menu buttons live in the portal under document.body. */
46+
function menuButtons(): HTMLButtonElement[] {
47+
return [...document.body.querySelectorAll("button")];
48+
}
49+
50+
function hasDeleteItem(): boolean {
51+
return menuButtons().some((b) => b.textContent?.includes("Delete"));
52+
}
53+
54+
function zOrderButtons(): HTMLButtonElement[] {
55+
return menuButtons().filter((b) => !b.textContent?.includes("Delete"));
56+
}
57+
58+
describe("CanvasContextMenu — handler gating", () => {
59+
it("renders all four z-order items, a divider, and Delete when both handlers are present", () => {
60+
const el = document.createElement("div");
61+
el.id = "target";
62+
document.body.append(el);
63+
64+
renderMenu({
65+
selection: makeSelection("Target", el),
66+
onApplyZIndex: vi.fn(),
67+
onDelete: vi.fn(),
68+
});
69+
70+
expect(zOrderButtons()).toHaveLength(4);
71+
expect(hasDeleteItem()).toBe(true);
72+
// The divider only appears between the two groups.
73+
expect(document.body.querySelector(".border-t")).not.toBeNull();
74+
});
75+
76+
it("hides every item and does NOT render the menu when no handlers are present", () => {
77+
const el = document.createElement("div");
78+
el.id = "target";
79+
// A z-index that a stray optimistic write would clobber — assert it is
80+
// untouched, since the menu must not mutate the DOM without a persist path.
81+
el.style.zIndex = "3";
82+
document.body.append(el);
83+
84+
renderMenu({ selection: makeSelection("Target", el) });
85+
86+
// No menu opened at all — no buttons, no dead-end items, no DOM mutation.
87+
expect(menuButtons()).toHaveLength(0);
88+
expect(document.body.querySelector(".fixed.z-50")).toBeNull();
89+
expect(el.style.zIndex).toBe("3");
90+
});
91+
92+
it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => {
93+
const el = document.createElement("div");
94+
el.id = "target";
95+
document.body.append(el);
96+
97+
renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
98+
99+
expect(zOrderButtons()).toHaveLength(4);
100+
expect(hasDeleteItem()).toBe(false);
101+
expect(document.body.querySelector(".border-t")).toBeNull();
102+
});
103+
104+
it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
105+
const el = document.createElement("div");
106+
el.id = "target";
107+
document.body.append(el);
108+
109+
renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() });
110+
111+
expect(zOrderButtons()).toHaveLength(0);
112+
expect(hasDeleteItem()).toBe(true);
113+
expect(document.body.querySelector(".border-t")).toBeNull();
114+
});
115+
});
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/**
2+
* Right-click context menu for a selected canvas element.
3+
*
4+
* Mirrors the look, positioning, and dismiss behavior of
5+
* player/components/ClipContextMenu.tsx — portaled to document.body,
6+
* overflow-adjusted, dismissed on outside-click or Escape via
7+
* useContextMenuDismiss.
8+
*
9+
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
10+
* Z-index changes are applied optimistically to the live iframe element(s) via
11+
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
12+
* moving a target past an equal-z sibling can require renumbering the affected
13+
* set). The patches are surfaced through the `onApplyZIndex` prop.
14+
*
15+
* The prop MUST be wired at the call site to route through the full persist
16+
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
17+
* element carries its full selection identity; sibling elements are iframe DOM
18+
* nodes, so their id / selector are derived from the node and they share the
19+
* selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
20+
* ─────────────────────────────────────────────────────────────────────────────
21+
*/
22+
23+
import { memo } from "react";
24+
import { createPortal } from "react-dom";
25+
import type { DomEditSelection } from "./domEditing";
26+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
27+
import {
28+
isZOrderActionEnabled,
29+
resolveZOrderChange,
30+
type ZOrderPatch,
31+
} from "./canvasContextMenuZOrder";
32+
33+
interface CanvasContextMenuProps {
34+
/** Viewport x of the right-click event. */
35+
x: number;
36+
/** Viewport y of the right-click event. */
37+
y: number;
38+
selection: DomEditSelection;
39+
onClose: () => void;
40+
/**
41+
* Called with the resolved z-order patch list after an optimistic DOM update.
42+
* Each patch is an { element, zIndex } pair (the target and, when a renumber
43+
* is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
44+
* module-level wiring comment).
45+
*/
46+
onApplyZIndex?: (patches: ZOrderPatch[]) => void;
47+
/**
48+
* Delete the selected element. Wire to handleDomEditElementDelete from
49+
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
50+
* Absent when the caller wires no delete persist path (e.g. a legacy mount):
51+
* the Delete item is then hidden rather than shown as a silent no-op.
52+
*/
53+
onDelete?: (selection: DomEditSelection) => void;
54+
}
55+
56+
type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
57+
58+
const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
59+
{ action: "bring-forward", label: "Bring forward" },
60+
{ action: "send-backward", label: "Send backward" },
61+
{ action: "bring-to-front", label: "Bring to front" },
62+
{ action: "send-to-back", label: "Send to back" },
63+
];
64+
65+
export const CanvasContextMenu = memo(function CanvasContextMenu({
66+
x,
67+
y,
68+
selection,
69+
onClose,
70+
onApplyZIndex,
71+
onDelete,
72+
}: CanvasContextMenuProps) {
73+
const menuRef = useContextMenuDismiss(onClose);
74+
75+
// Gate each item group on the presence of its persist handler. Without the
76+
// handler the action can't be persisted, so showing it would be a dead-end:
77+
// a z-write reverts on reload and Delete silently no-ops. Hide the group
78+
// instead. If nothing is actionable (a legacy mount with no handlers at all),
79+
// don't render the menu — an empty menu is itself a dead-end.
80+
const hasZActions = Boolean(onApplyZIndex);
81+
const hasDelete = Boolean(onDelete);
82+
const hasDivider = hasZActions && hasDelete;
83+
84+
// Overflow correction — match ClipContextMenu approach. Only the rendered
85+
// groups contribute height (keeps positioning correct when a group is hidden).
86+
const menuWidth = 200;
87+
const menuHeight =
88+
8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding
89+
const overflowY = y + menuHeight - window.innerHeight;
90+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
91+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
92+
93+
const el = selection.element;
94+
95+
function handleZAction(action: ZAction) {
96+
// No persist handler → do NOT touch the live iframe DOM. An optimistic
97+
// write with nothing to persist just reverts on the next reload.
98+
if (!onApplyZIndex) return;
99+
const patches = resolveZOrderChange(el, action);
100+
if (patches === null) return;
101+
// Optimistic update — visible immediately even before persist completes.
102+
for (const patch of patches) {
103+
patch.element.style.zIndex = String(patch.zIndex);
104+
const view = patch.element.ownerDocument?.defaultView;
105+
if (view && view.getComputedStyle(patch.element).position === "static") {
106+
patch.element.style.position = "relative";
107+
}
108+
}
109+
onApplyZIndex(patches);
110+
onClose();
111+
}
112+
113+
function handleDelete() {
114+
if (!onDelete) return;
115+
onDelete(selection);
116+
onClose();
117+
}
118+
119+
if (!hasZActions && !hasDelete) return null;
120+
121+
// The menu is portaled to document.body, but in the React tree it is still a
122+
// child of the DomEditOverlay <div>. React synthetic events bubble through the
123+
// REACT tree (not the DOM tree), so a click on any menu control would otherwise
124+
// bubble into the overlay's onPointerDown / onMouseDown handlers — which
125+
// preventDefault() to start a marquee and re-resolve the selection. That
126+
// preventDefault cancels the button's own click and the item action never runs.
127+
//
128+
// Stop pointer/mouse propagation at the menu root so overlay gesture handlers
129+
// never see these events, and drive the item actions on pointerDown (which
130+
// fires before any outside-click / dismiss logic can unmount the menu).
131+
const stopBubble = (e: React.SyntheticEvent) => {
132+
e.stopPropagation();
133+
};
134+
135+
return createPortal(
136+
<div
137+
ref={menuRef}
138+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
139+
style={{ left: adjustedX, top: adjustedY }}
140+
onPointerDown={stopBubble}
141+
onMouseDown={stopBubble}
142+
onClick={stopBubble}
143+
onContextMenu={(e) => {
144+
// Keep a right-click on the menu itself from re-opening / bubbling.
145+
e.preventDefault();
146+
e.stopPropagation();
147+
}}
148+
>
149+
{hasZActions &&
150+
Z_ACTIONS.map(({ action, label }) => {
151+
const enabled = isZOrderActionEnabled(el, action);
152+
return (
153+
<button
154+
key={action}
155+
type="button"
156+
className={`w-full flex items-center px-3 py-1.5 text-xs text-left ${
157+
enabled
158+
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
159+
: "text-neutral-600 cursor-not-allowed"
160+
}`}
161+
disabled={!enabled}
162+
// Act on pointerDown, not click: a pointerDown that reaches the
163+
// overlay/document would otherwise re-select or dismiss the menu
164+
// before the trailing click fires. Running here guarantees the
165+
// action lands. Guard `button === 0` so a right-press is ignored.
166+
onPointerDown={(e) => {
167+
if (e.button !== 0) return;
168+
e.preventDefault();
169+
e.stopPropagation();
170+
if (enabled) handleZAction(action);
171+
}}
172+
>
173+
{label}
174+
</button>
175+
);
176+
})}
177+
178+
{hasDivider && <div className="my-1 border-t border-neutral-700/60" />}
179+
180+
{hasDelete && (
181+
<button
182+
type="button"
183+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
184+
onPointerDown={(e) => {
185+
if (e.button !== 0) return;
186+
e.preventDefault();
187+
e.stopPropagation();
188+
handleDelete();
189+
}}
190+
>
191+
<span>Delete</span>
192+
<span className="text-neutral-500 text-[10px] ml-3"></span>
193+
</button>
194+
)}
195+
</div>,
196+
document.body,
197+
);
198+
});

0 commit comments

Comments
 (0)