支持 UI 编辑器节点跨树拖动
增加 UI-only 虚拟超级节点统一展示多个界面树。 支持跨界面移动完整子树并保留原 transform。 补充跨树移动状态测试与技术方案记录。
This commit is contained in:
@@ -1155,7 +1155,8 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
|
||||
const moveNode = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
sourceTreeId: UIDesignImageId,
|
||||
targetTreeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
targetParentId: NodeId,
|
||||
targetIndex: number,
|
||||
@@ -1166,68 +1167,52 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
const sourceTree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === sourceTreeId,
|
||||
);
|
||||
const image = current.ui_design_images[treeId];
|
||||
if (!tree || !image) return { ok: false, reason: 'missing' };
|
||||
const source = findNodeLocation(tree.root, nodeId);
|
||||
const target = findNodeLocation(tree.root, targetParentId);
|
||||
if (!source || !target || source.node.id === tree.root.id) {
|
||||
const targetTree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === targetTreeId,
|
||||
);
|
||||
if (!sourceTree || !targetTree) return { ok: false, reason: 'missing' };
|
||||
const source = findNodeLocation(sourceTree.root, nodeId);
|
||||
const target = findNodeLocation(targetTree.root, targetParentId);
|
||||
if (!source || !target || source.node.id === sourceTree.root.id) {
|
||||
return { ok: false, reason: 'missing' };
|
||||
}
|
||||
if (
|
||||
source.node.id === target.node.id ||
|
||||
containsNode(source.node, targetParentId)
|
||||
sourceTreeId !== targetTreeId &&
|
||||
containsNode(targetTree.root, nodeId)
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
return { ok: false, reason: 'duplicate' };
|
||||
}
|
||||
const logicalSize: Rect = {
|
||||
min: [0, 0],
|
||||
size: [
|
||||
image.pixel_size[0] / image.pixels_per_unit,
|
||||
image.pixel_size[1] / image.pixels_per_unit,
|
||||
],
|
||||
};
|
||||
if (
|
||||
!isValidRect(logicalSize) ||
|
||||
logicalSize.size.some((value) => value <= 0)
|
||||
sourceTreeId === targetTreeId &&
|
||||
(source.node.id === target.node.id ||
|
||||
containsNode(source.node, targetParentId))
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const pageRect = findNodePageRect(tree.root, nodeId, logicalSize);
|
||||
const targetPageRect = findNodePageRect(
|
||||
tree.root,
|
||||
targetParentId,
|
||||
logicalSize,
|
||||
);
|
||||
if (
|
||||
!pageRect ||
|
||||
pageRect.size.some((value) => value <= 0) ||
|
||||
!targetPageRect ||
|
||||
targetPageRect.size.some((value) => value <= 0)
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const nextTransform = setOffsetsForPageRect(
|
||||
source.node.transform,
|
||||
pageRect,
|
||||
targetPageRect,
|
||||
);
|
||||
if (!nextTransform) return { ok: false, reason: 'invalid' };
|
||||
const next = cloneState(current);
|
||||
const nextTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
const nextSourceTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === sourceTreeId,
|
||||
)!;
|
||||
const nextSource = findNodeLocation(nextTree.root, nodeId)!;
|
||||
const nextTarget = findNodeLocation(nextTree.root, targetParentId)!.node;
|
||||
const nextTargetTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === targetTreeId,
|
||||
)!;
|
||||
const nextSource = findNodeLocation(nextSourceTree.root, nodeId)!;
|
||||
const nextTarget = findNodeLocation(
|
||||
nextTargetTree.root,
|
||||
targetParentId,
|
||||
)!.node;
|
||||
if (targetIndex > nextTarget.children.length) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const [moved] = nextSource.parent
|
||||
? nextSource.parent.children.splice(nextSource.index, 1)
|
||||
: [];
|
||||
if (!moved) return { ok: false, reason: 'invalid' };
|
||||
moved.transform = nextTransform;
|
||||
if (targetIndex > nextTarget.children.length)
|
||||
return { ok: false, reason: 'invalid' };
|
||||
// TODO: Cross-tree moves intentionally preserve the local transform.
|
||||
// Coordinate conversion between design images can be added separately.
|
||||
nextTarget.children.splice(targetIndex, 0, moved);
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { Image as ImageIcon, Plus, ScanSearch } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UiEditorPageController } from '../useUiEditorPage';
|
||||
import { ImportOverview } from './ImportOverview';
|
||||
import { UiTreePanel } from './UiTreePanel';
|
||||
|
||||
const SUPER_ROOT_ID = '__ui-editor-super-root__';
|
||||
|
||||
function visitTreeNodes(
|
||||
node: UiNode,
|
||||
treeId: UIDesignImageId,
|
||||
result: Map<NodeId, UIDesignImageId>,
|
||||
) {
|
||||
result.set(node.id, treeId);
|
||||
for (const child of node.children) visitTreeNodes(child, treeId, result);
|
||||
}
|
||||
|
||||
export function InputSidebar({
|
||||
controller,
|
||||
}: {
|
||||
@@ -19,11 +34,42 @@ export function InputSidebar({
|
||||
images,
|
||||
sprites,
|
||||
spriteReferenceCounts,
|
||||
treeForActiveImage,
|
||||
selectedNodeId,
|
||||
focusRequest,
|
||||
} = controller;
|
||||
|
||||
const treeIdByNodeId = useMemo(() => {
|
||||
const result = new Map<NodeId, UIDesignImageId>();
|
||||
for (const tree of editor.state.ui_trees) {
|
||||
visitTreeNodes(tree.root, tree.src_ui_design, result);
|
||||
}
|
||||
return result;
|
||||
}, [editor.state.ui_trees]);
|
||||
|
||||
const superRoot = useMemo<UiNode | null>(() => {
|
||||
if (editor.state.ui_trees.length === 0) return null;
|
||||
return {
|
||||
id: SUPER_ROOT_ID,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
metadata: {
|
||||
name: 'UI Trees',
|
||||
description: '',
|
||||
layout_status: 'Passed',
|
||||
components_status: 'Passed',
|
||||
allow_llm_edit_layout: false,
|
||||
allow_llm_edit_component: false,
|
||||
source: 'System',
|
||||
},
|
||||
components: [],
|
||||
children: editor.state.ui_trees.map((tree) => tree.root),
|
||||
};
|
||||
}, [editor.state.ui_trees]);
|
||||
|
||||
return (
|
||||
<aside className="h-full min-h-0 overflow-y-auto border-r border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3">
|
||||
<div className="mb-3 grid gap-2">
|
||||
@@ -211,24 +257,33 @@ export function InputSidebar({
|
||||
/>
|
||||
|
||||
<UiTreePanel
|
||||
treeId={activeImageId}
|
||||
root={treeForActiveImage?.root ?? null}
|
||||
root={superRoot}
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusRequest={focusRequest}
|
||||
onSelectNode={controller.selectNode}
|
||||
onInsertNode={(parentId) => {
|
||||
controller.insertNode(parentId);
|
||||
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
||||
onSelectNode={(treeId, nodeId) => {
|
||||
controller.selectDesignImage(treeId);
|
||||
controller.selectNode(nodeId);
|
||||
}}
|
||||
onInsertNodeAfter={(nodeId) => {
|
||||
controller.insertNodeAfter(nodeId);
|
||||
onInsertNode={(treeId, parentId) => {
|
||||
controller.insertNode(parentId, treeId);
|
||||
}}
|
||||
onDeleteNode={(nodeId) => {
|
||||
controller.deleteNode(nodeId);
|
||||
onInsertNodeAfter={(treeId, nodeId) => {
|
||||
controller.insertNodeAfter(nodeId, treeId);
|
||||
}}
|
||||
onMoveNode={(nodeId, targetParentId, targetIndex) => {
|
||||
if (!activeImageId) return;
|
||||
onDeleteNode={(treeId, nodeId) => {
|
||||
controller.deleteNode(nodeId, treeId);
|
||||
}}
|
||||
onMoveNode={(
|
||||
sourceTreeId,
|
||||
targetTreeId,
|
||||
nodeId,
|
||||
targetParentId,
|
||||
targetIndex,
|
||||
) => {
|
||||
controller.moveNode(
|
||||
activeImageId,
|
||||
sourceTreeId,
|
||||
targetTreeId,
|
||||
nodeId,
|
||||
targetParentId,
|
||||
targetIndex,
|
||||
|
||||
@@ -19,15 +19,17 @@ import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesign
|
||||
import type { UiEditorNodeFocusRequest } from '../model';
|
||||
|
||||
type UiTreePanelProps = {
|
||||
treeId: UIDesignImageId | null;
|
||||
root: UiNode | null;
|
||||
selectedNodeId: NodeId | null;
|
||||
focusRequest: UiEditorNodeFocusRequest | null;
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
onInsertNode: (parentId: NodeId) => void;
|
||||
onInsertNodeAfter: (nodeId: NodeId) => void;
|
||||
onDeleteNode: (nodeId: NodeId) => void;
|
||||
treeIdForNode: (nodeId: NodeId) => UIDesignImageId | null;
|
||||
onSelectNode: (treeId: UIDesignImageId, id: NodeId) => void;
|
||||
onInsertNode: (treeId: UIDesignImageId, parentId: NodeId) => void;
|
||||
onInsertNodeAfter: (treeId: UIDesignImageId, nodeId: NodeId) => void;
|
||||
onDeleteNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
|
||||
onMoveNode: (
|
||||
sourceTreeId: UIDesignImageId,
|
||||
targetTreeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
targetParentId: NodeId,
|
||||
targetIndex: number,
|
||||
@@ -91,10 +93,10 @@ function TreeRow({
|
||||
}
|
||||
|
||||
export function UiTreePanel({
|
||||
treeId,
|
||||
root,
|
||||
selectedNodeId,
|
||||
focusRequest,
|
||||
treeIdForNode,
|
||||
onSelectNode,
|
||||
onInsertNode,
|
||||
onInsertNodeAfter,
|
||||
@@ -120,9 +122,9 @@ export function UiTreePanel({
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest || focusRequest.treeId !== treeId) return;
|
||||
if (!focusRequest) return;
|
||||
void treeApiRef.current?.scrollTo(focusRequest.nodeId, 'center');
|
||||
}, [focusRequest, treeId]);
|
||||
}, [focusRequest]);
|
||||
|
||||
const handleMove: MoveHandler<UiNode> = ({
|
||||
dragIds,
|
||||
@@ -130,11 +132,14 @@ export function UiTreePanel({
|
||||
parentNode,
|
||||
index,
|
||||
}) => {
|
||||
if (!treeId || !root || dragIds.length !== 1 || !parentId || !parentNode) {
|
||||
if (!root || dragIds.length !== 1 || !parentId || !parentNode) {
|
||||
return;
|
||||
}
|
||||
const [nodeId] = dragIds;
|
||||
if (!nodeId || nodeId === root.id) return;
|
||||
const sourceTreeId = treeIdForNode(nodeId);
|
||||
const targetTreeId = treeIdForNode(parentId);
|
||||
if (!sourceTreeId || !targetTreeId) return;
|
||||
const destinationIds = (parentNode.children ?? []).map(
|
||||
(child) => child.data.id,
|
||||
);
|
||||
@@ -143,9 +148,12 @@ export function UiTreePanel({
|
||||
dragIds,
|
||||
siblingIds: destinationIds,
|
||||
});
|
||||
onMoveNode(nodeId, parentId, targetIndex);
|
||||
onMoveNode(sourceTreeId, targetTreeId, nodeId, parentId, targetIndex);
|
||||
};
|
||||
|
||||
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
|
||||
const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3"
|
||||
@@ -168,22 +176,33 @@ export function UiTreePanel({
|
||||
selection={selectedNodeId ?? undefined}
|
||||
onSelect={(nodes) => {
|
||||
const selected = nodes[0]?.data;
|
||||
if (selected) onSelectNode(selected.id);
|
||||
if (selected) {
|
||||
const treeId = treeIdForNode(selected.id);
|
||||
if (treeId) onSelectNode(treeId, selected.id);
|
||||
}
|
||||
}}
|
||||
onMove={handleMove}
|
||||
disableDrag={(node) => node.id === root.id}
|
||||
disableDrag={(node) =>
|
||||
node.id === root.id || pageRootIds.has(node.id)
|
||||
}
|
||||
disableDrop={({ parentNode }) => parentNode.id === root.id}
|
||||
aria-label="UI 节点树"
|
||||
>
|
||||
{(props) => (
|
||||
<TreeRow
|
||||
{...props}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectNode={(nodeId) => {
|
||||
const treeId = treeIdForNode(nodeId);
|
||||
if (treeId) onSelectNode(treeId, nodeId);
|
||||
}}
|
||||
onOpenContextMenu={(event, nodeId) =>
|
||||
setContextMenu({
|
||||
nodeId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
nodeId === root.id
|
||||
? undefined
|
||||
: setContextMenu({
|
||||
nodeId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -194,7 +213,7 @@ export function UiTreePanel({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{contextMenu && root ? (
|
||||
{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 }}
|
||||
@@ -204,19 +223,19 @@ export function UiTreePanel({
|
||||
type="button"
|
||||
className="block w-full rounded-md px-3 py-2 text-left text-xs hover:bg-black/5"
|
||||
onClick={() => {
|
||||
onInsertNode(contextMenu.nodeId);
|
||||
onInsertNode(contextTreeId, contextMenu.nodeId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
新增子节点
|
||||
</button>
|
||||
{contextMenu.nodeId !== root.id ? (
|
||||
{!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(contextMenu.nodeId);
|
||||
onInsertNodeAfter(contextTreeId, contextMenu.nodeId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
@@ -226,7 +245,7 @@ export function UiTreePanel({
|
||||
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(contextMenu.nodeId);
|
||||
onDeleteNode(contextTreeId, contextMenu.nodeId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -585,12 +585,19 @@ export function useUiEditorPage(
|
||||
}
|
||||
|
||||
function moveNode(
|
||||
treeId: UIDesignImageId,
|
||||
sourceTreeId: UIDesignImageId,
|
||||
targetTreeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
targetParentId: NodeId,
|
||||
targetIndex: number,
|
||||
) {
|
||||
const result = editor.moveNode(treeId, nodeId, targetParentId, targetIndex);
|
||||
const result = editor.moveNode(
|
||||
sourceTreeId,
|
||||
targetTreeId,
|
||||
nodeId,
|
||||
targetParentId,
|
||||
targetIndex,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setStatus(
|
||||
result.reason === 'locked'
|
||||
@@ -603,9 +610,9 @@ export function useUiEditorPage(
|
||||
return result;
|
||||
}
|
||||
|
||||
function insertNode(parentId: NodeId) {
|
||||
if (!activeImageId) return;
|
||||
const result = editor.insertNode(activeImageId, parentId);
|
||||
function insertNode(parentId: NodeId, treeId = activeImageId) {
|
||||
if (!treeId) return;
|
||||
const result = editor.insertNode(treeId, parentId);
|
||||
if (!result.ok) {
|
||||
setStatus('无法新增节点。');
|
||||
return result;
|
||||
@@ -614,9 +621,9 @@ export function useUiEditorPage(
|
||||
return result;
|
||||
}
|
||||
|
||||
function insertNodeAfter(nodeId: NodeId) {
|
||||
if (!activeImageId) return;
|
||||
const result = editor.insertNodeAfter(activeImageId, nodeId);
|
||||
function insertNodeAfter(nodeId: NodeId, treeId = activeImageId) {
|
||||
if (!treeId) return;
|
||||
const result = editor.insertNodeAfter(treeId, nodeId);
|
||||
if (!result.ok) {
|
||||
setStatus('无法新增同级节点。');
|
||||
return result;
|
||||
@@ -625,9 +632,9 @@ export function useUiEditorPage(
|
||||
return result;
|
||||
}
|
||||
|
||||
function deleteNode(nodeId: NodeId) {
|
||||
if (!activeImageId) return;
|
||||
const result = editor.deleteNode(activeImageId, nodeId);
|
||||
function deleteNode(nodeId: NodeId, treeId = activeImageId) {
|
||||
if (!treeId) return;
|
||||
const result = editor.deleteNode(treeId, nodeId);
|
||||
if (!result.ok) {
|
||||
setStatus('无法删除该节点。');
|
||||
return result;
|
||||
|
||||
@@ -80,7 +80,59 @@ function nodeWithSprite(id: string): Node {
|
||||
};
|
||||
}
|
||||
|
||||
function pageRoot(id: string, children: Node[] = []): Node {
|
||||
return {
|
||||
id,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
metadata: {
|
||||
name: id,
|
||||
description: '',
|
||||
layout_status: 'Passed',
|
||||
components_status: 'Pending',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'System',
|
||||
},
|
||||
components: [],
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useUiEditorState', () => {
|
||||
it('moves a subtree between UI trees without changing its transform', () => {
|
||||
const moved = nodeWithSprite('moved');
|
||||
const initial: State = {
|
||||
...structuredClone(EMPTY_UI_EDITOR_STATE),
|
||||
ui_design_images: {
|
||||
source: image('Source'),
|
||||
target: image('Target'),
|
||||
},
|
||||
ui_trees: [
|
||||
{ src_ui_design: 'source', root: pageRoot('source-root', [moved]) },
|
||||
{ src_ui_design: 'target', root: pageRoot('target-root') },
|
||||
],
|
||||
};
|
||||
const { result } = renderHook(() => useUiEditorState(initial));
|
||||
const originalTransform = structuredClone(moved.transform);
|
||||
|
||||
act(() => {
|
||||
expect(
|
||||
result.current.moveNode('source', 'target', 'moved', 'target-root', 0),
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
expect(result.current.state.ui_trees[0]?.root.children).toEqual([]);
|
||||
expect(result.current.state.ui_trees[1]?.root.children[0]).toMatchObject({
|
||||
id: 'moved',
|
||||
transform: originalTransform,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes focused operations over one shared state', () => {
|
||||
const { result } = renderHook(() => useUiEditorState());
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# AI 游戏创作智能体 App 实施计划
|
||||
|
||||
## 2026-08-18 UI Editor 左侧节点树跨界面移动
|
||||
|
||||
左侧 UI 节点树使用 UI-only 的虚拟超级节点统一承载现有 `ui_trees` 的页面根节点,仅改变树视图,不写入 State 或持久化契约。页面根节点和超级节点不可拖动;普通节点可在不同页面根节点之间拖动,整个子树随节点移动并保留原 `transform`。移动不会自动切换当前 active image;后续如需跨界面坐标换算,另行设计。
|
||||
|
||||
## 2026-08-17 UI Editor 项目字体导入与预览
|
||||
|
||||
UI Editor 的 `State.font_assets` 正式承载项目字体面资源;`FontAsset` 包含项目资产 ID、受控项目相对路径、内容 SHA-256,以及由 Rust 解析的 family、face、weight、italic、格式和源文件名。`TextComponent.font` 直接绑定一个具体字体面;原 `font_style` 是与字体面元数据重复且可能矛盾的状态,直接从 Rust 权威类型和生成 TypeScript 类型中删除,不保留会话态兼容层。UI Editor 整体 State 仍只属于当前桌面会话,不新增持久化合同。
|
||||
|
||||
Reference in New Issue
Block a user