Skip to content
20 changes: 20 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
"docs/**",
Expand Down Expand Up @@ -90,6 +93,16 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
{
"file": "packages/studio/src/components/editor/domEditNudge.ts",
"exports": ["CANVAS_NUDGE_COMMIT_DEBOUNCE_MS"],
},
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
{
"file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
"exports": ["readEffectiveZIndex"],
},
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as
Expand Down Expand Up @@ -433,6 +446,13 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
"packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
"packages/studio/src/player/components/timelineCollision.test.ts",
"packages/studio/src/player/components/timelineStackingSync.test.ts",
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
Expand Down
115 changes: 115 additions & 0 deletions packages/studio/src/components/editor/CanvasContextMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
import { CanvasContextMenu } from "./CanvasContextMenu";
import type { DomEditSelection } from "./domEditing";

installReactActEnvironment();

let host: HTMLDivElement;
let root: Root | null = null;

beforeEach(() => {
host = document.createElement("div");
document.body.append(host);
});

afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});

function renderMenu(props: {
selection: DomEditSelection;
onApplyZIndex?: () => void;
onDelete?: (selection: DomEditSelection) => void;
}) {
root = createRoot(host);
act(() => {
root!.render(
React.createElement(CanvasContextMenu, {
x: 10,
y: 10,
selection: props.selection,
onClose: () => {},
onApplyZIndex: props.onApplyZIndex,
onDelete: props.onDelete,
}),
);
});
}

/** All menu buttons live in the portal under document.body. */
function menuButtons(): HTMLButtonElement[] {
return [...document.body.querySelectorAll("button")];
}

function hasDeleteItem(): boolean {
return menuButtons().some((b) => b.textContent?.includes("Delete"));
}

function zOrderButtons(): HTMLButtonElement[] {
return menuButtons().filter((b) => !b.textContent?.includes("Delete"));
}

describe("CanvasContextMenu — handler gating", () => {
it("renders all four z-order items, a divider, and Delete when both handlers are present", () => {
const el = document.createElement("div");
el.id = "target";
document.body.append(el);

renderMenu({
selection: makeSelection("Target", el),
onApplyZIndex: vi.fn(),
onDelete: vi.fn(),
});

expect(zOrderButtons()).toHaveLength(4);
expect(hasDeleteItem()).toBe(true);
// The divider only appears between the two groups.
expect(document.body.querySelector(".border-t")).not.toBeNull();
});

it("hides every item and does NOT render the menu when no handlers are present", () => {
const el = document.createElement("div");
el.id = "target";
// A z-index that a stray optimistic write would clobber — assert it is
// untouched, since the menu must not mutate the DOM without a persist path.
el.style.zIndex = "3";
document.body.append(el);

renderMenu({ selection: makeSelection("Target", el) });

// No menu opened at all — no buttons, no dead-end items, no DOM mutation.
expect(menuButtons()).toHaveLength(0);
expect(document.body.querySelector(".fixed.z-50")).toBeNull();
expect(el.style.zIndex).toBe("3");
});

it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => {
const el = document.createElement("div");
el.id = "target";
document.body.append(el);

renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });

expect(zOrderButtons()).toHaveLength(4);
expect(hasDeleteItem()).toBe(false);
expect(document.body.querySelector(".border-t")).toBeNull();
});

it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
const el = document.createElement("div");
el.id = "target";
document.body.append(el);

renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() });

expect(zOrderButtons()).toHaveLength(0);
expect(hasDeleteItem()).toBe(true);
expect(document.body.querySelector(".border-t")).toBeNull();
});
});
198 changes: 198 additions & 0 deletions packages/studio/src/components/editor/CanvasContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Right-click context menu for a selected canvas element.
*
* Mirrors the look, positioning, and dismiss behavior of
* player/components/ClipContextMenu.tsx — portaled to document.body,
* overflow-adjusted, dismissed on outside-click or Escape via
* useContextMenuDismiss.
*
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
* Z-index changes are applied optimistically to the live iframe element(s) via
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
* moving a target past an equal-z sibling can require renumbering the affected
* set). The patches are surfaced through the `onApplyZIndex` prop.
*
* The prop MUST be wired at the call site to route through the full persist
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
* element carries its full selection identity; sibling elements are iframe DOM
* nodes, so their id / selector are derived from the node and they share the
* selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
* ─────────────────────────────────────────────────────────────────────────────
*/

import { memo } from "react";
import { createPortal } from "react-dom";
import type { DomEditSelection } from "./domEditing";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import {
isZOrderActionEnabled,
resolveZOrderChange,
type ZOrderPatch,
} from "./canvasContextMenuZOrder";

interface CanvasContextMenuProps {
/** Viewport x of the right-click event. */
x: number;
/** Viewport y of the right-click event. */
y: number;
selection: DomEditSelection;
onClose: () => void;
/**
* Called with the resolved z-order patch list after an optimistic DOM update.
* Each patch is an { element, zIndex } pair (the target and, when a renumber
* is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
* module-level wiring comment).
*/
onApplyZIndex?: (patches: ZOrderPatch[]) => void;
/**
* Delete the selected element. Wire to handleDomEditElementDelete from
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
* Absent when the caller wires no delete persist path (e.g. a legacy mount):
* the Delete item is then hidden rather than shown as a silent no-op.
*/
onDelete?: (selection: DomEditSelection) => void;
}

type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";

const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
{ action: "bring-forward", label: "Bring forward" },
{ action: "send-backward", label: "Send backward" },
{ action: "bring-to-front", label: "Bring to front" },
{ action: "send-to-back", label: "Send to back" },
];

export const CanvasContextMenu = memo(function CanvasContextMenu({
x,
y,
selection,
onClose,
onApplyZIndex,
onDelete,
}: CanvasContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);

// Gate each item group on the presence of its persist handler. Without the
// handler the action can't be persisted, so showing it would be a dead-end:
// a z-write reverts on reload and Delete silently no-ops. Hide the group
// instead. If nothing is actionable (a legacy mount with no handlers at all),
// don't render the menu — an empty menu is itself a dead-end.
const hasZActions = Boolean(onApplyZIndex);
const hasDelete = Boolean(onDelete);
const hasDivider = hasZActions && hasDelete;

// Overflow correction — match ClipContextMenu approach. Only the rendered
// groups contribute height (keeps positioning correct when a group is hidden).
const menuWidth = 200;
const menuHeight =
8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding
const overflowY = y + menuHeight - window.innerHeight;
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;

const el = selection.element;

function handleZAction(action: ZAction) {
// No persist handler → do NOT touch the live iframe DOM. An optimistic
// write with nothing to persist just reverts on the next reload.
if (!onApplyZIndex) return;
const patches = resolveZOrderChange(el, action);
if (patches === null) return;
// Optimistic update — visible immediately even before persist completes.
for (const patch of patches) {
patch.element.style.zIndex = String(patch.zIndex);
const view = patch.element.ownerDocument?.defaultView;
if (view && view.getComputedStyle(patch.element).position === "static") {
patch.element.style.position = "relative";
}
}
onApplyZIndex(patches);
onClose();
}

function handleDelete() {
if (!onDelete) return;
onDelete(selection);
onClose();
}

if (!hasZActions && !hasDelete) return null;

// The menu is portaled to document.body, but in the React tree it is still a
// child of the DomEditOverlay <div>. React synthetic events bubble through the
// REACT tree (not the DOM tree), so a click on any menu control would otherwise
// bubble into the overlay's onPointerDown / onMouseDown handlers — which
// preventDefault() to start a marquee and re-resolve the selection. That
// preventDefault cancels the button's own click and the item action never runs.
//
// Stop pointer/mouse propagation at the menu root so overlay gesture handlers
// never see these events, and drive the item actions on pointerDown (which
// fires before any outside-click / dismiss logic can unmount the menu).
const stopBubble = (e: React.SyntheticEvent) => {
e.stopPropagation();
};

return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
onPointerDown={stopBubble}
onMouseDown={stopBubble}
onClick={stopBubble}
onContextMenu={(e) => {
// Keep a right-click on the menu itself from re-opening / bubbling.
e.preventDefault();
e.stopPropagation();
}}
>
{hasZActions &&
Z_ACTIONS.map(({ action, label }) => {
const enabled = isZOrderActionEnabled(el, action);
return (
<button
key={action}
type="button"
className={`w-full flex items-center px-3 py-1.5 text-xs text-left ${
enabled
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`}
disabled={!enabled}
// Act on pointerDown, not click: a pointerDown that reaches the
// overlay/document would otherwise re-select or dismiss the menu
// before the trailing click fires. Running here guarantees the
// action lands. Guard `button === 0` so a right-press is ignored.
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
if (enabled) handleZAction(action);
}}
>
{label}
</button>
);
})}

{hasDivider && <div className="my-1 border-t border-neutral-700/60" />}

{hasDelete && (
<button
type="button"
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"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
handleDelete();
}}
>
<span>Delete</span>
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
</button>
)}
</div>,
document.body,
);
});
Loading
Loading