重构 UI 编辑器逻辑以增强页面树操作:
- 添加页面树同步逻辑,确保 `Page` 节点与 UI 树一致。 - 实现节点的新增、删除、移动和元数据更新功能。 - 扩展节点 Inspector,支持查看和修改选中节点的名称、描述和变换属性。 - 重构相关测试,覆盖新的页面树和节点操作功能。 - 引入嵌套节点坐标计算逻辑,确保节点相对位置一致性。
This commit is contained in:
@@ -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>
|
||||
|
||||
+388
-134
File diff suppressed because it is too large
Load Diff
+38
-36
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user