扩展 UI 编辑器功能
- 添加选中节点状态管理,支持节点选择和预览交互。 - 优化 `PreviewWorkspace`,引入缩放逻辑和节点层级渲染。 - 更新 `UiTreePanel`,支持节点选中状态的 UI 表现。 - 集成节点选择状态到 `InputSidebar` 和数据模型。 - 添加 `recognizeUi` 方法,支持界面树识别响应逻辑。
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user