复用 UI 节点右键菜单
抽取页面级节点右键菜单并接入 UI Tree 在编辑叠加预览中支持节点右键操作 补充菜单交互测试与技术方案约定
This commit is contained in:
@@ -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}
|
||||
|
||||
+18
-1
@@ -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 拖动四个角点时,以本次进入等比缩放时相对原尺寸变化更大的轴作为主轴(完全相等时取水平轴),并在本次指针拖动内锁定。主轴按手柄方向和位移符号决定放大或缩小;另一轴只由初始宽高比推导,对角保持固定。不得再从两个轴各自推导的候选尺寸中取较大值,否则一轴放大、另一轴缩小时会覆盖较小轴的输入,导致控制点看似朝鼠标反方向跳动。锁定后,受比例驱动的从轴可以不跟随鼠标在该轴的位移,这是等比约束的明确结果。
|
||||
|
||||
Reference in New Issue
Block a user