Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 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,11 @@
"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/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 +441,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