Skip to content

Commit 9d0fa02

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 ff02d68 commit 9d0fa02

4 files changed

Lines changed: 816 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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
*/
51+
onDelete: (selection: DomEditSelection) => void;
52+
}
53+
54+
type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
55+
56+
const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
57+
{ action: "bring-forward", label: "Bring forward" },
58+
{ action: "send-backward", label: "Send backward" },
59+
{ action: "bring-to-front", label: "Bring to front" },
60+
{ action: "send-to-back", label: "Send to back" },
61+
];
62+
63+
export const CanvasContextMenu = memo(function CanvasContextMenu({
64+
x,
65+
y,
66+
selection,
67+
onClose,
68+
onApplyZIndex,
69+
onDelete,
70+
}: CanvasContextMenuProps) {
71+
const menuRef = useContextMenuDismiss(onClose);
72+
73+
// Overflow correction — match ClipContextMenu approach.
74+
const menuWidth = 200;
75+
const menuHeight = 8 + Z_ACTIONS.length * 28 + 1 + 28 + 8; // padding + items + divider + delete + padding
76+
const overflowY = y + menuHeight - window.innerHeight;
77+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
78+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
79+
80+
const el = selection.element;
81+
82+
function handleZAction(action: ZAction) {
83+
const patches = resolveZOrderChange(el, action);
84+
if (patches === null) return;
85+
// Optimistic update — visible immediately even before persist completes.
86+
for (const patch of patches) {
87+
patch.element.style.zIndex = String(patch.zIndex);
88+
const view = patch.element.ownerDocument?.defaultView;
89+
if (view && view.getComputedStyle(patch.element).position === "static") {
90+
patch.element.style.position = "relative";
91+
}
92+
}
93+
onApplyZIndex?.(patches);
94+
onClose();
95+
}
96+
97+
function handleDelete() {
98+
onDelete(selection);
99+
onClose();
100+
}
101+
102+
// The menu is portaled to document.body, but in the React tree it is still a
103+
// child of the DomEditOverlay <div>. React synthetic events bubble through the
104+
// REACT tree (not the DOM tree), so a click on any menu control would otherwise
105+
// bubble into the overlay's onPointerDown / onMouseDown handlers — which
106+
// preventDefault() to start a marquee and re-resolve the selection. That
107+
// preventDefault cancels the button's own click and the item action never runs.
108+
//
109+
// Stop pointer/mouse propagation at the menu root so overlay gesture handlers
110+
// never see these events, and drive the item actions on pointerDown (which
111+
// fires before any outside-click / dismiss logic can unmount the menu).
112+
const stopBubble = (e: React.SyntheticEvent) => {
113+
e.stopPropagation();
114+
};
115+
116+
return createPortal(
117+
<div
118+
ref={menuRef}
119+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
120+
style={{ left: adjustedX, top: adjustedY }}
121+
onPointerDown={stopBubble}
122+
onMouseDown={stopBubble}
123+
onClick={stopBubble}
124+
onContextMenu={(e) => {
125+
// Keep a right-click on the menu itself from re-opening / bubbling.
126+
e.preventDefault();
127+
e.stopPropagation();
128+
}}
129+
>
130+
{Z_ACTIONS.map(({ action, label }) => {
131+
const enabled = isZOrderActionEnabled(el, action);
132+
return (
133+
<button
134+
key={action}
135+
type="button"
136+
className={`w-full flex items-center px-3 py-1.5 text-xs text-left ${
137+
enabled
138+
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
139+
: "text-neutral-600 cursor-not-allowed"
140+
}`}
141+
disabled={!enabled}
142+
// Act on pointerDown, not click: a pointerDown that reaches the
143+
// overlay/document would otherwise re-select or dismiss the menu
144+
// before the trailing click fires. Running here guarantees the
145+
// action lands. Guard `button === 0` so a right-press is ignored.
146+
onPointerDown={(e) => {
147+
if (e.button !== 0) return;
148+
e.preventDefault();
149+
e.stopPropagation();
150+
if (enabled) handleZAction(action);
151+
}}
152+
>
153+
{label}
154+
</button>
155+
);
156+
})}
157+
158+
<div className="my-1 border-t border-neutral-700/60" />
159+
160+
<button
161+
type="button"
162+
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"
163+
onPointerDown={(e) => {
164+
if (e.button !== 0) return;
165+
e.preventDefault();
166+
e.stopPropagation();
167+
handleDelete();
168+
}}
169+
>
170+
<span>Delete</span>
171+
<span className="text-neutral-500 text-[10px] ml-3"></span>
172+
</button>
173+
</div>,
174+
document.body,
175+
);
176+
});

0 commit comments

Comments
 (0)