增加 UI 编辑器节点预览可见性
新增按界面图隔离的临时节点显示状态 接入 UI Tree 眼睛按钮与 Inspector 显示复选框 让两种预览模式按子树隐藏节点并清理失效状态 补充可见性测试与技术方案记录
This commit is contained in:
@@ -46,6 +46,16 @@ export function InputSidebar({
|
||||
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 {
|
||||
@@ -261,10 +271,16 @@ export function InputSidebar({
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusRequest={focusRequest}
|
||||
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
||||
hiddenNodeIdsForTree={(treeId) =>
|
||||
hiddenNodeIdsByTree.get(treeId) ?? new Set<NodeId>()
|
||||
}
|
||||
onSelectNode={(treeId, nodeId) => {
|
||||
controller.selectDesignImage(treeId);
|
||||
controller.selectNode(nodeId);
|
||||
}}
|
||||
onToggleNodeVisibility={(treeId, nodeId) =>
|
||||
controller.toggleNodePreviewVisibility(nodeId, treeId)
|
||||
}
|
||||
onInsertNode={(treeId, parentId) => {
|
||||
controller.insertNode(parentId, treeId);
|
||||
}}
|
||||
|
||||
+16
@@ -78,6 +78,10 @@ export function InspectorSidebar({
|
||||
node={view.node}
|
||||
parentSize={view.parentSize}
|
||||
readOnly={controller.editor.isLocked}
|
||||
isNodePreviewVisible={controller.isNodePreviewVisible(view.node.id)}
|
||||
onSetNodePreviewVisible={(visible) =>
|
||||
controller.setNodePreviewVisible(view.node.id, visible)
|
||||
}
|
||||
transformReadOnly={
|
||||
controller.editor.isLocked ||
|
||||
view.node.id === controller.treeForActiveImage?.root.id
|
||||
@@ -222,6 +226,8 @@ function NodeInspector({
|
||||
node,
|
||||
parentSize,
|
||||
readOnly,
|
||||
isNodePreviewVisible,
|
||||
onSetNodePreviewVisible,
|
||||
transformReadOnly,
|
||||
keepChildrenUnchanged,
|
||||
onKeepChildrenUnchangedChange,
|
||||
@@ -239,6 +245,8 @@ function NodeInspector({
|
||||
node: SelectedNode;
|
||||
parentSize: UiEditorPageController['selectedNodeParentSize'];
|
||||
readOnly: boolean;
|
||||
isNodePreviewVisible: boolean;
|
||||
onSetNodePreviewVisible: (visible: boolean) => void;
|
||||
transformReadOnly: boolean;
|
||||
keepChildrenUnchanged: boolean;
|
||||
onKeepChildrenUnchangedChange: (value: boolean) => void;
|
||||
@@ -255,6 +263,14 @@ function NodeInspector({
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<label className="flex items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isNodePreviewVisible}
|
||||
onChange={(event) => onSetNodePreviewVisible(event.target.checked)}
|
||||
/>
|
||||
在预览中显示
|
||||
</label>
|
||||
<div
|
||||
className="flex items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 p-0.5 text-[10px]"
|
||||
role="group"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, Layers3 } from 'lucide-react';
|
||||
import { ChevronDown, Eye, EyeOff, Layers3 } from 'lucide-react';
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
@@ -18,12 +18,16 @@ import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
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>;
|
||||
onSelectNode: (treeId: UIDesignImageId, id: NodeId) => void;
|
||||
onToggleNodeVisibility: (treeId: UIDesignImageId, id: NodeId) => void;
|
||||
onInsertNode: (treeId: UIDesignImageId, parentId: NodeId) => void;
|
||||
onInsertNodeAfter: (treeId: UIDesignImageId, nodeId: NodeId) => void;
|
||||
onDeleteNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
|
||||
@@ -41,19 +45,25 @@ function TreeRow({
|
||||
style,
|
||||
dragHandle,
|
||||
onSelectNode,
|
||||
hiddenNodeIds,
|
||||
onToggleNodeVisibility,
|
||||
onOpenContextMenu,
|
||||
}: NodeRendererProps<UiNode> & {
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
hiddenNodeIds: ReadonlySet<NodeId>;
|
||||
onToggleNodeVisibility: (id: NodeId) => void;
|
||||
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
|
||||
}) {
|
||||
const data = node.data;
|
||||
const nodeLabel = data.metadata.name || '未命名节点';
|
||||
const isVisible = !hiddenNodeIds.has(data.id);
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
className={`flex min-w-0 items-center gap-1 rounded-lg px-2 text-xs text-(--platform-text-strong) ${
|
||||
node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5'
|
||||
}`}
|
||||
} ${isVisible ? '' : 'opacity-60'}`}
|
||||
onClick={() => {
|
||||
node.select();
|
||||
onSelectNode(data.id);
|
||||
@@ -82,12 +92,23 @@ function TreeRow({
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{data.metadata.name || '未命名节点'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span>
|
||||
<span className="text-[10px] text-(--platform-text-soft)">
|
||||
{data.components.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleNodeVisibility(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,7 +118,9 @@ export function UiTreePanel({
|
||||
selectedNodeId,
|
||||
focusRequest,
|
||||
treeIdForNode,
|
||||
hiddenNodeIdsForTree,
|
||||
onSelectNode,
|
||||
onToggleNodeVisibility,
|
||||
onInsertNode,
|
||||
onInsertNodeAfter,
|
||||
onDeleteNode,
|
||||
@@ -188,24 +211,36 @@ export function UiTreePanel({
|
||||
disableDrop={({ parentNode }) => parentNode.id === root.id}
|
||||
aria-label="UI 节点树"
|
||||
>
|
||||
{(props) => (
|
||||
<TreeRow
|
||||
{...props}
|
||||
onSelectNode={(nodeId) => {
|
||||
const treeId = treeIdForNode(nodeId);
|
||||
if (treeId) onSelectNode(treeId, nodeId);
|
||||
}}
|
||||
onOpenContextMenu={(event, nodeId) =>
|
||||
nodeId === root.id
|
||||
? undefined
|
||||
: setContextMenu({
|
||||
nodeId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(props) => {
|
||||
const treeId = treeIdForNode(props.node.data.id);
|
||||
return (
|
||||
<TreeRow
|
||||
{...props}
|
||||
onSelectNode={(nodeId) => {
|
||||
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);
|
||||
}}
|
||||
onOpenContextMenu={(event, nodeId) =>
|
||||
nodeId === root.id
|
||||
? undefined
|
||||
: setContextMenu({
|
||||
nodeId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Tree>
|
||||
) : (
|
||||
<p className="m-0 text-xs text-(--platform-text-soft)">
|
||||
|
||||
@@ -340,6 +340,7 @@ export function PreviewWorkspace({
|
||||
<UiTreeRenderer
|
||||
tree={tree}
|
||||
renderMode={renderMode}
|
||||
hiddenNodeIds={controller.hiddenNodeIds}
|
||||
selectedNodeId={controller.selectedNodeId}
|
||||
resources={{
|
||||
previewUrls,
|
||||
|
||||
@@ -20,6 +20,7 @@ type NodePointerDown = (
|
||||
type UiTreeRendererProps = {
|
||||
tree: UITree | null;
|
||||
renderMode: UiEditorRenderMode;
|
||||
hiddenNodeIds: ReadonlySet<NodeId>;
|
||||
selectedNodeId: NodeId | null;
|
||||
resources: PreviewComponentResources;
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
@@ -56,6 +57,7 @@ function RenderNode({
|
||||
node,
|
||||
isRoot,
|
||||
renderMode,
|
||||
hiddenNodeIds,
|
||||
selectedNodeId,
|
||||
resources,
|
||||
onSelectNode,
|
||||
@@ -67,6 +69,8 @@ function RenderNode({
|
||||
onNodeResizePointerUp,
|
||||
viewportScale,
|
||||
}: Omit<UiTreeRendererProps, 'tree'> & { node: UiNode; isRoot?: boolean }) {
|
||||
if (hiddenNodeIds.has(node.id)) return null;
|
||||
|
||||
let geometry;
|
||||
try {
|
||||
geometry = tf2css(node.transform);
|
||||
@@ -123,6 +127,7 @@ function RenderNode({
|
||||
key={child.id}
|
||||
node={child}
|
||||
renderMode={renderMode}
|
||||
hiddenNodeIds={hiddenNodeIds}
|
||||
selectedNodeId={selectedNodeId}
|
||||
resources={resources}
|
||||
onSelectNode={onSelectNode}
|
||||
|
||||
@@ -58,6 +58,18 @@ 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;
|
||||
@@ -90,6 +102,8 @@ export function useUiEditorPage(
|
||||
null,
|
||||
);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
|
||||
const [hiddenNodeIdsByImage, setHiddenNodeIdsByImage] =
|
||||
useState<HiddenNodeIdsByImage>({});
|
||||
const { focusRequest, focusNode } = useUiEditorNodeFocus({
|
||||
activateImage: setActiveImageId,
|
||||
clearSpriteSelection: () => setSelectedSpriteId(null),
|
||||
@@ -123,6 +137,7 @@ export function useUiEditorPage(
|
||||
setIsLoading(false);
|
||||
setLoadError(null);
|
||||
setPersistedRevision(0);
|
||||
setHiddenNodeIdsByImage({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -141,6 +156,7 @@ export function useUiEditorPage(
|
||||
setActiveImageId(ids[0] ?? null);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedSpriteId(null);
|
||||
setHiddenNodeIdsByImage({});
|
||||
setPreviewUrls({});
|
||||
setPersistedRevision(revision);
|
||||
if (projectPath) {
|
||||
@@ -260,6 +276,89 @@ export function useUiEditorPage(
|
||||
? editor.state.ui_trees.find((tree) => tree.src_ui_design === activeImageId)
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [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) =>
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
treeId ? hiddenNodeIdsByImage[treeId] : undefined,
|
||||
nodeId,
|
||||
),
|
||||
[activeImageId, hiddenNodeIdsByImage],
|
||||
);
|
||||
|
||||
const setNodePreviewVisible = useCallback(
|
||||
(nodeId: NodeId, visible: boolean, treeId = activeImageId) => {
|
||||
if (!treeId) return;
|
||||
setHiddenNodeIdsByImage((current) => {
|
||||
const currentForImage = current[treeId] ?? {};
|
||||
const currentlyVisible = !currentForImage[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];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[activeImageId],
|
||||
);
|
||||
|
||||
const toggleNodePreviewVisibility = useCallback(
|
||||
(nodeId: NodeId, treeId = activeImageId) => {
|
||||
setNodePreviewVisible(
|
||||
nodeId,
|
||||
!isNodePreviewVisible(nodeId, treeId),
|
||||
treeId,
|
||||
);
|
||||
},
|
||||
[activeImageId, isNodePreviewVisible, setNodePreviewVisible],
|
||||
);
|
||||
|
||||
const findNodeContext = useCallback(function findNodeContext(
|
||||
node: UiNode,
|
||||
nodeId: NodeId,
|
||||
@@ -364,6 +463,12 @@ 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);
|
||||
@@ -496,6 +601,7 @@ export function useUiEditorPage(
|
||||
setActiveImageId(null);
|
||||
setSelectedSpriteId(null);
|
||||
setSelectedNodeId(null);
|
||||
setHiddenNodeIdsByImage({});
|
||||
setPreviewUrls({});
|
||||
setClearOpen(false);
|
||||
}
|
||||
@@ -825,6 +931,11 @@ export function useUiEditorPage(
|
||||
activeImage,
|
||||
selectedSprite,
|
||||
selectedFont,
|
||||
hiddenNodeIdsByImage,
|
||||
hiddenNodeIds,
|
||||
isNodePreviewVisible,
|
||||
setNodePreviewVisible,
|
||||
toggleNodePreviewVisibility,
|
||||
pageOptions,
|
||||
spriteReferenceCounts,
|
||||
fontReferenceCounts,
|
||||
|
||||
@@ -97,4 +97,70 @@ describe('UiEditorPage', () => {
|
||||
expect(result.current.selectedNodeId).toBeNull();
|
||||
expect(result.current.selectedSpriteId).toBe('panel');
|
||||
});
|
||||
|
||||
it('keeps node preview visibility transient and scoped to each interface image', () => {
|
||||
const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor'));
|
||||
act(() => {
|
||||
result.current.editor.addDesignImages([
|
||||
{
|
||||
id: 'page-a',
|
||||
image: {
|
||||
metadata: {
|
||||
name: 'Page A',
|
||||
description: '',
|
||||
role: 'Page',
|
||||
slave_to: null,
|
||||
},
|
||||
path: 'assets/page-a.png',
|
||||
pixel_size: [320, 180],
|
||||
pixels_per_unit: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'page-b',
|
||||
image: {
|
||||
metadata: {
|
||||
name: 'Page B',
|
||||
description: '',
|
||||
role: 'Page',
|
||||
slave_to: null,
|
||||
},
|
||||
path: 'assets/page-b.png',
|
||||
pixel_size: [320, 180],
|
||||
pixels_per_unit: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => result.current.selectDesignImage('page-a'));
|
||||
const rootId = result.current.treeForActiveImage!.root.id;
|
||||
let insertedNodeId: string | undefined;
|
||||
act(() => {
|
||||
const inserted = result.current.insertNode(rootId);
|
||||
if (inserted?.ok) insertedNodeId = inserted.value;
|
||||
});
|
||||
const stateBeforeToggle = JSON.stringify(result.current.editor.state);
|
||||
act(() => {
|
||||
result.current.toggleNodePreviewVisibility(rootId);
|
||||
});
|
||||
expect(result.current.isNodePreviewVisible(rootId)).toBe(false);
|
||||
expect(result.current.hiddenNodeIds.has(rootId)).toBe(true);
|
||||
expect(JSON.stringify(result.current.editor.state)).toBe(stateBeforeToggle);
|
||||
|
||||
act(() => result.current.selectDesignImage('page-b'));
|
||||
expect(result.current.hiddenNodeIds.size).toBe(0);
|
||||
|
||||
act(() => result.current.selectDesignImage('page-a'));
|
||||
expect(result.current.hiddenNodeIds.has(rootId)).toBe(true);
|
||||
expect(insertedNodeId).toBeTruthy();
|
||||
act(() => result.current.toggleNodePreviewVisibility(insertedNodeId!));
|
||||
expect(result.current.hiddenNodeIds.has(insertedNodeId!)).toBe(true);
|
||||
|
||||
act(() => result.current.deleteNode(insertedNodeId!));
|
||||
expect(result.current.hiddenNodeIds.has(insertedNodeId!)).toBe(false);
|
||||
|
||||
act(() => result.current.clearState());
|
||||
expect(result.current.hiddenNodeIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
|
||||
import type { UITree } from '../src/features/ui-editor/types/UITree';
|
||||
import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer';
|
||||
|
||||
function node(id: string, children: UiNode[] = []): UiNode {
|
||||
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: 'Passed',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'System',
|
||||
},
|
||||
components: [],
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
const tree: UITree = {
|
||||
src_ui_design: 'page',
|
||||
root: node('root', [node('child', [node('grandchild')])]),
|
||||
};
|
||||
|
||||
const resources = {
|
||||
previewUrls: {},
|
||||
sprites: {},
|
||||
fonts: {},
|
||||
fontFaces: {},
|
||||
};
|
||||
|
||||
function renderTree(
|
||||
renderMode: 'editor-overlay' | 'final-preview',
|
||||
hiddenNodeIds: ReadonlySet<string>,
|
||||
) {
|
||||
return render(
|
||||
<UiTreeRenderer
|
||||
tree={tree}
|
||||
renderMode={renderMode}
|
||||
hiddenNodeIds={hiddenNodeIds}
|
||||
selectedNodeId={null}
|
||||
resources={resources}
|
||||
onSelectNode={vi.fn()}
|
||||
onNodePointerDown={vi.fn()}
|
||||
onNodePointerMove={vi.fn()}
|
||||
onNodePointerUp={vi.fn()}
|
||||
onNodeResizePointerDown={vi.fn()}
|
||||
onNodeResizePointerMove={vi.fn()}
|
||||
onNodeResizePointerUp={vi.fn()}
|
||||
viewportScale={1}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('UI tree preview visibility', () => {
|
||||
it('hides a node subtree in editor overlay and final preview', () => {
|
||||
const editorOverlay = renderTree('editor-overlay', new Set(['child']));
|
||||
expect(
|
||||
editorOverlay.container.querySelector('[data-node-id="root"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
editorOverlay.container.querySelector('[data-node-id="child"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
editorOverlay.container.querySelector('[data-node-id="grandchild"]'),
|
||||
).toBeNull();
|
||||
|
||||
const finalPreview = renderTree('final-preview', new Set(['root']));
|
||||
expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1092,3 +1092,9 @@ game-project/
|
||||
- Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revision;Rust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。
|
||||
- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
|
||||
- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。
|
||||
|
||||
## 2026-08-18 UI Editor 节点预览可见性
|
||||
|
||||
- 节点显示 / 隐藏是编辑器页面级临时状态,不新增 Rust `Node` 字段,不进入 UI State 保存快照、运行时数据或后端契约。状态按界面图 ID 和节点 ID 维护;切换界面图保留,清空或重新加载时重置,删除节点或界面图时清理失效 ID。
|
||||
- 同一可见性状态同时作用于 `编辑叠加` 和 `最终预览`。隐藏节点会截断整个子树,移除节点组件、编辑边框、名称、缩放控制点和画布命中区域;参考图和预览画布本身保持存在。父节点恢复时,子节点此前独立设置的隐藏状态保持不变,根节点不享受特殊限制,也允许隐藏。
|
||||
- UI Tree 行提供 `Eye` / `EyeOff` 按钮,按钮只切换状态、不改变选择;节点 Inspector 提供“在预览中显示”复选框,两者共享页面控制器状态。隐藏节点仍可通过 UI Tree 选择并在 Inspector 中编辑,但不会重新出现在画布上,直到恢复显示。
|
||||
|
||||
Reference in New Issue
Block a user