重构 UI 编辑器逻辑以增强页面树操作:

- 添加页面树同步逻辑,确保 `Page` 节点与 UI 树一致。
- 实现节点的新增、删除、移动和元数据更新功能。
- 扩展节点 Inspector,支持查看和修改选中节点的名称、描述和变换属性。
- 重构相关测试,覆盖新的页面树和节点操作功能。
- 引入嵌套节点坐标计算逻辑,确保节点相对位置一致性。
This commit is contained in:
2026-08-14 12:16:17 +08:00
parent 12d6e20430
commit 9ea3b3cfe6
12 changed files with 1808 additions and 340 deletions
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UIDesignImageId } from "./UIDesignImageId";
import type { UIDesignImageRole } from "./UIDesignImageRole";
export type UIDesignSuggestionTreeNode = { id: UIDesignImageId, name: string, description: string, role: UIDesignImageRole, children: Array<UIDesignSuggestionTreeNode>, };
File diff suppressed because it is too large Load Diff
@@ -84,6 +84,35 @@ export function EditorDialogs({
</button>
</div>
</ThemedModal>
<ThemedModal
open={controller.pendingRoleChange !== null}
onClose={controller.cancelRoleChange}
ariaLabel="确认移除页面组件树"
panelClassName="w-[440px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold"></h2>
<p className="text-sm leading-6 text-(--platform-text-soft)">
Page UI
Tree
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={controller.cancelRoleChange}
>
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.confirmRoleChange}
>
</button>
</div>
</ThemedModal>
</>
);
}
@@ -20,7 +20,7 @@ export function ImportOverview({
uiTrees: UITree[];
}) {
const componentCount = uiTrees.reduce(
(total, tree) => total + countUiComponents(tree.children),
(total, tree) => total + countUiComponents([tree.root]),
0,
);
@@ -4,7 +4,11 @@ import type { UiEditorPageController } from '../useUiEditorPage';
import { ImportOverview } from './ImportOverview';
import { UiTreePanel } from './UiTreePanel';
export function InputSidebar({ controller }: { controller: UiEditorPageController }) {
export function InputSidebar({
controller,
}: {
controller: UiEditorPageController;
}) {
// TODO: AssetImporter 支持字体文件后,在这里增加独立的项目字体面板。
const {
projectPath,
@@ -58,7 +62,11 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle
>
<div className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md bg-black/5">
{previewUrls[id] ? (
<img src={previewUrls[id]} alt="" className="size-full object-cover" />
<img
src={previewUrls[id]}
alt=""
className="size-full object-cover"
/>
) : (
<ImageIcon size={15} />
)}
@@ -68,8 +76,8 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle
{image.metadata.name}
</strong>
<span className="block truncate text-[10px] text-(--platform-text-soft)">
{image.metadata.role ?? '自动判断'} · {image.pixel_size[0]} ×{' '}
{image.pixel_size[1]}
{image.metadata.role ?? '自动判断'} ·{' '}
{image.pixel_size[0]} × {image.pixel_size[1]}
</span>
</span>
</button>
@@ -135,9 +143,28 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle
{activeTool !== 'input' ? (
<UiTreePanel
nodes={treeForActiveImage?.children ?? null}
treeId={activeImageId}
root={treeForActiveImage?.root ?? null}
selectedNodeId={selectedNodeId}
onSelectNode={controller.selectNode}
onInsertNode={(parentId) => {
controller.insertNode(parentId);
}}
onInsertNodeAfter={(nodeId) => {
controller.insertNodeAfter(nodeId);
}}
onDeleteNode={(nodeId) => {
controller.deleteNode(nodeId);
}}
onMoveNode={(nodeId, targetParentId, targetIndex) => {
if (!activeImageId) return;
controller.moveNode(
activeImageId,
nodeId,
targetParentId,
targetIndex,
);
}}
/>
) : null}
</aside>
@@ -269,9 +269,12 @@ export function TransformEditor({
onChange,
}: TransformEditorProps) {
const [presetOpen, setPresetOpen] = useState(false);
const [customOpen, setCustomOpen] = useState(false);
const [customOpen, setCustomOpen] = useState(() =>
findPreset(transform).id === CUSTOM_PRESET.id,
);
const presetRef = useRef<HTMLDivElement | null>(null);
const selectedPreset = useMemo(() => findPreset(transform), [transform]);
const matchedPreset = useMemo(() => findPreset(transform), [transform]);
const selectedPreset = customOpen ? CUSTOM_PRESET : matchedPreset;
const geometry = useMemo(
() => resolvedGeometry(transform, parentSize),
[parentSize, transform],
@@ -415,6 +418,7 @@ export function TransformEditor({
className={`grid aspect-square place-items-center rounded-lg border text-[10px] font-bold transition ${active ? 'border-orange-400 bg-orange-100 text-orange-700' : 'border-transparent bg-slate-50 text-slate-500 hover:border-orange-200 hover:bg-orange-50 hover:text-orange-700'}`}
onClick={() => {
onChange(applyPreset(transform, preset, parentSize));
setCustomOpen(false);
setPresetOpen(false);
}}
>
@@ -435,23 +439,47 @@ export function TransformEditor({
</div>
<button
type="button"
className={`mt-3 flex h-9 w-full items-center justify-between rounded-lg border px-2.5 text-left text-xs transition ${selectedPreset.id === 'custom' ? 'border-orange-300 bg-orange-50 text-orange-700' : 'border-slate-200 bg-slate-50 text-slate-600 hover:border-orange-200 hover:bg-orange-50'}`}
aria-pressed={selectedPreset.id === CUSTOM_PRESET.id}
disabled={readOnly}
className={`mt-3 flex h-9 w-full items-center rounded-lg border px-2.5 text-left text-xs transition disabled:cursor-default disabled:opacity-65 ${selectedPreset.id === CUSTOM_PRESET.id ? 'border-orange-300 bg-orange-50 text-orange-700' : 'border-slate-200 bg-slate-50 text-slate-600 hover:border-orange-200 hover:bg-orange-50'}`}
onClick={() => {
setCustomOpen((open) => !open);
setCustomOpen(true);
setPresetOpen(false);
}}
>
<span>Custom anchors</span>
<ChevronDown
size={14}
className={`transition-transform ${customOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
<span>Custom Anchor</span>
</button>
</div>
) : null}
</div>
{customOpen ? (
<div className="grid gap-2 rounded-xl border border-dashed border-orange-200 bg-orange-50/45 p-3">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.08em] text-orange-700">
<Move size={13} aria-hidden="true" />
Custom Anchor
</div>
<VectorInputRow
label="Anchor Min"
values={transform.anchor_min}
step={0.05}
readOnly={readOnly}
onCommit={(axis, value) =>
updateVector('anchor_min', axis, value)
}
/>
<VectorInputRow
label="Anchor Max"
values={transform.anchor_max}
step={0.05}
readOnly={readOnly}
onCommit={(axis, value) =>
updateVector('anchor_max', axis, value)
}
/>
</div>
) : null}
{warning ? (
<div
className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800"
@@ -497,32 +525,6 @@ export function TransformEditor({
/>
</div>
{customOpen ? (
<div className="grid gap-2 rounded-xl border border-dashed border-orange-200 bg-orange-50/45 p-3">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.08em] text-orange-700">
<Move size={13} aria-hidden="true" />
Custom anchors
</div>
<VectorInputRow
label="Anchor Min"
values={transform.anchor_min}
step={0.05}
readOnly={readOnly}
onCommit={(axis, value) =>
updateVector('anchor_min', axis, value)
}
/>
<VectorInputRow
label="Anchor Max"
values={transform.anchor_max}
step={0.05}
readOnly={readOnly}
onCommit={(axis, value) =>
updateVector('anchor_max', axis, value)
}
/>
</div>
) : null}
</div>
</section>
);
@@ -1,18 +1,58 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Image as ImageIcon, ScanSearch } from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Image as ImageIcon, Minus, Plus, ScanSearch } from 'lucide-react';
import {
createPanDragState,
fitViewportToBounds,
moveViewportFromPan,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
type CanvasViewport,
type DragState,
} from '@genarrative/image-canvas-core';
import {
CanvasViewport as SharedCanvasViewport,
CanvasWorld,
ZoomControls,
} from '@genarrative/image-canvas-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';
type NodeDragState = {
nodeId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startTransform: UiNode['transform'];
};
function PreviewNode({
node,
isRoot = false,
selectedNodeId,
onSelectNode,
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
}: {
node: UiNode;
isRoot?: boolean;
selectedNodeId: string | null;
onSelectNode: (id: string) => void;
onNodePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
node: UiNode,
) => void;
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
}) {
let geometry;
try {
@@ -25,21 +65,27 @@ function PreviewNode({
return (
<div
data-node-id={node.id}
className="absolute min-h-0 min-w-0 cursor-pointer"
className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`}
style={{
...geometry,
outline: selectedNodeId === node.id
? '2px solid rgb(249 115 22)'
: '1px solid rgb(59 130 246 / 70%)',
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%)',
backgroundColor:
selectedNodeId === node.id
? 'rgb(249 115 22 / 10%)'
: 'rgb(59 130 246 / 4%)',
}}
onClick={(event) => {
event.stopPropagation();
onSelectNode(node.id);
}}
onPointerDown={(event) => onNodePointerDown(event, node)}
onPointerMove={onNodePointerMove}
onPointerUp={onNodePointerUp}
onPointerCancel={onNodePointerUp}
title={node.metadata.name || undefined}
>
{node.metadata.name ? (
@@ -54,6 +100,9 @@ function PreviewNode({
node={child}
selectedNodeId={selectedNodeId}
onSelectNode={onSelectNode}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
/>
))}
</div>
@@ -66,8 +115,15 @@ export function PreviewWorkspace({
controller: UiEditorPageController;
}) {
const { editor, activeImage, activeImageId, previewUrls } = controller;
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const viewportElementRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
const nodeDragRef = useRef<NodeDragState | null>(null);
const [viewport, setViewportState] = useState<CanvasViewport>(
viewportRef.current,
);
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
const [spaceHeld, setSpaceHeld] = useState(false);
const tree = controller.treeForActiveImage;
const logicalSize = useMemo(() => {
if (!activeImage) return null;
@@ -75,55 +131,318 @@ export function PreviewWorkspace({
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;
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return null;
}
return { width, height };
}, [activeImage]);
}, [
activeImage?.pixel_size[0],
activeImage?.pixel_size[1],
activeImage?.pixels_per_unit,
]);
const setViewport = useCallback((next: CanvasViewport) => {
viewportRef.current = next;
setViewportState(next);
}, []);
const fitToCanvas = useCallback(() => {
if (!logicalSize) return;
const element = viewportElementRef.current;
const size = {
width: element?.clientWidth || 900,
height: element?.clientHeight || 640,
};
setViewport(
fitViewportToBounds({
bounds: {
x: 0,
y: 0,
width: logicalSize.width,
height: logicalSize.height,
},
canvasSize: size,
}),
);
}, [logicalSize, setViewport]);
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));
const element = viewportElementRef.current;
if (!element) return;
const updateSize = () => {
setCanvasSize({
width: element.clientWidth || 900,
height: element.clientHeight || 640,
});
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(viewport);
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(element);
return () => observer.disconnect();
}, [logicalSize]);
}, []);
useEffect(() => {
fitToCanvas();
controller.clearNodeSelection();
}, [activeImageId, fitToCanvas]);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest(
'button, a, input, textarea, select, [contenteditable="true"], [role="button"]',
),
);
};
const onKeyDown = (event: KeyboardEvent) => {
if (
event.code === 'Space' &&
!event.repeat &&
!isEditableTarget(event.target)
) {
setSpaceHeld(true);
}
if (!event.ctrlKey && !event.metaKey) return;
if (event.key === '0') {
event.preventDefault();
fitToCanvas();
} else if (event.key === '1' && logicalSize) {
event.preventDefault();
const element = viewportElementRef.current;
const width = element?.clientWidth ?? 900;
const height = element?.clientHeight ?? 640;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale: 1,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') setSpaceHeld(false);
};
const onWindowBlur = () => setSpaceHeld(false);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onWindowBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onWindowBlur);
};
}, [fitToCanvas, logicalSize, setViewport]);
const handleNodePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, node: UiNode) => {
if (event.button !== 0 || spaceHeld || node.id === tree?.root.id) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
nodeDragRef.current = {
nodeId: node.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.transform),
};
},
[controller, spaceHeld, tree?.root.id],
);
const handleNodePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
const drag = nodeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId || !activeImageId) return;
const logicalDeltaX =
(event.clientX - drag.startClientX) / viewportRef.current.scale;
const logicalDeltaY =
(event.clientY - drag.startClientY) / viewportRef.current.scale;
const nextTransform = structuredClone(drag.startTransform);
nextTransform.offset_min[0] += logicalDeltaX;
nextTransform.offset_min[1] += logicalDeltaY;
nextTransform.offset_max[0] += logicalDeltaX;
nextTransform.offset_max[1] += logicalDeltaY;
controller.updateNodeTransform(activeImageId, drag.nodeId, nextTransform);
},
[activeImageId, controller],
);
const handleNodePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
if (nodeDragRef.current?.pointerId !== event.pointerId) return;
nodeDragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
},
[],
);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
panRef.current = createPanDragState({
pointerId: event.pointerId,
pointer: { x: event.clientX, y: event.clientY },
viewport: viewportRef.current,
});
return;
}
if (event.button === 0 && event.target === event.currentTarget) {
controller.clearNodeSelection();
}
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = panRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setViewport(
moveViewportFromPan(drag, { x: event.clientX, y: event.clientY }),
);
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (panRef.current?.pointerId !== event.pointerId) return;
panRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
setViewport(
resolveViewportFromWheel({
viewport: viewportRef.current,
deltaX: event.deltaX,
deltaY: event.deltaY,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
screenPoint: {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
},
}).viewport,
);
};
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">
<h1 className="m-0 text-sm font-semibold">{controller.activeToolLabel}</h1>
<h1 className="m-0 text-sm font-semibold">
{controller.activeToolLabel}
</h1>
<span className="text-[10px] text-(--platform-text-soft)">
{Object.keys(controller.images).length} ·{' '}
{Object.keys(controller.sprites).length}
</span>
</header>
<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' }}
{activeImage && previewUrls[activeImageId ?? ''] && logicalSize ? (
<div className="relative min-h-0 flex-1">
<SharedCanvasViewport
ref={viewportElementRef}
className="size-full"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
onClick={(event) => {
if (!(event.target as Element).closest('[data-node-id]')) {
controller.clearNodeSelection();
}
}}
>
<CanvasWorld
viewport={viewport}
worldSize={Math.max(logicalSize.width, logicalSize.height)}
>
<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="relative"
style={{ width: logicalSize.width, height: logicalSize.height }}
>
<img
src={previewUrls[activeImageId ?? '']}
alt={activeImage.metadata.name}
className="absolute inset-0 size-full object-fill shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
draggable={false}
/>
{tree?.root ? (
<PreviewNode
node={tree.root}
isRoot
selectedNodeId={controller.selectedNodeId}
onSelectNode={controller.selectNode}
onNodePointerDown={handleNodePointerDown}
onNodePointerMove={handleNodePointerMove}
onNodePointerUp={handleNodePointerUp}
/>
) : null}
</div>
</CanvasWorld>
</SharedCanvasViewport>
<ZoomControls
viewport={viewport}
onFit={fitToCanvas}
onScaleFromCenter={(nextScale) => {
const element = viewportElementRef.current;
const width = element?.clientWidth ?? canvasSize.width;
const height = element?.clientHeight ?? canvasSize.height;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
}}
>
{(actions) => (
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg">
<button
type="button"
className="grid size-8 place-items-center rounded hover:bg-black/5"
aria-label="缩小画布"
onClick={actions.zoomOut}
>
<Minus size={14} />
</button>
<button
type="button"
className="min-w-14 rounded px-2 py-1 text-[10px] tabular-nums hover:bg-black/5"
aria-label={`适配画布,当前 ${actions.displayPercent}`}
onClick={actions.fit}
>
{actions.displayPercent}
</button>
<button
type="button"
className="grid size-8 place-items-center rounded hover:bg-black/5"
aria-label="放大画布"
onClick={actions.zoomIn}
>
<Plus size={14} />
</button>
</div>
)}
</ZoomControls>
</div>
) : (
<div className="grid min-h-0 flex-1 place-items-center overflow-auto p-8">
<div className="w-full max-w-xl rounded-2xl border border-dashed border-(--platform-subpanel-border) bg-white/35 p-10 text-center">
<ImageIcon
size={30}
@@ -134,8 +453,8 @@ export function PreviewWorkspace({
UI
</p>
</div>
)}
</div>
</div>
)}
<footer className="flex min-h-14 shrink-0 items-center gap-2 border-t border-(--platform-subpanel-border) px-4 py-2">
{controller.status ? (
<span className="mr-auto text-xs text-(--platform-text-soft)">
@@ -1,81 +1,225 @@
import { ChevronRight, Layers3 } from 'lucide-react';
import { ChevronDown, Layers3 } from 'lucide-react';
import { useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react';
import {
Tree,
adjustMoveIndex,
type MoveHandler,
type NodeRendererProps,
} from 'react-arborist';
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';
function TreeNodes({
nodes,
depth = 0,
selectedNodeId,
onSelectNode,
}: {
nodes: UiNode[];
depth?: number;
type UiTreePanelProps = {
treeId: UIDesignImageId | null;
root: UiNode | null;
selectedNodeId: NodeId | null;
onSelectNode: (id: NodeId) => void;
onInsertNode: (parentId: NodeId) => void;
onInsertNodeAfter: (nodeId: NodeId) => void;
onDeleteNode: (nodeId: NodeId) => void;
onMoveNode: (
nodeId: NodeId,
targetParentId: NodeId,
targetIndex: number,
) => void;
};
function TreeRow({
node,
style,
dragHandle,
onSelectNode,
onOpenContextMenu,
}: NodeRendererProps<UiNode> & {
onSelectNode: (id: NodeId) => void;
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
}) {
const data = node.data;
return (
<ul className="m-0 list-none space-y-1 p-0">
{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>
</button>
{node.children.length > 0 ? (
<TreeNodes
nodes={node.children}
depth={depth + 1}
selectedNodeId={selectedNodeId}
onSelectNode={onSelectNode}
/>
) : null}
</li>
))}
</ul>
<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'
}`}
onClick={() => {
node.select();
onSelectNode(data.id);
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
node.select();
onSelectNode(data.id);
onOpenContextMenu(event, data.id);
}}
>
<button
type="button"
aria-label={node.isOpen ? '折叠节点' : '展开节点'}
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
onClick={(event) => {
event.stopPropagation();
node.toggle();
}}
>
{!node.isLeaf ? (
<ChevronDown
size={13}
className={`transition-transform ${node.isOpen ? '' : '-rotate-90'}`}
/>
) : null}
</button>
<span className="min-w-0 flex-1 truncate">
{data.metadata.name || '未命名节点'}
</span>
<span className="text-[10px] text-(--platform-text-soft)">
{data.components.length}
</span>
</div>
);
}
export function UiTreePanel({
nodes,
treeId,
root,
selectedNodeId,
onSelectNode,
}: {
nodes: UiNode[] | null;
selectedNodeId: NodeId | null;
onSelectNode: (id: NodeId) => void;
}) {
onInsertNode,
onInsertNodeAfter,
onDeleteNode,
onMoveNode,
}: UiTreePanelProps) {
const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId;
x: number;
y: number;
} | null>(null);
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener('click', close);
window.addEventListener('blur', close);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('blur', close);
};
}, [contextMenu]);
const handleMove: MoveHandler<UiNode> = ({
dragIds,
parentId,
parentNode,
index,
}) => {
if (!treeId || !root || dragIds.length !== 1 || !parentId || !parentNode) {
return;
}
const [nodeId] = dragIds;
if (!nodeId || nodeId === root.id) return;
const destinationIds = (parentNode.children ?? []).map(
(child) => child.data.id,
);
const targetIndex = adjustMoveIndex({
index,
dragIds,
siblingIds: destinationIds,
});
onMoveNode(nodeId, parentId, targetIndex);
};
return (
<section className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
<section
className="relative mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3"
onContextMenu={(event) => event.preventDefault()}
>
<div className="flex items-center gap-2">
<Layers3 size={15} />
<h2 className="m-0 text-sm font-semibold">UI Tree</h2>
</div>
<div className="mt-3">
{nodes ? (
<TreeNodes
nodes={nodes}
selectedNodeId={selectedNodeId}
onSelectNode={onSelectNode}
/>
<div className="mt-3 h-64 min-h-0">
{root ? (
<Tree<UiNode>
data={[root]}
width="100%"
height={256}
rowHeight={34}
indent={16}
openByDefault
selection={selectedNodeId ?? undefined}
onSelect={(nodes) => {
const selected = nodes[0]?.data;
if (selected) onSelectNode(selected.id);
}}
onMove={handleMove}
disableDrag={(node) => node.id === root.id}
aria-label="UI 节点树"
>
{(props) => (
<TreeRow
{...props}
onSelectNode={onSelectNode}
onOpenContextMenu={(event, nodeId) =>
setContextMenu({
nodeId,
x: event.clientX,
y: event.clientY,
})
}
/>
)}
</Tree>
) : (
<p className="m-0 text-xs text-(--platform-text-soft)">
</p>
)}
</div>
{contextMenu && root ? (
<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 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="block w-full rounded-md px-3 py-2 text-left text-xs hover:bg-black/5"
onClick={() => {
onInsertNode(contextMenu.nodeId);
setContextMenu(null);
}}
>
</button>
{contextMenu.nodeId !== root.id ? (
<>
<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);
setContextMenu(null);
}}
>
</button>
<button
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);
setContextMenu(null);
}}
>
</button>
</>
) : null}
</div>
) : null}
</section>
);
}
@@ -13,17 +13,18 @@ import {
validateLayoutReviewPrerequisites,
} from '../../features/ui-editor/prerequisites';
import type { Node as UiNode } from '../../features/ui-editor/types/Node';
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId';
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 type { UIDesignSuggestionTreeNode } from '../../features/ui-editor/types/UIDesignSuggestionTreeNode';
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,
type NodeMetadataPatch,
useUiEditorState,
} from '../../features/ui-editor/useUiEditorState';
import {
@@ -38,23 +39,31 @@ export function useUiEditorPage(projectPath: string) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
const [activeTool, setActiveTool] = useState<UiEditorToolId>('input');
const [imageOrder, setImageOrder] = useState<UIDesignImageId[]>([]);
const [activeImageId, setActiveImageId] =
useState<UIDesignImageId | null>(null);
const [activeImageId, setActiveImageId] = 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);
const [issues, setIssues] =
useState<UiEditorPrerequisiteIssue[] | null>(null);
const [issues, setIssues] = useState<UiEditorPrerequisiteIssue[] | null>(
null,
);
const [clearOpen, setClearOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState<{
id: UIDesignImageId;
role: UIDesignImageRole | null;
} | null>(null);
const [pendingRemoval, setPendingRemoval] =
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 [recognitionStatus, setRecognitionStatus] = useState<string | null>(
null,
);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -85,16 +94,49 @@ export function useUiEditorPage(projectPath: string) {
visit(node.children);
}
}
for (const tree of editor.state.ui_trees) visit(tree.children);
for (const tree of editor.state.ui_trees) visit([tree.root]);
return counts;
}, [editor.state.ui_trees]);
const treeForActiveImage = activeImageId
? editor.state.ui_trees.find(
(tree) => tree.src_ui_design === activeImageId,
)
? editor.state.ui_trees.find((tree) => tree.src_ui_design === activeImageId)
: null;
function findNodeContext(
node: UiNode,
nodeId: NodeId,
parentSize: { width: number; height: number },
): { node: UiNode; parentSize: { width: number; height: number } } | null {
if (node.id === nodeId) return { node, parentSize };
const width =
parentSize.width *
(node.transform.anchor_max[0] - node.transform.anchor_min[0]) +
node.transform.offset_max[0] -
node.transform.offset_min[0];
const height =
parentSize.height *
(node.transform.anchor_max[1] - node.transform.anchor_min[1]) +
node.transform.offset_max[1] -
node.transform.offset_min[1];
for (const child of node.children) {
const found = findNodeContext(child, nodeId, { width, height });
if (found) return found;
}
return null;
}
const selectedNodeContext = useMemo(() => {
if (!activeImage || !treeForActiveImage || !selectedNodeId) return null;
const ppu = activeImage.pixels_per_unit;
const width = activeImage.pixel_size[0] / ppu;
const height = activeImage.pixel_size[1] / ppu;
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return findNodeContext(treeForActiveImage.root, selectedNodeId, {
width,
height,
});
}, [activeImage, selectedNodeId, treeForActiveImage]);
async function importAssets(imported: ImportedAsset[]) {
if (!importKind || !projectPath) return;
setStatus(null);
@@ -216,12 +258,14 @@ export function useUiEditorPage(projectPath: string) {
}
function selectDesignImage(id: UIDesignImageId) {
setPendingRoleChange(null);
setActiveImageId(id);
setSelectedSpriteId(null);
setSelectedNodeId(null);
}
function selectNode(id: NodeId) {
setSelectedSpriteId(null);
setSelectedNodeId(id);
}
@@ -229,6 +273,80 @@ export function useUiEditorPage(projectPath: string) {
setSelectedNodeId(null);
}
function setNodeTransform(transform: UiNode['transform']) {
if (!activeImageId || !selectedNodeId) return;
return updateNodeTransform(activeImageId, selectedNodeId, transform);
}
function updateNodeTransform(
treeId: UIDesignImageId,
nodeId: NodeId,
transform: UiNode['transform'],
) {
const result = editor.setNodeTransform(treeId, nodeId, transform);
if (!result.ok) setStatus('节点 Transform 更新失败。');
return result;
}
function setNodeMetadata(patch: NodeMetadataPatch) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.setNodeMetadata(activeImageId, selectedNodeId, patch);
if (!result.ok) setStatus('节点信息更新失败。');
return result;
}
function moveNode(
treeId: UIDesignImageId,
nodeId: NodeId,
targetParentId: NodeId,
targetIndex: number,
) {
const result = editor.moveNode(treeId, nodeId, targetParentId, targetIndex);
if (!result.ok) {
setStatus(
result.reason === 'locked'
? '当前操作正在进行,请稍候。'
: result.reason === 'invalid'
? '无法移动到该节点。'
: '节点移动失败。',
);
}
return result;
}
function insertNode(parentId: NodeId) {
if (!activeImageId) return;
const result = editor.insertNode(activeImageId, parentId);
if (!result.ok) {
setStatus('无法新增节点。');
return result;
}
setSelectedNodeId(result.value);
return result;
}
function insertNodeAfter(nodeId: NodeId) {
if (!activeImageId) return;
const result = editor.insertNodeAfter(activeImageId, nodeId);
if (!result.ok) {
setStatus('无法新增同级节点。');
return result;
}
setSelectedNodeId(result.value);
return result;
}
function deleteNode(nodeId: NodeId) {
if (!activeImageId) return;
const result = editor.deleteNode(activeImageId, nodeId);
if (!result.ok) {
setStatus('无法删除该节点。');
return result;
}
if (selectedNodeId === nodeId) setSelectedNodeId(null);
return result;
}
function selectSprite(id: SpriteAssetId) {
setSelectedSpriteId(id);
}
@@ -247,10 +365,32 @@ export function useUiEditorPage(projectPath: string) {
function setImageRole(role: UIDesignImageRole | null) {
if (!activeImageId) return;
const tree = editor.state.ui_trees.find(
(candidate) => candidate.src_ui_design === activeImageId,
);
if (activeImage?.metadata.role === 'Page' && role !== 'Page' && tree) {
setPendingRoleChange({ id: activeImageId, role });
return;
}
editor.setImageRole(activeImageId, role);
setIssues(null);
}
function confirmRoleChange() {
if (!pendingRoleChange) return;
const result = editor.setImageRole(
pendingRoleChange.id,
pendingRoleChange.role,
);
if (!result.ok) {
setStatus('界面角色更新失败。');
return;
}
setPendingRoleChange(null);
setSelectedNodeId(null);
setIssues(null);
}
function setImageSlaveTo(slaveTo: UIDesignImageId | null) {
if (!activeImageId) return;
editor.setImageSlaveTo(activeImageId, slaveTo);
@@ -280,7 +420,7 @@ export function useUiEditorPage(projectPath: string) {
setIsSuggesting(true);
try {
await editor.runWithStateLocked(async (snapshot) => {
const suggestions = await invoke<UIDesignSuggestion[]>(
const suggestions = await invoke<UIDesignSuggestionTreeNode[]>(
'suggest_ui_design_semantic',
{ projectPath, state: snapshot },
);
@@ -288,7 +428,9 @@ export function useUiEditorPage(projectPath: string) {
setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`);
});
} catch (cause) {
setSuggestionStatus(cause instanceof Error ? cause.message : String(cause));
setSuggestionStatus(
cause instanceof Error ? cause.message : String(cause),
);
} finally {
setIsSuggesting(false);
}
@@ -306,12 +448,12 @@ export function useUiEditorPage(projectPath: string) {
});
editor.replaceState(applyRecognitionResult(snapshot, result));
setSelectedNodeId(null);
setRecognitionStatus(
`已替换 ${result.ui_trees.length} 棵界面树。`,
);
setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`);
});
} catch (cause) {
setRecognitionStatus(cause instanceof Error ? cause.message : String(cause));
setRecognitionStatus(
cause instanceof Error ? cause.message : String(cause),
);
} finally {
setIsRecognizing(false);
}
@@ -331,6 +473,7 @@ export function useUiEditorPage(projectPath: string) {
status,
issues,
clearOpen,
pendingRoleChange,
pendingRemoval,
images,
sprites,
@@ -339,11 +482,20 @@ export function useUiEditorPage(projectPath: string) {
pageOptions,
spriteReferenceCounts,
treeForActiveImage,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeParentSize: selectedNodeContext?.parentSize,
selectTool,
selectDesignImage,
selectSprite,
selectNode,
clearNodeSelection,
setNodeTransform,
updateNodeTransform,
setNodeMetadata,
moveNode,
insertNode,
insertNodeAfter,
deleteNode,
openImporter: setImportKind,
closeImporter: () => setImportKind(null),
importAssets,
@@ -358,6 +510,8 @@ export function useUiEditorPage(projectPath: string) {
setImageName,
setImageDescription,
setImageRole,
confirmRoleChange,
cancelRoleChange: () => setPendingRoleChange(null),
setImageSlaveTo,
setSpriteName,
setSpriteAssetType,
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { applyUiDesignSuggestions } from '../src/features/ui-editor/uiDesignSuggestions';
import type { State } from '../src/features/ui-editor/types/State';
function state(): State {
return {
ui_trees: [],
sprite_assets: {},
font_assets: {},
ui_design_images: {
page: {
metadata: { name: '', description: '', role: null, slave_to: null },
path: 'page.png',
pixel_size: [100, 100],
pixels_per_unit: 1,
},
section: {
metadata: {
name: '已有名称',
description: '',
role: 'Section',
slave_to: null,
},
path: 'section.png',
pixel_size: [100, 100],
pixels_per_unit: 1,
},
detail: {
metadata: {
name: '',
description: '已有描述',
role: null,
slave_to: null,
},
path: 'detail.png',
pixel_size: [100, 100],
pixels_per_unit: 1,
},
},
};
}
describe('applyUiDesignSuggestions', () => {
it('masks existing metadata and maps descendants to their owning Page', () => {
const before = state();
const after = applyUiDesignSuggestions(before, [
{
id: 'page',
name: '主页面',
description: '页面描述',
role: 'Page',
children: [
{
id: 'section',
name: '模型名称不应覆盖',
description: '页签描述',
role: 'Section',
children: [
{
id: 'detail',
name: '详情',
description: '模型描述不应覆盖',
role: 'Detail',
children: [],
},
],
},
],
},
]);
expect(after.ui_design_images.page.metadata).toEqual({
name: '主页面',
description: '页面描述',
role: 'Page',
slave_to: null,
});
expect(after.ui_design_images.section.metadata).toEqual({
name: '已有名称',
description: '页签描述',
role: 'Section',
slave_to: 'page',
});
expect(after.ui_design_images.detail.metadata).toEqual({
name: '详情',
description: '已有描述',
role: 'Detail',
slave_to: 'page',
});
expect(before.ui_design_images.page.metadata.name).toBe('');
});
});
@@ -37,13 +37,14 @@ function sprite(id: string): SpriteAsset {
function nodeWithSprite(id: string): Node {
return {
id,
transform: {
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [0, 0],
offset_max: [32, 32],
},
metadata: { name: 'Image', description: '', from: '' },
metadata: { name: 'Image', description: '', status: 'Passed', source: 'Llm' },
components: [
{
Image: {
@@ -169,7 +170,26 @@ describe('useUiEditorState', () => {
},
sprite_assets: { panel: sprite('panel') },
ui_trees: [
{ src_ui_design: 'page', children: [nodeWithSprite('panel')] },
{
src_ui_design: 'page',
root: {
id: 'page-root',
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
metadata: {
name: '页面根节点',
description: '',
status: 'Passed',
source: 'System',
},
components: [],
children: [nodeWithSprite('panel')],
},
},
],
};
const { result } = renderHook(() => useUiEditorState(initial));