复用 UI 节点右键菜单

抽取页面级节点右键菜单并接入 UI Tree

在编辑叠加预览中支持节点右键操作

补充菜单交互测试与技术方案约定
This commit is contained in:
2026-08-19 13:25:56 +08:00
parent a5e15bf48a
commit ed092b3688
7 changed files with 305 additions and 53 deletions
@@ -362,6 +362,7 @@ export function InputSidebar({
controller.deleteNode(nodeId, treeId);
}}
onMoveNode={(request) => controller.moveNode(request)}
isLocked={controller.editor.isLocked}
/>
</aside>
);
@@ -0,0 +1,137 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
const VIEWPORT_GUTTER = 8;
const MENU_WIDTH = 176;
const PAGE_ROOT_MENU_HEIGHT = 48;
const NODE_MENU_HEIGHT = 120;
type ContextMenuPosition = {
x: number;
y: number;
};
type UiNodeContextMenuProps = {
nodeId: NodeId;
position: ContextMenuPosition;
isPageRoot: boolean;
disabled?: boolean;
onClose: () => void;
onInsertChild: (nodeId: NodeId) => void;
onInsertSibling: (nodeId: NodeId) => void;
onDelete: (nodeId: NodeId) => void;
};
function clampMenuPosition(position: ContextMenuPosition, isPageRoot: boolean) {
if (typeof window === 'undefined') return position;
const height = isPageRoot ? PAGE_ROOT_MENU_HEIGHT : NODE_MENU_HEIGHT;
return {
x: Math.max(
VIEWPORT_GUTTER,
Math.min(position.x, window.innerWidth - MENU_WIDTH - VIEWPORT_GUTTER),
),
y: Math.max(
VIEWPORT_GUTTER,
Math.min(position.y, window.innerHeight - height - VIEWPORT_GUTTER),
),
};
}
export function UiNodeContextMenu({
nodeId,
position,
isPageRoot,
disabled = false,
onClose,
onInsertChild,
onInsertSibling,
onDelete,
}: UiNodeContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState(() =>
clampMenuPosition(position, isPageRoot),
);
useLayoutEffect(() => {
setMenuPosition(clampMenuPosition(position, isPageRoot));
}, [isPageRoot, position]);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as globalThis.Node)) {
onClose();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onClose();
};
const close = () => onClose();
window.addEventListener('pointerdown', onPointerDown);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('blur', close);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('blur', close);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
};
}, [onClose]);
if (typeof document === 'undefined') return null;
const run = (action: (id: NodeId) => void) => {
if (disabled) return;
onClose();
action(nodeId);
};
return createPortal(
<div
ref={menuRef}
role="menu"
aria-label="节点操作菜单"
className="fixed z-50 min-w-44 rounded-lg border border-(--platform-subpanel-border) bg-white p-1 shadow-xl"
style={{ left: menuPosition.x, top: menuPosition.y }}
>
<button
type="button"
role="menuitem"
disabled={disabled}
className="block w-full rounded-md px-3 py-2 text-left text-xs hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => run(onInsertChild)}
>
</button>
{!isPageRoot ? (
<>
<button
type="button"
role="menuitem"
disabled={disabled}
className="block w-full rounded-md px-3 py-2 text-left text-xs hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => run(onInsertSibling)}
>
</button>
<button
type="button"
role="menuitem"
disabled={disabled}
className="block w-full rounded-md px-3 py-2 text-left text-xs text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => run(onDelete)}
>
</button>
</>
) : null}
</div>,
document.body,
);
}
@@ -18,6 +18,7 @@ import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
import type { UiEditorNodeFocusRequest } from '../model';
import { UiNodeContextMenu } from './UiNodeContextMenu';
type UiTreePanelProps = {
root: UiNode | null;
@@ -31,6 +32,7 @@ type UiTreePanelProps = {
onInsertNodeAfter: (treeId: UIDesignImageId, nodeId: NodeId) => void;
onDeleteNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
onMoveNode: (request: UiNodeMoveRequest) => void;
isLocked: boolean;
};
function TreeRow({
@@ -118,6 +120,7 @@ export function UiTreePanel({
onInsertNodeAfter,
onDeleteNode,
onMoveNode,
isLocked,
}: UiTreePanelProps) {
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
const [contextMenu, setContextMenu] = useState<{
@@ -126,17 +129,6 @@ export function UiTreePanel({
y: number;
} | null>(null);
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener('click', close);
window.addEventListener('blur', close);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('blur', close);
};
}, [contextMenu]);
useEffect(() => {
if (!focusRequest) return;
void treeApiRef.current?.scrollTo(focusRequest.nodeId, 'center');
@@ -240,46 +232,16 @@ export function UiTreePanel({
)}
</div>
{contextMenu && root && contextTreeId ? (
<div
className="fixed z-50 min-w-40 rounded-lg border border-(--platform-subpanel-border) bg-white p-1 shadow-xl"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="block w-full rounded-md px-3 py-2 text-left text-xs hover:bg-black/5"
onClick={() => {
onInsertNode(contextTreeId, contextMenu.nodeId);
setContextMenu(null);
}}
>
</button>
{!pageRootIds.has(contextMenu.nodeId) ? (
<>
<button
type="button"
className="block w-full rounded-md px-3 py-2 text-left text-xs hover:bg-black/5"
onClick={() => {
onInsertNodeAfter(contextTreeId, contextMenu.nodeId);
setContextMenu(null);
}}
>
</button>
<button
type="button"
className="block w-full rounded-md px-3 py-2 text-left text-xs text-red-700 hover:bg-red-50"
onClick={() => {
onDeleteNode(contextTreeId, contextMenu.nodeId);
setContextMenu(null);
}}
>
</button>
</>
) : null}
</div>
<UiNodeContextMenu
nodeId={contextMenu.nodeId}
position={contextMenu}
isPageRoot={pageRootIds.has(contextMenu.nodeId)}
disabled={isLocked}
onClose={() => setContextMenu(null)}
onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)}
onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)}
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)}
/>
) : null}
</section>
);
@@ -14,6 +14,7 @@ import {
} from '@genarrative/image-canvas-react';
import { Image as ImageIcon, Minus, Plus } from 'lucide-react';
import {
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
@@ -22,7 +23,9 @@ import {
useState,
} from 'react';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs';
import { findNodePageContext } from './nodeTransformGeometry';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
@@ -46,6 +49,12 @@ export function PreviewWorkspace({
const [renderMode, setRenderMode] =
useState<UiEditorRenderMode>('editor-overlay');
const [showFrame, setShowFrame] = useState(false);
const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId;
x: number;
y: number;
isPageRoot: boolean;
} | null>(null);
const tree = controller.treeForActiveImage ?? null;
const activeImagePixelWidth = activeImage?.pixel_size[0];
const activeImagePixelHeight = activeImage?.pixel_size[1];
@@ -280,6 +289,23 @@ export function PreviewWorkspace({
);
};
const handleNodeContextMenu = (
event: ReactMouseEvent<HTMLDivElement>,
node: NonNullable<typeof tree>['root'],
isPageRoot: boolean,
) => {
setContextMenu({
nodeId: node.id,
x: event.clientX,
y: event.clientY,
isPageRoot,
});
};
useEffect(() => {
if (renderMode !== 'editor-overlay') setContextMenu(null);
}, [renderMode]);
return (
<section className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--platform-body-fill)">
<header className="flex min-w-0 shrink-0 flex-wrap items-center justify-between gap-2 border-b border-(--platform-subpanel-border) px-4 py-2">
@@ -383,6 +409,7 @@ export function PreviewWorkspace({
fontFaces: controller.fontFaces,
}}
onSelectNode={controller.selectNode}
onNodeContextMenu={handleNodeContextMenu}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
@@ -394,6 +421,18 @@ export function PreviewWorkspace({
</div>
</CanvasWorld>
</SharedCanvasViewport>
{contextMenu ? (
<UiNodeContextMenu
nodeId={contextMenu.nodeId}
position={contextMenu}
isPageRoot={contextMenu.isPageRoot}
disabled={editor.isLocked}
onClose={() => setContextMenu(null)}
onInsertChild={(nodeId) => controller.insertNode(nodeId)}
onInsertSibling={(nodeId) => controller.insertNodeAfter(nodeId)}
onDelete={(nodeId) => controller.deleteNode(nodeId)}
/>
) : null}
<ZoomControls
viewport={viewport}
onFit={fitToCanvas}
@@ -1,4 +1,7 @@
import type { PointerEvent as ReactPointerEvent } from 'react';
import type {
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
} from 'react';
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
@@ -30,6 +33,11 @@ type UiTreeRendererProps = {
selectedNodeId: NodeId | null;
resources: PreviewComponentResources;
onSelectNode: (id: NodeId) => void;
onNodeContextMenu: (
event: ReactMouseEvent<HTMLDivElement>,
node: UiNode,
isPageRoot: boolean,
) => void;
onNodePointerDown: NodePointerDown;
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
@@ -69,6 +77,7 @@ function RenderNode({
selectedNodeId,
resources,
onSelectNode,
onNodeContextMenu,
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
@@ -129,6 +138,13 @@ function RenderNode({
event.stopPropagation();
onSelectNode(node.id);
}}
onContextMenu={(event) => {
if (!isEditorOverlay) return;
event.preventDefault();
event.stopPropagation();
onSelectNode(node.id);
onNodeContextMenu(event, node, Boolean(isRoot));
}}
onPointerDown={
parentContainer ? undefined : (event) => onNodePointerDown(event, node)
}
@@ -166,6 +182,7 @@ function RenderNode({
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
onNodeContextMenu={onNodeContextMenu}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
@@ -7,6 +7,7 @@ import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UITree } from '../src/features/ui-editor/types/UITree';
import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ExclusiveChildrenTabs';
import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer';
import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu';
function node(id: string, children: UiNode[] = []): UiNode {
return {
@@ -58,7 +59,11 @@ const resources = {
function renderTree(
renderMode: 'editor-overlay' | 'final-preview',
hiddenNodeIds: ReadonlySet<string>,
options: { showFrame?: boolean; selectedNodeId?: string | null } = {},
options: {
showFrame?: boolean;
selectedNodeId?: string | null;
onNodeContextMenu?: ReturnType<typeof vi.fn>;
} = {},
) {
return render(
<UiTreeRenderer
@@ -69,6 +74,7 @@ function renderTree(
selectedNodeId={options.selectedNodeId ?? null}
resources={resources}
onSelectNode={vi.fn()}
onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
@@ -81,6 +87,45 @@ function renderTree(
}
describe('UI tree preview visibility', () => {
it('shares node menu restrictions, disabled actions, and dismissal behavior', () => {
const onClose = vi.fn();
const onInsertChild = vi.fn();
const onInsertSibling = vi.fn();
const onDelete = vi.fn();
render(
<UiNodeContextMenu
nodeId="page-root"
position={{ x: window.innerWidth, y: window.innerHeight }}
isPageRoot
disabled
onClose={onClose}
onInsertChild={onInsertChild}
onInsertSibling={onInsertSibling}
onDelete={onDelete}
/>,
);
const menu = screen.getByRole('menu') as HTMLDivElement;
expect(menu.style.left).toBe(`${window.innerWidth - 184}px`);
expect(menu.style.top).toBe(`${window.innerHeight - 56}px`);
expect(
(
screen.getByRole('menuitem', {
name: '新增子节点',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.queryByRole('menuitem', { name: '新增同级节点' })).toBeNull();
expect(
screen.queryByRole('menuitem', { name: '删除节点及子节点' }),
).toBeNull();
fireEvent.click(screen.getByRole('menuitem', { name: '新增子节点' }));
expect(onInsertChild).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('maps a Godot HBoxContainer to preview CSS and disables child free-transform handles', () => {
const hboxTree: UITree = {
src_ui_design: 'page',
@@ -101,6 +146,7 @@ describe('UI tree preview visibility', () => {
selectedNodeId="first"
resources={resources}
onSelectNode={vi.fn()}
onNodeContextMenu={vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
@@ -177,6 +223,7 @@ describe('UI tree preview visibility', () => {
selectedNodeId={null}
resources={resources}
onSelectNode={vi.fn()}
onNodeContextMenu={vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
@@ -234,4 +281,47 @@ describe('UI tree preview visibility', () => {
fireEvent.click(screen.getByRole('tab', { name: 'child-b' }));
expect(onToggleChild).toHaveBeenCalledWith('child-b');
});
it('opens a context-menu target only from editor overlay nodes', () => {
const onNodeContextMenu = vi.fn();
const onSelectNode = vi.fn();
const editorOverlay = render(
<UiTreeRenderer
tree={tree}
renderMode="editor-overlay"
showFrame={false}
hiddenNodeIds={new Set()}
selectedNodeId={null}
resources={resources}
onSelectNode={onSelectNode}
onNodeContextMenu={onNodeContextMenu}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
viewportScale={1}
/>,
);
const child = editorOverlay.container.querySelector(
'[data-node-id="child"]',
) as HTMLDivElement;
fireEvent.contextMenu(child, { clientX: 32, clientY: 48 });
expect(onSelectNode).toHaveBeenCalledWith('child');
expect(onNodeContextMenu).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: 'child' }),
false,
);
editorOverlay.unmount();
const finalPreview = renderTree('final-preview', new Set(), {
onNodeContextMenu,
});
fireEvent.contextMenu(
finalPreview.container.querySelector('[data-node-id="child"]')!,
);
expect(onNodeContextMenu).toHaveBeenCalledTimes(1);
});
});
@@ -1,5 +1,11 @@
# AI 游戏创作智能体 App 实施计划
## 2026-08-19 UI Editor 节点右键菜单
左侧 `UI Tree` 与预览画布复用同一个节点右键菜单组件和条目模型,顺序固定为“新增子节点 → 新增同级节点 → 删除节点及子节点”。页面根仅显示“新增子节点”;虚拟超级根不提供菜单。预览仅在“编辑叠加”模式中响应已渲染节点的鼠标右键,右键会选中该节点并阻止嵌套节点事件冒泡;“最终预览”以及画布空白处保留原有行为,空白处不接管浏览器原生菜单。
菜单由页面级 portal 呈现,使用屏幕坐标且在视口边缘内收,避免受预览画布缩放和容器裁切影响。点击菜单外、按 Escape、窗口失焦、滚动或调整窗口大小均关闭菜单。全局 `controller.editor.isLocked` 时,节点仍可选中和查看菜单,但所有结构修改项禁用;状态层的 mutation 校验继续作为最终防线。点击可用项先关闭菜单再执行现有 controller 动作,不改变既有新增或删除后的选择策略。
## 2026-08-19 UI Editor 预览等比角点缩放
UI Editor 预览中按住 Shift 拖动四个角点时,以本次进入等比缩放时相对原尺寸变化更大的轴作为主轴(完全相等时取水平轴),并在本次指针拖动内锁定。主轴按手柄方向和位移符号决定放大或缩小;另一轴只由初始宽高比推导,对角保持固定。不得再从两个轴各自推导的候选尺寸中取较大值,否则一轴放大、另一轴缩小时会覆盖较小轴的输入,导致控制点看似朝鼠标反方向跳动。锁定后,受比例驱动的从轴可以不跟随鼠标在该轴的位移,这是等比约束的明确结果。