修复 UI 节点隐藏状态与移动语义

将隐藏状态收束为页面会话级 Set<NodeId》。

抽取节点移动请求 DTO 与树遍历 helper。

恢复同树重挂的页面位置稳定换算并补充回归测试。
This commit is contained in:
2026-08-18 15:17:02 +08:00
parent 26f4f61c0d
commit 847353e4f0
8 changed files with 206 additions and 174 deletions
@@ -0,0 +1,16 @@
import type { Node as UiNode } from './types/Node';
import type { NodeId } from './types/NodeId';
export function visitUiNodes(
root: UiNode,
visit: (node: UiNode) => void,
): void {
visit(root);
for (const child of root.children) visitUiNodes(child, visit);
}
export function collectUiNodeIds(root: UiNode): Set<NodeId> {
const ids = new Set<NodeId>();
visitUiNodes(root, (node) => ids.add(node.id));
return ids;
}
@@ -0,0 +1,11 @@
import type { NodeId } from './NodeId';
import type { UIDesignImageId } from './UIDesignImageId';
/** UI 节点移动的完整上下文,避免在树视图与状态层之间散落五个并行参数。 */
export type UiNodeMoveRequest = {
sourceTreeId: UIDesignImageId;
targetTreeId: UIDesignImageId;
nodeId: NodeId;
targetParentId: NodeId;
targetIndex: number;
};
@@ -14,6 +14,7 @@ import type { State } from './types/State';
import type { UIDesignImage } from './types/UIDesignImage';
import type { UIDesignImageId } from './types/UIDesignImageId';
import type { UIDesignImageRole } from './types/UIDesignImageRole';
import type { UiNodeMoveRequest } from './types/UiNodeMoveRequest';
export const EMPTY_UI_EDITOR_STATE: State = {
ui_trees: [],
@@ -1151,13 +1152,13 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
);
const moveNode = useCallback(
(
sourceTreeId: UIDesignImageId,
targetTreeId: UIDesignImageId,
nodeId: NodeId,
targetParentId: NodeId,
targetIndex: number,
): UiEditorOperationResult => {
({
sourceTreeId,
targetTreeId,
nodeId,
targetParentId,
targetIndex,
}: UiNodeMoveRequest): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
if (!Number.isInteger(targetIndex) || targetIndex < 0) {
@@ -1189,6 +1190,44 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
) {
return { ok: false, reason: 'invalid' };
}
let nextTransform: Node['transform'] | null = null;
if (sourceTreeId === targetTreeId) {
const image = current.ui_design_images[sourceTreeId];
if (!image) return { ok: false, reason: 'missing' };
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)
) {
return { ok: false, reason: 'invalid' };
}
const pageRect = findNodePageRect(sourceTree.root, nodeId, logicalSize);
const targetPageRect = findNodePageRect(
targetTree.root,
targetParentId,
logicalSize,
);
if (
!pageRect ||
pageRect.size.some((value) => value <= 0) ||
!targetPageRect ||
targetPageRect.size.some((value) => value <= 0)
) {
return { ok: false, reason: 'invalid' };
}
nextTransform = setOffsetsForPageRect(
source.node.transform,
pageRect,
targetPageRect,
);
if (!nextTransform) return { ok: false, reason: 'invalid' };
}
const next = cloneState(current);
const nextSourceTree = next.ui_trees.find(
(candidate) => candidate.src_ui_design === sourceTreeId,
@@ -1208,8 +1247,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
? nextSource.parent.children.splice(nextSource.index, 1)
: [];
if (!moved) return { ok: false, reason: 'invalid' };
// TODO: Cross-tree moves intentionally preserve the local transform.
// Coordinate conversion between design images can be added separately.
// 跨树时只保留节点原本的局部 transform;源、目标界面图的画布和父级坐标空间独立,
// “保留 transform”不等于承诺页面位置稳定。只有同树移动才在上面做页面矩形换算。
if (nextTransform) moved.transform = nextTransform;
nextTarget.children.splice(targetIndex, 0, moved);
commit(next);
return { ok: true, value: undefined };
@@ -1,6 +1,7 @@
import { Image as ImageIcon, Plus, ScanSearch, Type } from 'lucide-react';
import { useMemo } from 'react';
import { visitUiNodes } from '../../../features/ui-editor/treeUtils';
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';
@@ -10,15 +11,6 @@ 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,
}: {
@@ -44,21 +36,13 @@ export function InputSidebar({
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);
visitUiNodes(tree.root, (node) =>
result.set(node.id, tree.src_ui_design),
);
}
return result;
}, [editor.state.ui_trees]);
const hiddenNodeIdsByTree = useMemo(() => {
const result = new Map<UIDesignImageId, ReadonlySet<NodeId>>();
for (const [treeId, hiddenNodeIds] of Object.entries(
controller.hiddenNodeIdsByImage,
)) {
result.set(treeId, new Set(Object.keys(hiddenNodeIds)));
}
return result;
}, [controller.hiddenNodeIdsByImage]);
const superRoot = useMemo<UiNode | null>(() => {
if (editor.state.ui_trees.length === 0) return null;
return {
@@ -330,15 +314,13 @@ export function InputSidebar({
selectedNodeId={selectedNodeId}
focusRequest={focusRequest}
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
hiddenNodeIdsForTree={(treeId) =>
hiddenNodeIdsByTree.get(treeId) ?? new Set<NodeId>()
}
hiddenNodeIds={controller.hiddenNodeIds}
onSelectNode={(treeId, nodeId) => {
controller.selectDesignImage(treeId);
controller.selectNode(nodeId);
}}
onToggleNodeVisibility={(treeId, nodeId) =>
controller.toggleNodePreviewVisibility(nodeId, treeId)
onToggleNodeVisibility={(nodeId) =>
controller.toggleNodePreviewVisibility(nodeId)
}
onInsertNode={(treeId, parentId) => {
controller.insertNode(parentId, treeId);
@@ -349,21 +331,7 @@ export function InputSidebar({
onDeleteNode={(treeId, nodeId) => {
controller.deleteNode(nodeId, treeId);
}}
onMoveNode={(
sourceTreeId,
targetTreeId,
nodeId,
targetParentId,
targetIndex,
) => {
controller.moveNode(
sourceTreeId,
targetTreeId,
nodeId,
targetParentId,
targetIndex,
);
}}
onMoveNode={(request) => controller.moveNode(request)}
/>
</aside>
);
@@ -16,28 +16,21 @@ import {
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 { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
import type { UiEditorNodeFocusRequest } from '../model';
const EMPTY_HIDDEN_NODE_IDS = new Set<NodeId>();
type UiTreePanelProps = {
root: UiNode | null;
selectedNodeId: NodeId | null;
focusRequest: UiEditorNodeFocusRequest | null;
treeIdForNode: (nodeId: NodeId) => UIDesignImageId | null;
hiddenNodeIdsForTree: (treeId: UIDesignImageId) => ReadonlySet<NodeId>;
hiddenNodeIds: ReadonlySet<NodeId>;
onSelectNode: (treeId: UIDesignImageId, id: NodeId) => void;
onToggleNodeVisibility: (treeId: UIDesignImageId, id: NodeId) => void;
onToggleNodeVisibility: (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,
) => void;
onMoveNode: (request: UiNodeMoveRequest) => void;
};
function TreeRow({
@@ -118,7 +111,7 @@ export function UiTreePanel({
selectedNodeId,
focusRequest,
treeIdForNode,
hiddenNodeIdsForTree,
hiddenNodeIds,
onSelectNode,
onToggleNodeVisibility,
onInsertNode,
@@ -171,7 +164,13 @@ export function UiTreePanel({
dragIds,
siblingIds: destinationIds,
});
onMoveNode(sourceTreeId, targetTreeId, nodeId, parentId, targetIndex);
onMoveNode({
sourceTreeId,
targetTreeId,
nodeId,
targetParentId: parentId,
targetIndex,
});
};
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
@@ -212,7 +211,6 @@ export function UiTreePanel({
aria-label="UI 节点树"
>
{(props) => {
const treeId = treeIdForNode(props.node.data.id);
return (
<TreeRow
{...props}
@@ -220,15 +218,8 @@ export function UiTreePanel({
const nodeTreeId = treeIdForNode(nodeId);
if (nodeTreeId) onSelectNode(nodeTreeId, nodeId);
}}
hiddenNodeIds={
treeId
? hiddenNodeIdsForTree(treeId)
: EMPTY_HIDDEN_NODE_IDS
}
onToggleNodeVisibility={(nodeId) => {
const nodeTreeId = treeIdForNode(nodeId);
if (nodeTreeId) onToggleNodeVisibility(nodeTreeId, nodeId);
}}
hiddenNodeIds={hiddenNodeIds}
onToggleNodeVisibility={onToggleNodeVisibility}
onOpenContextMenu={(event, nodeId) =>
nodeId === root.id
? undefined
@@ -19,6 +19,7 @@ import {
validateVisualBindingResult,
} from '../../features/ui-editor/prerequisites';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { Component } from '../../features/ui-editor/types/Component';
import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId';
@@ -31,6 +32,7 @@ import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
import type { UIDesignSuggestionTreeNode } from '../../features/ui-editor/types/UIDesignSuggestionTreeNode';
import type { UiNodeMoveRequest } from '../../features/ui-editor/types/UiNodeMoveRequest';
import {
mockUiDesignStateStore,
type UiDesignStateStore,
@@ -58,18 +60,6 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
type HiddenNodeIdsByImage = Record<UIDesignImageId, Record<NodeId, true>>;
function collectNodeIds(root: UiNode): Set<NodeId> {
const ids = new Set<NodeId>();
const visit = (node: UiNode) => {
ids.add(node.id);
for (const child of node.children) visit(child);
};
visit(root);
return ids;
}
export type PendingWorkflowStepChange = {
from: UiEditorStepId;
to: UiEditorStepId;
@@ -102,8 +92,10 @@ export function useUiEditorPage(
null,
);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const [hiddenNodeIdsByImage, setHiddenNodeIdsByImage] =
useState<HiddenNodeIdsByImage>({});
// NodeId 在 State 内全局唯一;隐藏状态直接记录节点 ID,跨树移动时随节点保留。
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<NodeId>>(
() => new Set(),
);
const { focusRequest, focusNode } = useUiEditorNodeFocus({
activateImage: setActiveImageId,
clearSpriteSelection: () => setSelectedSpriteId(null),
@@ -137,7 +129,7 @@ export function useUiEditorPage(
setIsLoading(false);
setLoadError(null);
setPersistedRevision(0);
setHiddenNodeIdsByImage({});
setHiddenNodeIds(new Set());
return;
}
let cancelled = false;
@@ -156,7 +148,7 @@ export function useUiEditorPage(
setActiveImageId(ids[0] ?? null);
setSelectedNodeId(null);
setSelectedSpriteId(null);
setHiddenNodeIdsByImage({});
setHiddenNodeIds(new Set());
setPreviewUrls({});
setPersistedRevision(revision);
if (projectPath) {
@@ -277,83 +269,44 @@ export function useUiEditorPage(
: null;
useEffect(() => {
setHiddenNodeIdsByImage((current) => {
let changed = false;
const next: HiddenNodeIdsByImage = {};
for (const [treeId, hiddenNodeIds] of Object.entries(current)) {
const tree = editor.state.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) {
changed = true;
continue;
}
const validNodeIds = collectNodeIds(tree.root);
const filtered = Object.fromEntries(
Object.keys(hiddenNodeIds)
.filter((nodeId) => validNodeIds.has(nodeId))
.map((nodeId) => [nodeId, true]),
) as Record<NodeId, true>;
if (
Object.keys(filtered).length !== Object.keys(hiddenNodeIds).length
) {
changed = true;
}
if (Object.keys(filtered).length > 0) {
next[treeId as UIDesignImageId] = filtered;
}
const validNodeIds = new Set<NodeId>();
for (const tree of editor.state.ui_trees) {
for (const nodeId of collectUiNodeIds(tree.root)) {
validNodeIds.add(nodeId);
}
return changed ? next : current;
}
setHiddenNodeIds((current) => {
const next = new Set(
[...current].filter((nodeId) => validNodeIds.has(nodeId)),
);
return next.size === current.size ? current : next;
});
}, [editor.state.ui_trees]);
const hiddenNodeIds = useMemo<ReadonlySet<NodeId>>(
() =>
new Set(
activeImageId
? Object.keys(hiddenNodeIdsByImage[activeImageId] ?? {})
: [],
),
[activeImageId, hiddenNodeIdsByImage],
);
const isNodePreviewVisible = useCallback(
(nodeId: NodeId, treeId = activeImageId) =>
!(treeId ? hiddenNodeIdsByImage[treeId]?.[nodeId] : false),
[activeImageId, hiddenNodeIdsByImage],
(nodeId: NodeId) => !hiddenNodeIds.has(nodeId),
[hiddenNodeIds],
);
const setNodePreviewVisible = useCallback(
(nodeId: NodeId, visible: boolean, treeId = activeImageId) => {
if (!treeId) return;
setHiddenNodeIdsByImage((current) => {
const currentForImage = current[treeId] ?? {};
const currentlyVisible = !currentForImage[nodeId];
(nodeId: NodeId, visible: boolean) => {
setHiddenNodeIds((current) => {
const currentlyVisible = !current.has(nodeId);
if (currentlyVisible === visible) return current;
const nextForImage = { ...currentForImage };
if (visible) delete nextForImage[nodeId];
else nextForImage[nodeId] = true;
const next = { ...current };
if (Object.keys(nextForImage).length > 0) {
next[treeId] = nextForImage;
} else {
delete next[treeId];
}
const next = new Set(current);
if (visible) next.delete(nodeId);
else next.add(nodeId);
return next;
});
},
[activeImageId],
[],
);
const toggleNodePreviewVisibility = useCallback(
(nodeId: NodeId, treeId = activeImageId) => {
setNodePreviewVisible(
nodeId,
!isNodePreviewVisible(nodeId, treeId),
treeId,
);
(nodeId: NodeId) => {
setNodePreviewVisible(nodeId, !isNodePreviewVisible(nodeId));
},
[activeImageId, isNodePreviewVisible, setNodePreviewVisible],
[isNodePreviewVisible, setNodePreviewVisible],
);
const findNodeContext = useCallback(function findNodeContext(
@@ -460,12 +413,6 @@ export function useUiEditorPage(
if (id === selectedSpriteId) setSelectedSpriteId(null);
if (id === selectedFontId) setSelectedFontId(null);
if (id !== activeImageId) return;
setHiddenNodeIdsByImage((current) => {
if (!(id in current)) return current;
const next = { ...current };
delete next[id as UIDesignImageId];
return next;
});
setSelectedNodeId(null);
const index = imageOrder.indexOf(id);
const remaining = imageOrder.filter((item) => item !== id);
@@ -598,7 +545,7 @@ export function useUiEditorPage(
setActiveImageId(null);
setSelectedSpriteId(null);
setSelectedNodeId(null);
setHiddenNodeIdsByImage({});
setHiddenNodeIds(new Set());
setPreviewUrls({});
setClearOpen(false);
}
@@ -687,20 +634,8 @@ export function useUiEditorPage(
return result;
}
function moveNode(
sourceTreeId: UIDesignImageId,
targetTreeId: UIDesignImageId,
nodeId: NodeId,
targetParentId: NodeId,
targetIndex: number,
) {
const result = editor.moveNode(
sourceTreeId,
targetTreeId,
nodeId,
targetParentId,
targetIndex,
);
function moveNode(request: UiNodeMoveRequest) {
const result = editor.moveNode(request);
if (!result.ok) {
setStatus(
result.reason === 'locked'
@@ -928,7 +863,6 @@ export function useUiEditorPage(
activeImage,
selectedSprite,
selectedFont,
hiddenNodeIdsByImage,
hiddenNodeIds,
isNodePreviewVisible,
setNodePreviewVisible,
@@ -158,7 +158,7 @@ describe('UiEditorPage', () => {
expect(result.current.selectedSpriteId).toBe('panel');
});
it('keeps node preview visibility transient and scoped to each interface image', () => {
it('keeps node preview visibility transient and attached to global node IDs', () => {
const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor'));
act(() => {
result.current.editor.addDesignImages([
@@ -209,7 +209,7 @@ describe('UiEditorPage', () => {
expect(JSON.stringify(result.current.editor.state)).toBe(stateBeforeToggle);
act(() => result.current.selectDesignImage('page-b'));
expect(result.current.hiddenNodeIds.size).toBe(0);
expect(result.current.hiddenNodeIds.has(rootId)).toBe(true);
act(() => result.current.selectDesignImage('page-a'));
expect(result.current.hiddenNodeIds.has(rootId)).toBe(true);
@@ -122,7 +122,13 @@ describe('useUiEditorState', () => {
act(() => {
expect(
result.current.moveNode('source', 'target', 'moved', 'target-root', 0),
result.current.moveNode({
sourceTreeId: 'source',
targetTreeId: 'target',
nodeId: 'moved',
targetParentId: 'target-root',
targetIndex: 0,
}),
).toEqual({ ok: true, value: undefined });
});
@@ -133,6 +139,72 @@ describe('useUiEditorState', () => {
});
});
it('keeps page position stable when reparenting within one UI tree', () => {
const moved = nodeWithSprite('moved');
moved.transform = {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
};
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
ui_trees: [
{
src_ui_design: 'page',
root: pageRoot('page-root', [
{
...pageRoot('source-parent'),
transform: {
anchor_min: [0, 0],
anchor_max: [0.5, 0.5],
offset_min: [0, 0],
offset_max: [0, 0],
},
children: [moved],
},
{
...pageRoot('target-parent'),
transform: {
anchor_min: [0.5, 0.5],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
},
]),
},
],
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
expect(
result.current.moveNode({
sourceTreeId: 'page',
targetTreeId: 'page',
nodeId: 'moved',
targetParentId: 'target-parent',
targetIndex: 0,
}),
).toEqual({ ok: true, value: undefined });
});
expect(
result.current.state.ui_trees[0]?.root.children[0]?.children,
).toEqual([]);
expect(
result.current.state.ui_trees[0]?.root.children[1]?.children[0]
?.transform,
).toMatchObject({
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [-50, -40],
offset_max: [-50, -40],
});
});
it('exposes focused operations over one shared state', () => {
const { result } = renderHook(() => useUiEditorState());