扩展 UI 编辑器功能

- 添加选中节点状态管理,支持节点选择和预览交互。
- 优化 `PreviewWorkspace`,引入缩放逻辑和节点层级渲染。
- 更新 `UiTreePanel`,支持节点选中状态的 UI 表现。
- 集成节点选择状态到 `InputSidebar` 和数据模型。
- 添加 `recognizeUi` 方法,支持界面树识别响应逻辑。
This commit is contained in:
2026-08-13 20:31:06 +08:00
parent 0440ab6eb6
commit 1723aa4322
5 changed files with 215 additions and 17 deletions
@@ -17,6 +17,7 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle
spriteReferenceCounts,
activeTool,
treeForActiveImage,
selectedNodeId,
} = controller;
return (
@@ -133,7 +134,11 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle
/>
{activeTool !== 'input' ? (
<UiTreePanel nodes={treeForActiveImage?.children ?? null} />
<UiTreePanel
nodes={treeForActiveImage?.children ?? null}
selectedNodeId={selectedNodeId}
onSelectNode={controller.selectNode}
/>
) : null}
</aside>
);
@@ -1,13 +1,97 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Image as ImageIcon, ScanSearch } from 'lucide-react';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import { tf2css } from '../../../features/ui-editor/utils/transform/tf2css';
import type { UiEditorPageController } from '../useUiEditorPage';
function PreviewNode({
node,
selectedNodeId,
onSelectNode,
}: {
node: UiNode;
selectedNodeId: string | null;
onSelectNode: (id: string) => void;
}) {
let geometry;
try {
geometry = tf2css(node.transform);
} catch {
// TODO: expose malformed node diagnostics without breaking the rest of the preview.
return null;
}
return (
<div
data-node-id={node.id}
className="absolute min-h-0 min-w-0 cursor-pointer"
style={{
...geometry,
outline: selectedNodeId === node.id
? '2px solid rgb(249 115 22)'
: '1px solid rgb(59 130 246 / 70%)',
outlineOffset: selectedNodeId === node.id ? 0 : -1,
backgroundColor: selectedNodeId === node.id
? 'rgb(249 115 22 / 10%)'
: 'rgb(59 130 246 / 4%)',
}}
onClick={(event) => {
event.stopPropagation();
onSelectNode(node.id);
}}
title={node.metadata.name || undefined}
>
{node.metadata.name ? (
<span className="pointer-events-none absolute left-0 top-0 max-w-full truncate bg-blue-600/80 px-1 text-[9px] leading-4 text-white">
{node.metadata.name}
</span>
) : null}
{/* TODO: render supported Component variants once component preview is in scope. */}
{node.children.map((child) => (
<PreviewNode
key={child.id}
node={child}
selectedNodeId={selectedNodeId}
onSelectNode={onSelectNode}
/>
))}
</div>
);
}
export function PreviewWorkspace({
controller,
}: {
controller: UiEditorPageController;
}) {
const { editor, activeImage, activeImageId, previewUrls } = controller;
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const tree = controller.treeForActiveImage;
const logicalSize = useMemo(() => {
if (!activeImage) return null;
const ppu = activeImage.pixels_per_unit;
if (!Number.isFinite(ppu) || ppu <= 0) return null;
const width = activeImage.pixel_size[0] / ppu;
const height = activeImage.pixel_size[1] / ppu;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
return { width, height };
}, [activeImage]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !logicalSize) return;
const updateScale = () => {
const availableWidth = Math.max(1, viewport.clientWidth - 64);
const availableHeight = Math.max(1, viewport.clientHeight - 64);
setScale(Math.min(1, availableWidth / logicalSize.width, availableHeight / logicalSize.height));
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(viewport);
return () => observer.disconnect();
}, [logicalSize]);
return (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-(--platform-body-fill)">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-(--platform-subpanel-border) px-4">
@@ -17,13 +101,28 @@ export function PreviewWorkspace({
{Object.keys(controller.sprites).length}
</span>
</header>
<div className="grid min-h-0 flex-1 place-items-center overflow-auto p-8">
{activeImage && previewUrls[activeImageId ?? ''] ? (
<img
src={previewUrls[activeImageId ?? '']}
alt={activeImage.metadata.name}
className="max-h-full max-w-full rounded-lg object-contain shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
/>
<div
ref={viewportRef}
className="grid min-h-0 flex-1 place-items-center overflow-auto p-8"
onClick={controller.clearNodeSelection}
>
{activeImage && previewUrls[activeImageId ?? ''] && logicalSize ? (
<div className="relative shrink-0" style={{ width: logicalSize.width * scale, height: logicalSize.height * scale }}>
<div
className="relative"
style={{ width: logicalSize.width, height: logicalSize.height, transform: `scale(${scale})`, transformOrigin: 'top left' }}
>
<img
src={previewUrls[activeImageId ?? '']}
alt={activeImage.metadata.name}
className="absolute inset-0 size-full rounded-lg object-fill shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
draggable={false}
/>
{tree?.children.map((node) => (
<PreviewNode key={node.id} node={node} selectedNodeId={controller.selectedNodeId} onSelectNode={controller.selectNode} />
))}
</div>
</div>
) : (
<div className="w-full max-w-xl rounded-2xl border border-dashed border-(--platform-subpanel-border) bg-white/35 p-10 text-center">
<ImageIcon
@@ -1,24 +1,46 @@
import { ChevronRight, Layers3 } from 'lucide-react';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
function TreeNodes({ nodes, depth = 0 }: { nodes: UiNode[]; depth?: number }) {
function TreeNodes({
nodes,
depth = 0,
selectedNodeId,
onSelectNode,
}: {
nodes: UiNode[];
depth?: number;
selectedNodeId: NodeId | null;
onSelectNode: (id: NodeId) => void;
}) {
return (
<ul className="m-0 list-none space-y-1 p-0">
{nodes.map((node, index) => (
<li key={`${node.metadata.name}-${index}`}>
<div
className="flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-(--platform-text-strong)"
{nodes.map((node) => (
<li key={node.id}>
<button
type="button"
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-(--platform-text-strong) ${
selectedNodeId === node.id
? 'bg-orange-100 text-orange-900'
: 'hover:bg-black/5'
}`}
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => onSelectNode(node.id)}
>
<ChevronRight size={12} className="text-(--platform-text-soft)" />
<span className="truncate">{node.metadata.name || '未命名节点'}</span>
<span className="ml-auto text-[10px] text-(--platform-text-soft)">
{node.components.length}
</span>
</div>
</button>
{node.children.length > 0 ? (
<TreeNodes nodes={node.children} depth={depth + 1} />
<TreeNodes
nodes={node.children}
depth={depth + 1}
selectedNodeId={selectedNodeId}
onSelectNode={onSelectNode}
/>
) : null}
</li>
))}
@@ -26,7 +48,15 @@ function TreeNodes({ nodes, depth = 0 }: { nodes: UiNode[]; depth?: number }) {
);
}
export function UiTreePanel({ nodes }: { nodes: UiNode[] | null }) {
export function UiTreePanel({
nodes,
selectedNodeId,
onSelectNode,
}: {
nodes: UiNode[] | null;
selectedNodeId: NodeId | null;
onSelectNode: (id: NodeId) => void;
}) {
return (
<section className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
<div className="flex items-center gap-2">
@@ -35,7 +65,11 @@ export function UiTreePanel({ nodes }: { nodes: UiNode[] | null }) {
</div>
<div className="mt-3">
{nodes ? (
<TreeNodes nodes={nodes} />
<TreeNodes
nodes={nodes}
selectedNodeId={selectedNodeId}
onSelectNode={onSelectNode}
/>
) : (
<p className="m-0 text-xs text-(--platform-text-soft)">
@@ -30,6 +30,20 @@ export default function UiEditorPage({
{controller.suggestionStatus}
</p>
) : null}
{controller.recognitionStatus ? (
<p className="rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs shadow-lg">
{controller.recognitionStatus}
</p>
) : null}
<button
type="button"
className="rounded-full bg-blue-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-60"
onClick={() => void controller.recognizeUi()}
disabled={controller.isRecognizing || controller.editor.isLocked}
title="调试:识别 UI 结构"
>
{controller.isRecognizing ? '结构识别中…' : '识别 UI 结构'}
</button>
<button
type="button"
className="rounded-full bg-orange-500 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-orange-600 disabled:cursor-wait disabled:opacity-60"
@@ -18,7 +18,10 @@ 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 { UIDesignSuggestion } from '../../features/ui-editor/types/UIDesignSuggestion';
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions';
import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import {
EMPTY_UI_EDITOR_STATE,
useUiEditorState,
@@ -39,6 +42,7 @@ export function useUiEditorPage(projectPath: string) {
useState<UIDesignImageId | null>(null);
const [selectedSpriteId, setSelectedSpriteId] =
useState<SpriteAssetId | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const [importKind, setImportKind] = useState<UiEditorImportKind | null>(null);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [status, setStatus] = useState<string | null>(null);
@@ -49,6 +53,8 @@ export function useUiEditorPage(projectPath: string) {
useState<PendingResourceRemoval | null>(null);
const [isSuggesting, setIsSuggesting] = useState(false);
const [suggestionStatus, setSuggestionStatus] = useState<string | null>(null);
const [isRecognizing, setIsRecognizing] = useState(false);
const [recognitionStatus, setRecognitionStatus] = useState<string | null>(null);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -143,6 +149,7 @@ export function useUiEditorPage(projectPath: string) {
});
if (id === selectedSpriteId) setSelectedSpriteId(null);
if (id !== activeImageId) return;
setSelectedNodeId(null);
const index = imageOrder.indexOf(id);
const remaining = imageOrder.filter((item) => item !== id);
setImageOrder(remaining);
@@ -197,6 +204,7 @@ export function useUiEditorPage(projectPath: string) {
setImageOrder([]);
setActiveImageId(null);
setSelectedSpriteId(null);
setSelectedNodeId(null);
setPreviewUrls({});
setIssues(null);
setClearOpen(false);
@@ -210,6 +218,15 @@ export function useUiEditorPage(projectPath: string) {
function selectDesignImage(id: UIDesignImageId) {
setActiveImageId(id);
setSelectedSpriteId(null);
setSelectedNodeId(null);
}
function selectNode(id: NodeId) {
setSelectedNodeId(id);
}
function clearNodeSelection() {
setSelectedNodeId(null);
}
function selectSprite(id: SpriteAssetId) {
@@ -277,6 +294,29 @@ export function useUiEditorPage(projectPath: string) {
}
}
async function recognizeUi() {
if (isRecognizing) return;
setRecognitionStatus(null);
setIsRecognizing(true);
try {
await editor.runWithStateLocked(async (snapshot) => {
const result = await invoke<RecognitionDTO>('recognize_ui', {
projectPath,
state: snapshot,
});
editor.replaceState(applyRecognitionResult(snapshot, result));
setSelectedNodeId(null);
setRecognitionStatus(
`已替换 ${result.ui_trees.length} 棵界面树。`,
);
});
} catch (cause) {
setRecognitionStatus(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsRecognizing(false);
}
}
return {
projectPath,
editor,
@@ -285,6 +325,7 @@ export function useUiEditorPage(projectPath: string) {
imageOrder,
activeImageId,
selectedSpriteId,
selectedNodeId,
importKind,
previewUrls,
status,
@@ -301,6 +342,8 @@ export function useUiEditorPage(projectPath: string) {
selectTool,
selectDesignImage,
selectSprite,
selectNode,
clearNodeSelection,
openImporter: setImportKind,
closeImporter: () => setImportKind(null),
importAssets,
@@ -322,6 +365,9 @@ export function useUiEditorPage(projectPath: string) {
isSuggesting,
suggestionStatus,
suggestUiDesignSemantics,
isRecognizing,
recognitionStatus,
recognizeUi,
};
}