Files
heygen-com--hyperframes/packages/studio/src/components/editor/CanvasContextMenu.tsx
T
wehub-resource-sync 85453da49f
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

199 lines
8.1 KiB
TypeScript

/**
* 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,
);
});