新增 UI 编辑器核心模块

- 引入 UI 设计图片和精灵素材批处理准备功能
- 新增界面图和组件前置条件校验逻辑
- 添加与 SpriteBorder 相关的验证和字段编辑组件
- 支持 UI 状态管理,包括设计图和精灵素材的增删改功能
This commit is contained in:
2026-08-13 14:10:16 +08:00
parent f3fc5e1b4b
commit f09fbeba7a
19 changed files with 2443 additions and 0 deletions
@@ -0,0 +1,98 @@
import { invoke } from '@tauri-apps/api/core';
import type { ImportedAsset } from '../../components/AssetImporter';
import type { SpriteAsset } from './types/SpriteAsset';
import type { UIDesignImage } from './types/UIDesignImage';
import type { UIDesignImageId } from './types/UIDesignImageId';
import type { DesignImageInput } from './useUiEditorState';
type ImagePreviewResponse = { dataUrl: string };
export type PreparedImageAsset<T> = {
resource: T;
previewUrl: string;
};
function basename(path: string) {
return path.replaceAll('\\', '/').split('/').at(-1) || 'image';
}
export function decodeImageSize(src: string): Promise<[number, number]> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
if (image.naturalWidth <= 0 || image.naturalHeight <= 0) {
reject(new Error('图片尺寸无效'));
return;
}
resolve([image.naturalWidth, image.naturalHeight]);
};
image.onerror = () => reject(new Error('图片无法解码'));
image.src = src;
});
}
async function readImportedImage(
projectPath: string,
asset: ImportedAsset,
) {
const preview = await invoke<ImagePreviewResponse>(
'read_local_project_image_preview',
{ projectPath, relativePath: asset.localPath },
);
return {
previewUrl: preview.dataUrl,
pixelSize: await decodeImageSize(preview.dataUrl),
};
}
export async function prepareDesignImageBatch(
projectPath: string,
assets: readonly ImportedAsset[],
): Promise<Array<PreparedImageAsset<DesignImageInput>>> {
return Promise.all(
assets.map(async (asset) => {
const { previewUrl, pixelSize } = await readImportedImage(
projectPath,
asset,
);
const image: UIDesignImage = {
metadata: {
name: basename(asset.localPath),
role: null,
slave_to: null,
},
path: asset.localPath,
pixel_size: pixelSize,
pixels_per_unit: 1,
};
return {
resource: { id: asset.id as UIDesignImageId, image },
previewUrl,
};
}),
);
}
export async function prepareSpriteAssetBatch(
projectPath: string,
assets: readonly ImportedAsset[],
): Promise<Array<PreparedImageAsset<SpriteAsset>>> {
return Promise.all(
assets.map(async (asset) => {
const { previewUrl, pixelSize } = await readImportedImage(
projectPath,
asset,
);
const resource: SpriteAsset = {
asset_id: asset.id,
metadata: { name: basename(asset.localPath), asset_type: '' },
path: asset.localPath,
pixel_size: pixelSize,
pixels_per_unit: 1,
border: { left: 0, right: 0, top: 0, bottom: 0 },
};
return { resource, previewUrl };
}),
);
}
@@ -0,0 +1,147 @@
import type { State } from './types/State';
import type { UIDesignImageId } from './types/UIDesignImageId';
export type UiEditorPrerequisiteIssue = {
code: string;
message: string;
resourceId?: string;
};
function imageResourceIssues(state: State): UiEditorPrerequisiteIssue[] {
const issues: UiEditorPrerequisiteIssue[] = [];
for (const [id, image] of Object.entries(state.ui_design_images)) {
if (
!image.path.trim() ||
image.pixel_size.some(
(value) => !Number.isFinite(value) || value <= 0,
) ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
issues.push({
code: 'invalid-design-image',
message: '界面图无法读取或尺寸无效',
resourceId: id,
});
}
}
return issues;
}
export function validateComponentRecognitionPrerequisites(
state: State,
): UiEditorPrerequisiteIssue[] {
const ids = Object.keys(state.ui_design_images) as UIDesignImageId[];
const issues = imageResourceIssues(state);
if (ids.length === 0) {
issues.push({ code: 'missing-design-image', message: '请先导入界面图' });
}
if (ids.length > 4) {
issues.push({ code: 'design-image-limit', message: '界面图最多 4 张' });
}
for (const id of ids) {
const image = state.ui_design_images[id]!;
const { role, slave_to: slaveTo } = image.metadata;
if (role === 'Page' && slaveTo !== null) {
issues.push({
code: 'page-has-slave-to',
message: '主页面不能设置归属页面',
resourceId: id,
});
} else if (role !== null && role !== 'Page') {
if (slaveTo === null) {
issues.push({
code: 'missing-slave-to',
message: '该界面角色需要选择归属主页面',
resourceId: id,
});
} else if (slaveTo === id) {
issues.push({
code: 'self-slave-to',
message: '界面不能归属于自身',
resourceId: id,
});
} else if (
state.ui_design_images[slaveTo]?.metadata.role !== 'Page'
) {
issues.push({
code: 'invalid-slave-to',
message: '归属页面必须是有效主页面',
resourceId: id,
});
}
}
}
return issues;
}
export function validateAssetRecognitionPrerequisites(
state: State,
): UiEditorPrerequisiteIssue[] {
const issues = validateComponentRecognitionPrerequisites(state);
if (state.ui_trees.length === 0) {
issues.push({
code: 'missing-ui-tree',
message: '请先完成组件识别',
});
}
for (const tree of state.ui_trees) {
if (!(tree.src_ui_design in state.ui_design_images)) {
issues.push({
code: 'missing-tree-design-image',
message: '组件树引用的界面图不存在',
resourceId: tree.src_ui_design,
});
}
}
return issues;
}
export function validateLayoutGenerationPrerequisites(
state: State,
): UiEditorPrerequisiteIssue[] {
const issues = validateAssetRecognitionPrerequisites(state);
if (Object.keys(state.sprite_assets).length === 0) {
issues.push({
code: 'missing-sprite-assets',
message: '请先导入并绑定独立素材',
});
}
const visit = (nodes: State['ui_trees'][number]['children']) => {
for (const node of nodes) {
for (const component of node.components) {
if (
'Image' in component &&
component.Image.target_graphic !== null &&
!(component.Image.target_graphic in state.sprite_assets)
) {
issues.push({
code: 'missing-target-graphic',
message: '图片组件引用的独立素材不存在',
resourceId: component.Image.target_graphic,
});
}
if (
'Text' in component &&
component.Text.font !== null &&
!(component.Text.font in state.font_assets)
) {
issues.push({
code: 'missing-font',
message: '文本组件引用的字体不存在',
resourceId: component.Text.font,
});
}
}
visit(node.children);
}
};
for (const tree of state.ui_trees) visit(tree.children);
return issues;
}
export function validateLayoutReviewPrerequisites(
state: State,
): UiEditorPrerequisiteIssue[] {
return validateLayoutGenerationPrerequisites(state);
}
@@ -0,0 +1,68 @@
import type { SpriteBorder } from './types/SpriteBorder';
const MAX_U32 = 4_294_967_295;
export type SpriteBorderValidation =
| { ok: true }
| { ok: false; message: string; axis?: 'horizontal' | 'vertical' };
export function spriteBorderIsClear(border: SpriteBorder) {
return (
border.left === 0 &&
border.right === 0 &&
border.top === 0 &&
border.bottom === 0
);
}
export function validateSpriteBorder(
pixelSize: [number, number],
border: SpriteBorder,
): SpriteBorderValidation {
const values = [border.left, border.right, border.top, border.bottom];
if (
values.some(
(value) =>
!Number.isInteger(value) || value < 0 || value > MAX_U32,
)
) {
return { ok: false, message: '边距必须是非负整数像素' };
}
if (spriteBorderIsClear(border)) return { ok: true };
const width = Math.floor(pixelSize[0]);
const height = Math.floor(pixelSize[1]);
if (border.left + border.right + 1 > width) {
return {
ok: false,
axis: 'horizontal',
message: '水平中心至少保留 1 px',
};
}
if (border.top + border.bottom + 1 > height) {
return {
ok: false,
axis: 'vertical',
message: '垂直中心至少保留 1 px',
};
}
return { ok: true };
}
export function spriteBorderRatioPreset(
pixelSize: [number, number],
centerRatio: 1 | 2,
): SpriteBorder | null {
const width = Math.floor(pixelSize[0]);
const height = Math.floor(pixelSize[1]);
const divisor = centerRatio + 2;
const horizontal = Math.floor(width / divisor);
const vertical = Math.floor(height / divisor);
if (horizontal === 0 || vertical === 0) return null;
return {
left: horizontal,
right: horizontal,
top: vertical,
bottom: vertical,
};
}
@@ -0,0 +1,416 @@
import { useCallback, useRef, useState } from 'react';
import type { Component } from './types/Component';
import { validateSpriteBorder } from './spriteBorder';
import type { Node } from './types/Node';
import type { SpriteAsset } from './types/SpriteAsset';
import type { SpriteAssetId } from './types/SpriteAssetId';
import type { SpriteBorder } from './types/SpriteBorder';
import type { State } from './types/State';
import type { UIDesignImage } from './types/UIDesignImage';
import type { UIDesignImageId } from './types/UIDesignImageId';
import type { UIDesignImageRole } from './types/UIDesignImageRole';
export const EMPTY_UI_EDITOR_STATE: State = {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
};
export type UiEditorOperationFailureReason =
| 'locked'
| 'duplicate'
| 'limit'
| 'missing'
| 'invalid';
export type UiEditorOperationResult<T = undefined> =
| { ok: true; value: T }
| { ok: false; reason: UiEditorOperationFailureReason };
type UiEditorOperationFailure = Extract<
UiEditorOperationResult,
{ ok: false }
>;
export type DesignImageInput = {
id: UIDesignImageId;
image: UIDesignImage;
};
export type RemovalImpact = {
removedResourceCount: number;
removedTreeCount: number;
clearedSlaveToCount: number;
clearedTargetGraphicCount: number;
};
function cloneState(state: State): State {
return structuredClone(state);
}
function visitComponents(
nodes: Node[],
visit: (component: Component) => void,
) {
for (const node of nodes) {
for (const component of node.components) {
visit(component);
}
visitComponents(node.children, visit);
}
}
export function designImageRemovalImpact(
state: State,
id: UIDesignImageId,
): RemovalImpact {
return {
removedResourceCount: id in state.ui_design_images ? 1 : 0,
removedTreeCount: state.ui_trees.filter(
(tree) => tree.src_ui_design === id,
).length,
clearedSlaveToCount: Object.values(state.ui_design_images).filter(
(image) => image.metadata.slave_to === id,
).length,
clearedTargetGraphicCount: 0,
};
}
export function spriteAssetRemovalImpact(
state: State,
id: SpriteAssetId,
): RemovalImpact {
let clearedTargetGraphicCount = 0;
for (const tree of state.ui_trees) {
visitComponents(tree.children, (component) => {
if (
'Image' in component &&
component.Image.target_graphic === id
) {
clearedTargetGraphicCount += 1;
}
});
}
return {
removedResourceCount: id in state.sprite_assets ? 1 : 0,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount,
};
}
function validImageResource(image: UIDesignImage) {
return (
image.path.trim().length > 0 &&
image.metadata.name.trim().length > 0 &&
image.pixel_size.every(
(value) => Number.isFinite(value) && value > 0,
) &&
Number.isFinite(image.pixels_per_unit) &&
image.pixels_per_unit > 0
);
}
function validSpriteResource(sprite: SpriteAsset) {
return (
sprite.asset_id.trim().length > 0 &&
sprite.path.trim().length > 0 &&
sprite.metadata.name.trim().length > 0 &&
sprite.pixel_size.every(
(value) => Number.isFinite(value) && value > 0,
) &&
Number.isFinite(sprite.pixels_per_unit) &&
sprite.pixels_per_unit > 0 &&
validateSpriteBorder(sprite.pixel_size, sprite.border).ok
);
}
export function useUiEditorState(
initialState: State = EMPTY_UI_EDITOR_STATE,
) {
const [state, setState] = useState<State>(() => cloneState(initialState));
const [isLocked, setIsLocked] = useState(false);
const stateRef = useRef(state);
const isLockedRef = useRef(false);
stateRef.current = state;
const commit = useCallback((nextState: State) => {
stateRef.current = nextState;
setState(nextState);
}, []);
const guard = useCallback((): UiEditorOperationFailure | null => {
// Every semantic write exits before reading or committing State while locked.
return isLockedRef.current ? { ok: false, reason: 'locked' } : null;
}, []);
const runWithStateLocked = useCallback(
async <T>(operation: (snapshot: State) => Promise<T>): Promise<T> => {
if (isLockedRef.current) {
throw new Error('UI editor State is already locked');
}
// Acquire synchronously so another operation cannot enter before React
// renders isLocked=true. The callback receives an isolated, stable
// snapshot; all editor writes remain blocked until it settles.
isLockedRef.current = true;
setIsLocked(true);
const snapshot = cloneState(stateRef.current);
try {
return await operation(snapshot);
} finally {
isLockedRef.current = false;
setIsLocked(false);
}
},
[],
);
const setImageName = useCallback(
(id: UIDesignImageId, name: string): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.name = name;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setImageRole = useCallback(
(
id: UIDesignImageId,
role: UIDesignImageRole | null,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.role = role;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setImageSlaveTo = useCallback(
(
id: UIDesignImageId,
slaveTo: UIDesignImageId | null,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
if (slaveTo !== null && !(slaveTo in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.slave_to = slaveTo;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const addDesignImages = useCallback(
(entries: readonly DesignImageInput[]): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
const ids = entries.map((entry) => entry.id);
if (new Set(ids).size !== ids.length) {
return { ok: false, reason: 'duplicate' };
}
if (ids.some((id) => id in current.ui_design_images)) {
return { ok: false, reason: 'duplicate' };
}
if (Object.keys(current.ui_design_images).length + entries.length > 4) {
return { ok: false, reason: 'limit' };
}
if (entries.some((entry) => !validImageResource(entry.image))) {
return { ok: false, reason: 'invalid' };
}
const next = cloneState(current);
for (const entry of entries) {
next.ui_design_images[entry.id] = structuredClone(entry.image);
}
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const addSpriteAssets = useCallback(
(assets: readonly SpriteAsset[]): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
const ids = assets.map((asset) => asset.asset_id);
if (
new Set(ids).size !== ids.length ||
ids.some((id) => id in current.sprite_assets)
) {
return { ok: false, reason: 'duplicate' };
}
if (assets.some((asset) => !validSpriteResource(asset))) {
return { ok: false, reason: 'invalid' };
}
const next = cloneState(current);
for (const asset of assets) {
next.sprite_assets[asset.asset_id] = structuredClone(asset);
}
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setSpriteName = useCallback(
(id: SpriteAssetId, name: string): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.sprite_assets)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.sprite_assets[id]!.metadata.name = name;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setSpriteAssetType = useCallback(
(id: SpriteAssetId, assetType: string): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.sprite_assets)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.sprite_assets[id]!.metadata.asset_type = assetType;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setSpriteBorder = useCallback(
(
id: SpriteAssetId,
border: SpriteBorder,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
const sprite = current.sprite_assets[id];
if (!sprite) return { ok: false, reason: 'missing' };
if (!validateSpriteBorder(sprite.pixel_size, border).ok) {
return { ok: false, reason: 'invalid' };
}
const next = cloneState(current);
next.sprite_assets[id]!.border = structuredClone(border);
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const removeDesignImage = useCallback(
(
id: UIDesignImageId,
options: { dryRun: boolean },
): UiEditorOperationResult<RemovalImpact> => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
const impact = designImageRemovalImpact(current, id);
if (options.dryRun) return { ok: true, value: impact };
const next = cloneState(current);
delete next.ui_design_images[id];
next.ui_trees = next.ui_trees.filter(
(tree) => tree.src_ui_design !== id,
);
for (const image of Object.values(next.ui_design_images)) {
if (image.metadata.slave_to === id) image.metadata.slave_to = null;
}
commit(next);
return { ok: true, value: impact };
},
[commit, guard],
);
const removeSpriteAsset = useCallback(
(
id: SpriteAssetId,
options: { dryRun: boolean },
): UiEditorOperationResult<RemovalImpact> => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.sprite_assets)) {
return { ok: false, reason: 'missing' };
}
const impact = spriteAssetRemovalImpact(current, id);
if (options.dryRun) return { ok: true, value: impact };
const next = cloneState(current);
delete next.sprite_assets[id];
for (const tree of next.ui_trees) {
visitComponents(tree.children, (component) => {
if (
'Image' in component &&
component.Image.target_graphic === id
) {
component.Image.target_graphic = null;
}
});
}
commit(next);
return { ok: true, value: impact };
},
[commit, guard],
);
const clearState = useCallback((): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
commit(cloneState(EMPTY_UI_EDITOR_STATE));
return { ok: true, value: undefined };
}, [commit, guard]);
return {
state,
isLocked,
runWithStateLocked,
setImageName,
setImageRole,
setImageSlaveTo,
addDesignImages,
addSpriteAssets,
setSpriteName,
setSpriteAssetType,
setSpriteBorder,
removeDesignImage,
removeSpriteAsset,
clearState,
};
}
@@ -0,0 +1,89 @@
import { AssetImporter } from '../../../components/AssetImporter';
import { ThemedModal } from '../../../components/modal/ThemedModal';
import type { UiEditorPageController } from '../useUiEditorPage';
export function EditorDialogs({
controller,
}: {
controller: UiEditorPageController;
}) {
const impact = controller.pendingRemoval?.impact;
return (
<>
<AssetImporter
projectPath={controller.projectPath}
open={controller.importKind !== null}
onClose={controller.closeImporter}
onImport={(assets) => {
void controller.importAssets(assets);
controller.closeImporter();
}}
maxItems={controller.importKind === 'design-image' ? 4 : 100}
maxFileSize={20 * 1024 * 1024}
acceptedMediaTypes={['image/png', 'image/jpeg', 'image/webp']}
/>
<ThemedModal
open={controller.clearOpen}
onClose={controller.closeClearDialog}
ariaLabel="确认清空 UI Editor State"
panelClassName="w-[420px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold"> State</h2>
<p className="text-sm leading-6 text-(--platform-text-soft)">
State
</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.closeClearDialog}
>
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.clearState}
>
</button>
</div>
</ThemedModal>
<ThemedModal
open={controller.pendingRemoval !== null}
onClose={controller.cancelRemoval}
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)">
State 使 State
</p>
<ul className="rounded-lg bg-black/4 p-3 pl-7 text-xs leading-6">
<li>{impact?.removedTreeCount ?? 0}</li>
<li>{impact?.clearedSlaveToCount ?? 0}</li>
<li>{impact?.clearedTargetGraphicCount ?? 0}</li>
</ul>
<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.cancelRemoval}
>
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.confirmRemoval}
>
</button>
</div>
</ThemedModal>
</>
);
}
@@ -0,0 +1,133 @@
import { Image as ImageIcon, Plus } from 'lucide-react';
import type { UiEditorPageController } from '../useUiEditorPage';
import { UiTreePanel } from './UiTreePanel';
export function InputSidebar({ controller }: { controller: UiEditorPageController }) {
// TODO: AssetImporter 支持字体文件后,在这里增加独立的项目字体面板。
const {
projectPath,
editor,
imageOrder,
activeImageId,
previewUrls,
images,
sprites,
spriteReferenceCounts,
activeTool,
treeForActiveImage,
} = controller;
return (
<aside className="min-h-0 overflow-y-auto border-r border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3">
<section className="rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
<div className="flex items-center justify-between">
<div>
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
Input
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
</div>
<button
type="button"
className="grid size-8 place-items-center rounded-lg border border-orange-200 bg-orange-50 text-orange-800 disabled:opacity-40"
aria-label="导入界面图"
disabled={!projectPath || imageOrder.length >= 4 || editor.isLocked}
onClick={() => controller.openImporter('design-image')}
>
<Plus size={15} />
</button>
</div>
<div className="mt-3 space-y-2">
{imageOrder.length > 0 ? (
imageOrder.map((id) => {
const image = images[id];
if (!image) return null;
return (
<button
key={id}
type="button"
className={`flex w-full items-center gap-2 rounded-lg border p-2 text-left ${
activeImageId === id
? 'border-orange-300 bg-orange-50'
: 'border-(--platform-subpanel-border) bg-white/45'
}`}
onClick={() => controller.selectDesignImage(id)}
>
<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" />
) : (
<ImageIcon size={15} />
)}
</div>
<span className="min-w-0 flex-1">
<strong className="block truncate text-xs">
{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]}
</span>
</span>
</button>
);
})
) : (
<p className="m-0 rounded-lg border border-dashed border-(--platform-subpanel-border) p-4 text-center text-xs text-(--platform-text-soft)">
14
</p>
)}
</div>
</section>
<section className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
<div className="flex items-center justify-between">
<div>
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
Assets
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
</div>
<button
type="button"
className="grid size-8 place-items-center rounded-lg border border-orange-200 bg-orange-50 text-orange-800 disabled:opacity-40"
aria-label="导入独立素材"
disabled={!projectPath || editor.isLocked}
onClick={() => controller.openImporter('sprite')}
>
<Plus size={15} />
</button>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{Object.values(sprites).map((sprite) => (
<button
key={sprite.asset_id}
type="button"
className="relative aspect-square overflow-hidden rounded-lg border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]"
title={`${sprite.metadata.name} · 引用 ${spriteReferenceCounts[sprite.asset_id] ?? 0}`}
onClick={() => controller.selectSprite(sprite.asset_id)}
>
{previewUrls[sprite.asset_id] ? (
<img
src={previewUrls[sprite.asset_id]}
alt={sprite.metadata.name}
className="size-full object-contain"
/>
) : (
<ImageIcon size={18} className="m-auto" />
)}
<span className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-1 py-1 text-[9px] text-white">
{sprite.metadata.name}
</span>
</button>
))}
</div>
</section>
{activeTool !== 'input' ? (
<UiTreePanel nodes={treeForActiveImage?.children ?? null} />
) : null}
</aside>
);
}
@@ -0,0 +1,205 @@
import { Boxes, Trash2 } from 'lucide-react';
import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole';
import { UI_DESIGN_IMAGE_ROLES } from '../../model';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { PrerequisiteIssues } from '../PrerequisiteIssues';
import { SpriteBorderEditor } from './SpriteBorder/SpriteBorderEditor';
export function InspectorSidebar({
controller,
}: {
controller: UiEditorPageController;
}) {
const {
activeImage,
activeImageId,
selectedSprite,
selectedSpriteId,
previewUrls,
spriteReferenceCounts,
pageOptions,
} = controller;
return (
<aside className="flex min-h-0 flex-col overflow-y-auto border-l border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-4">
<div className="flex items-center gap-2">
<Boxes size={15} />
<h2 className="m-0 text-sm font-semibold">
{selectedSprite ? '当前素材' : '当前界面'}
</h2>
</div>
{selectedSprite && selectedSpriteId ? (
<div className="mt-4 space-y-4">
<div className="grid aspect-square place-items-center overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]">
{previewUrls[selectedSpriteId] ? (
<img
src={previewUrls[selectedSpriteId]}
alt={selectedSprite.metadata.name}
className="size-full object-contain"
/>
) : null}
</div>
<label className="block text-[11px] font-semibold text-(--platform-text-soft)">
<input
className="mt-1 h-9 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-xs text-(--platform-text-strong)"
value={selectedSprite.metadata.name}
onChange={(event) => controller.setSpriteName(event.target.value)}
/>
</label>
<label className="block text-[11px] font-semibold text-(--platform-text-soft)">
<input
className="mt-1 h-9 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-xs text-(--platform-text-strong)"
value={selectedSprite.metadata.asset_type}
placeholder="未设置"
onChange={(event) =>
controller.setSpriteAssetType(event.target.value)
}
/>
</label>
<div className="rounded-lg bg-black/4 p-2 text-xs">
<span className="block text-[10px] text-(--platform-text-soft)">
</span>
{spriteReferenceCounts[selectedSpriteId] ?? 0}
</div>
<SpriteBorderEditor
spriteId={selectedSpriteId}
border={selectedSprite.border}
pixelSize={selectedSprite.pixel_size}
previewUrl={previewUrls[selectedSpriteId]}
onChange={controller.setSpriteBorder}
/>
<ResourceId value={selectedSpriteId} />
</div>
) : activeImage && activeImageId ? (
<div className="mt-4 space-y-4">
<label className="block text-[11px] font-semibold text-(--platform-text-soft)">
<input
className="mt-1 h-9 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-xs text-(--platform-text-strong)"
value={activeImage.metadata.name}
onChange={(event) => controller.setImageName(event.target.value)}
/>
</label>
<div className="grid grid-cols-2 gap-2 text-xs">
<Metric label="宽度" value={`${activeImage.pixel_size[0]} px`} />
<Metric label="高度" value={`${activeImage.pixel_size[1]} px`} />
</div>
<label className="block text-[11px] font-semibold text-(--platform-text-soft)">
<select
className="mt-1 h-9 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-xs text-(--platform-text-strong)"
value={activeImage.metadata.role ?? ''}
onChange={(event) =>
controller.setImageRole(
(event.target.value || null) as UIDesignImageRole | null,
)
}
>
<option value=""></option>
{UI_DESIGN_IMAGE_ROLES.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</label>
{activeImage.metadata.role !== 'Page' ||
activeImage.metadata.slave_to !== null ? (
<label className="block text-[11px] font-semibold text-(--platform-text-soft)">
<select
className="mt-1 h-9 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-xs text-(--platform-text-strong)"
value={activeImage.metadata.slave_to ?? ''}
onChange={(event) =>
controller.setImageSlaveTo(
(event.target.value || null) as UIDesignImageId | null,
)
}
>
<option value=""></option>
{pageOptions
.filter(([id]) => id !== activeImageId)
.map(([id, image]) => (
<option key={id} value={id}>
{image.metadata.name}
</option>
))}
</select>
</label>
) : null}
<ResourceId value={activeImageId} />
</div>
) : (
<p className="mt-4 text-xs text-(--platform-text-soft)">
</p>
)}
{controller.issues ? (
<div className="mt-5">
<PrerequisiteIssues issues={controller.issues} />
</div>
) : null}
{selectedSprite && selectedSpriteId ? (
<DeleteResourceButton
label="删除"
disabled={controller.editor.isLocked}
onClick={() => controller.requestSpriteRemoval(selectedSpriteId)}
/>
) : activeImage && activeImageId ? (
<DeleteResourceButton
label="删除"
disabled={controller.editor.isLocked}
onClick={() => controller.requestDesignImageRemoval(activeImageId)}
/>
) : null}
</aside>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg bg-black/4 p-2">
<span className="block text-[10px] text-(--platform-text-soft)">
{label}
</span>
{value}
</div>
);
}
function ResourceId({ value }: { value: string }) {
return (
<div className="rounded-lg bg-black/4 p-2 font-mono text-[10px] break-all text-(--platform-text-soft)">
{value}
</div>
);
}
function DeleteResourceButton({
label,
disabled,
onClick,
}: {
label: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<div className="mt-auto pt-4">
<button
type="button"
className="flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 text-xs font-semibold text-red-700 disabled:cursor-not-allowed disabled:opacity-40"
disabled={disabled}
onClick={onClick}
>
<Trash2 size={13} />
{label}
</button>
</div>
);
}
@@ -0,0 +1,115 @@
import { ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
spriteBorderIsClear,
spriteBorderRatioPreset,
} from '../../../../../features/ui-editor/spriteBorder';
import type { SpriteAssetId } from '../../../../../features/ui-editor/types/SpriteAssetId';
import type { SpriteBorder } from '../../../../../features/ui-editor/types/SpriteBorder';
import { SpriteBorderFields } from './SpriteBorderFields';
import { SpriteBorderGuideEditor } from './SpriteBorderGuideEditor';
const CLEAR_BORDER: SpriteBorder = { left: 0, right: 0, top: 0, bottom: 0 };
export function SpriteBorderEditor({
spriteId,
border,
pixelSize,
previewUrl,
onChange,
}: {
spriteId: SpriteAssetId;
border: SpriteBorder;
pixelSize: [number, number];
previewUrl?: string;
onChange: (border: SpriteBorder) => void;
}) {
const [open, setOpen] = useState(() => !spriteBorderIsClear(border));
const previousSpriteId = useRef(spriteId);
useEffect(() => {
if (previousSpriteId.current === spriteId) return;
previousSpriteId.current = spriteId;
setOpen(!spriteBorderIsClear(border));
}, [border, spriteId]);
const preset121 = spriteBorderRatioPreset(pixelSize, 2);
const preset111 = spriteBorderRatioPreset(pixelSize, 1);
return (
<section className="overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-white/35">
<button
type="button"
className="flex h-10 w-full items-center justify-between px-3 text-left text-xs font-semibold"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<span></span>
<span className="flex items-center gap-2 text-[10px] font-normal text-(--platform-text-soft)">
{spriteBorderIsClear(border)
? '无边距'
: `${border.left} / ${border.right} / ${border.top} / ${border.bottom}`}
<ChevronDown
size={14}
className={`transition-transform ${open ? 'rotate-180' : ''}`}
/>
</span>
</button>
{open ? (
<div className="space-y-3 border-t border-(--platform-subpanel-border) p-3">
<div className="flex gap-2">
<PresetButton
label="1:2:1"
disabled={!preset121}
onClick={() => preset121 && onChange(preset121)}
/>
<PresetButton
label="1:1:1"
disabled={!preset111}
onClick={() => preset111 && onChange(preset111)}
/>
<PresetButton
label="清除"
disabled={spriteBorderIsClear(border)}
onClick={() => onChange(CLEAR_BORDER)}
/>
</div>
<SpriteBorderGuideEditor
border={border}
pixelSize={pixelSize}
previewUrl={previewUrl}
onChange={onChange}
/>
<SpriteBorderFields
border={border}
pixelSize={pixelSize}
onChange={onChange}
/>
</div>
) : null}
</section>
);
}
function PresetButton({
label,
disabled,
onClick,
}: {
label: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className="h-8 flex-1 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-[10px] disabled:cursor-not-allowed disabled:opacity-35"
disabled={disabled}
onClick={onClick}
>
{label}
</button>
);
}
@@ -0,0 +1,134 @@
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { validateSpriteBorder } from '../../../../../features/ui-editor/spriteBorder';
import type { SpriteBorder } from '../../../../../features/ui-editor/types/SpriteBorder';
type BorderSide = keyof SpriteBorder;
type BorderDraft = Record<BorderSide, string>;
const SIDES: Array<{ side: BorderSide; label: string }> = [
{ side: 'left', label: '左' },
{ side: 'right', label: '右' },
{ side: 'top', label: '上' },
{ side: 'bottom', label: '下' },
];
function borderToDraft(border: SpriteBorder): BorderDraft {
return Object.fromEntries(
SIDES.map(({ side }) => [side, String(border[side])]),
) as BorderDraft;
}
function draftToBorder(draft: BorderDraft): SpriteBorder | null {
if (SIDES.some(({ side }) => draft[side].trim() === '')) return null;
return Object.fromEntries(
SIDES.map(({ side }) => [side, Number(draft[side])]),
) as SpriteBorder;
}
export function SpriteBorderFields({
border,
pixelSize,
onChange,
}: {
border: SpriteBorder;
pixelSize: [number, number];
onChange: (border: SpriteBorder) => void;
}) {
const [draft, setDraft] = useState(() => borderToDraft(border));
const groupRef = useRef<HTMLDivElement>(null);
useEffect(() => setDraft(borderToDraft(border)), [border]);
const parsed = draftToBorder(draft);
const validation = parsed
? validateSpriteBorder(pixelSize, parsed)
: { ok: false as const, message: '请输入完整的非负整数像素' };
function commit() {
if (!parsed || !validation.ok) return false;
onChange(parsed);
return true;
}
function reset() {
setDraft(borderToDraft(border));
}
function handleKeyDown(
side: BorderSide,
event: KeyboardEvent<HTMLInputElement>,
) {
if (event.key === 'Escape') {
event.preventDefault();
reset();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
commit();
return;
}
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
const step = event.shiftKey ? 10 : 1;
const direction = event.key === 'ArrowUp' ? 1 : -1;
const current = Number(draft[side]) || 0;
setDraft((value) => ({
...value,
[side]: String(Math.max(0, current + direction * step)),
}));
}
return (
<>
<div
ref={groupRef}
className="grid grid-cols-2 gap-2"
onBlur={(event) => {
if (
!groupRef.current?.contains(event.relatedTarget) &&
!commit()
) {
reset();
}
}}
>
{SIDES.map(({ side, label }) => {
const invalid =
!validation.ok &&
(!('axis' in validation) ||
validation.axis ===
(side === 'left' || side === 'right'
? 'horizontal'
: 'vertical'));
return (
<label key={side} className="text-[10px] text-(--platform-text-soft)">
{label}
<input
type="text"
inputMode="numeric"
className={`mt-1 h-8 w-full rounded-lg border bg-white/70 px-2 text-xs text-(--platform-text-strong) outline-none ${
invalid
? 'border-red-400 focus:border-red-500'
: 'border-(--platform-subpanel-border) focus:border-orange-400'
}`}
value={draft[side]}
onChange={(event) =>
setDraft((value) => ({
...value,
[side]: event.target.value,
}))
}
onKeyDown={(event) => handleKeyDown(side, event)}
/>
</label>
);
})}
</div>
{!validation.ok ? (
<p className="m-0 text-[10px] text-red-600">{validation.message}</p>
) : null}
</>
);
}
@@ -0,0 +1,212 @@
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type { SpriteBorder } from '../../../../../features/ui-editor/types/SpriteBorder';
type BorderSide = keyof SpriteBorder;
const SIDES: BorderSide[] = ['left', 'right', 'top', 'bottom'];
export function SpriteBorderGuideEditor({
border,
pixelSize,
previewUrl,
onChange,
}: {
border: SpriteBorder;
pixelSize: [number, number];
previewUrl?: string;
onChange: (border: SpriteBorder) => void;
}) {
const [draft, setDraft] = useState(border);
const [active, setActive] = useState<BorderSide | null>(null);
const activeRef = useRef<BorderSide | null>(null);
const draftRef = useRef(border);
const previewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setDraft(border);
draftRef.current = border;
activeRef.current = null;
setActive(null);
}, [border]);
if (!previewUrl) {
return (
<p className="m-0 rounded-lg bg-black/4 p-3 text-center text-[10px] text-(--platform-text-soft)">
使
</p>
);
}
function update(side: BorderSide, clientX: number, clientY: number) {
const bounds = previewRef.current?.getBoundingClientRect();
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
const [width, height] = pixelSize.map(Math.floor) as [number, number];
const current = draftRef.current;
let value: number;
if (side === 'left') {
value = Math.round(((clientX - bounds.left) / bounds.width) * width);
value = Math.min(Math.max(value, 0), width - current.right - 1);
} else if (side === 'right') {
value = Math.round(((bounds.right - clientX) / bounds.width) * width);
value = Math.min(Math.max(value, 0), width - current.left - 1);
} else if (side === 'top') {
value = Math.round(((clientY - bounds.top) / bounds.height) * height);
value = Math.min(Math.max(value, 0), height - current.bottom - 1);
} else {
value = Math.round(((bounds.bottom - clientY) / bounds.height) * height);
value = Math.min(Math.max(value, 0), height - current.top - 1);
}
const next = { ...current, [side]: value };
draftRef.current = next;
setDraft(next);
}
function start(side: BorderSide, event: PointerEvent<HTMLButtonElement>) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
activeRef.current = side;
setActive(side);
update(side, event.clientX, event.clientY);
}
function move(side: BorderSide, event: PointerEvent<HTMLButtonElement>) {
if (
activeRef.current !== side ||
!event.currentTarget.hasPointerCapture(event.pointerId)
) {
return;
}
update(side, event.clientX, event.clientY);
}
function finish(event: PointerEvent<HTMLButtonElement>) {
if (!activeRef.current) return;
activeRef.current = null;
setActive(null);
event.currentTarget.releasePointerCapture(event.pointerId);
onChange(draftRef.current);
}
function cancel() {
if (!activeRef.current) return;
activeRef.current = null;
setActive(null);
draftRef.current = border;
setDraft(border);
}
return (
<div className="grid max-h-[220px] place-items-center overflow-hidden rounded-lg bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px] p-2">
<div
ref={previewRef}
className="relative inline-flex max-h-[204px] max-w-full touch-none select-none overflow-hidden"
>
<img
src={previewUrl}
alt=""
draggable={false}
className="block max-h-[204px] max-w-full object-contain"
/>
<GuideOverlay border={draft} pixelSize={pixelSize} />
{SIDES.map((side) => (
<GuideHandle
key={side}
side={side}
border={draft}
pixelSize={pixelSize}
active={active === side}
onPointerDown={(event) => start(side, event)}
onPointerMove={(event) => move(side, event)}
onPointerUp={finish}
onPointerCancel={cancel}
onLostPointerCapture={cancel}
onKeyDown={(event) => {
if (event.key === 'Escape') cancel();
}}
/>
))}
</div>
</div>
);
}
function GuideOverlay({
border,
pixelSize: [width, height],
}: {
border: SpriteBorder;
pixelSize: [number, number];
}) {
return (
<div className="pointer-events-none absolute inset-0">
<Shade side="left" size={(border.left / width) * 100} />
<Shade side="right" size={(border.right / width) * 100} />
<Shade side="top" size={(border.top / height) * 100} />
<Shade side="bottom" size={(border.bottom / height) * 100} />
</div>
);
}
function Shade({ side, size }: { side: BorderSide; size: number }) {
const vertical = side === 'left' || side === 'right';
return (
<div
className={`absolute bg-orange-500/12 ${
vertical ? 'inset-y-0' : 'inset-x-0'
} ${side === 'left' ? 'left-0' : side === 'right' ? 'right-0' : side === 'top' ? 'top-0' : 'bottom-0'}`}
style={vertical ? { width: `${size}%` } : { height: `${size}%` }}
/>
);
}
function GuideHandle({
side,
border,
pixelSize,
active,
...events
}: {
side: BorderSide;
border: SpriteBorder;
pixelSize: [number, number];
active: boolean;
} & Pick<
React.ComponentProps<'button'>,
| 'onPointerDown'
| 'onPointerMove'
| 'onPointerUp'
| 'onPointerCancel'
| 'onLostPointerCapture'
| 'onKeyDown'
>) {
const vertical = side === 'left' || side === 'right';
const value = border[side];
const position = `${(value / (vertical ? pixelSize[0] : pixelSize[1])) * 100}%`;
const style =
side === 'left'
? { left: position }
: side === 'right'
? { right: position }
: side === 'top'
? { top: position }
: { bottom: position };
return (
<button
type="button"
aria-label={`调整${side}边距`}
className={`absolute z-10 border-0 bg-orange-500 p-0 outline-none ${
vertical
? 'inset-y-0 w-0.5 -translate-x-1/2 cursor-ew-resize'
: 'inset-x-0 h-0.5 -translate-y-1/2 cursor-ns-resize'
}`}
style={style}
{...events}
>
{active ? (
<span className="pointer-events-none absolute top-1 left-1 rounded bg-orange-600 px-1 py-0.5 text-[9px] whitespace-nowrap text-white">
{side.slice(0, 1).toUpperCase()} {value}
</span>
) : null}
</button>
);
}
@@ -0,0 +1,27 @@
import type { UiEditorPrerequisiteIssue } from '../../../features/ui-editor/prerequisites';
export function PrerequisiteIssues({
issues,
}: {
issues: UiEditorPrerequisiteIssue[];
}) {
if (issues.length === 0) {
return (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800">
State
</div>
);
}
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<strong></strong>
<ul className="mt-2 space-y-1 pl-4">
{issues.map((issue, index) => (
<li key={`${issue.code}-${issue.resourceId ?? index}`}>
{issue.message}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,66 @@
import { Image as ImageIcon, ScanSearch } from 'lucide-react';
import type { UiEditorPageController } from '../useUiEditorPage';
export function PreviewWorkspace({
controller,
}: {
controller: UiEditorPageController;
}) {
const { editor, activeImage, activeImageId, previewUrls } = controller;
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>
<span className="text-[10px] text-(--platform-text-soft)">
{Object.keys(controller.images).length} ·{' '}
{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 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}
className="mx-auto text-(--platform-text-soft)"
/>
<h2 className="mt-4 text-base font-semibold"></h2>
<p className="text-xs text-(--platform-text-soft)">
UI
</p>
</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)">
{controller.status}
</span>
) : (
<span className="mr-auto" />
)}
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs disabled:opacity-40"
disabled={editor.isLocked}
onClick={controller.openClearDialog}
>
</button>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg bg-orange-500 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.checkPrerequisites}
>
<ScanSearch size={14} />
</button>
</footer>
</section>
);
}
@@ -0,0 +1,39 @@
import { UI_EDITOR_TOOLS, type UiEditorToolId } from '../model';
export function ToolNavigation({
activeTool,
onChange,
}: {
activeTool: UiEditorToolId;
onChange: (tool: UiEditorToolId) => void;
}) {
return (
<nav
className="grid shrink-0 grid-cols-4 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
aria-label="UI 编辑工具"
>
{UI_EDITOR_TOOLS.map((tool) => {
const active = tool.id === activeTool;
return (
<button
key={tool.id}
type="button"
className={`min-w-0 rounded-xl border px-3 py-2 text-left transition ${
active
? 'border-orange-300 bg-orange-50 text-orange-950 shadow-sm'
: 'border-transparent text-(--platform-text-strong) hover:bg-black/4'
}`}
onClick={() => onChange(tool.id)}
>
<span className="block truncate text-xs font-semibold">
{tool.label}
</span>
<span className="mt-0.5 block truncate text-[10px] text-(--platform-text-soft)">
{tool.hint}
</span>
</button>
);
})}
</nav>
);
}
@@ -0,0 +1,47 @@
import { ChevronRight, Layers3 } from 'lucide-react';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
function TreeNodes({ nodes, depth = 0 }: { nodes: UiNode[]; depth?: number }) {
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)"
style={{ paddingLeft: 8 + depth * 16 }}
>
<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>
{node.children.length > 0 ? (
<TreeNodes nodes={node.children} depth={depth + 1} />
) : null}
</li>
))}
</ul>
);
}
export function UiTreePanel({ nodes }: { nodes: UiNode[] | null }) {
return (
<section className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
<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} />
) : (
<p className="m-0 text-xs text-(--platform-text-soft)">
</p>
)}
</div>
</section>
);
}
@@ -0,0 +1,29 @@
import { EditorDialogs } from './components/EditorDialogs';
import { InputSidebar } from './components/InputSidebar';
import { InspectorSidebar } from './components/Inspector/InspectorSidebar';
import { PreviewWorkspace } from './components/PreviewWorkspace';
import { ToolNavigation } from './components/ToolNavigation';
import { useUiEditorPage } from './useUiEditorPage';
export default function UiEditorPage({
projectPath = '/tmp/ui_editor',
}: {
projectPath?: string;
}) {
const controller = useUiEditorPage(projectPath);
return (
<main className="flex h-[calc(100vh-42px)] min-h-0 min-w-5xl flex-col overflow-hidden bg-(--platform-body-fill) pt-12 text-(--platform-text-strong)">
<ToolNavigation
activeTool={controller.activeTool}
onChange={controller.selectTool}
/>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(360px,1fr)_320px] overflow-hidden">
<InputSidebar controller={controller} />
<PreviewWorkspace controller={controller} />
<InspectorSidebar controller={controller} />
</div>
<EditorDialogs controller={controller} />
</main>
);
}
@@ -0,0 +1,53 @@
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState';
export type UiEditorToolId = 'input' | 'components' | 'assets' | 'layout';
export type UiEditorImportKind = 'design-image' | 'sprite';
export type PendingResourceRemoval = {
kind: UiEditorImportKind;
id: string;
impact: RemovalImpact;
};
export const UI_EDITOR_TOOLS: Array<{
id: UiEditorToolId;
label: string;
hint: string;
}> = [
{ id: 'input', label: '导入并确认输入', hint: '界面图与独立素材' },
{ id: 'components', label: '组件结构', hint: '识别与校正' },
{ id: 'assets', label: '素材绑定', hint: '语义与槽位' },
{ id: 'layout', label: '布局验收', hint: '生成与微调' },
];
export const UI_DESIGN_IMAGE_ROLES: Array<{
value: UIDesignImageRole;
label: string;
}> = [
{ value: 'Page', label: '主页面' },
{ value: 'Section', label: '子界面 / 页签' },
{ value: 'Modal', label: '模态弹窗' },
{ value: 'Drawer', label: '抽屉 / 侧栏' },
{ value: 'Popover', label: '局部浮层' },
{ value: 'State', label: '交互状态' },
{ value: 'Scrolled', label: '滚动后内容' },
{ value: 'Detail', label: '局部详情' },
];
export function removalHasDownstreamReferences(impact: RemovalImpact) {
return (
impact.removedTreeCount +
impact.clearedSlaveToCount +
impact.clearedTargetGraphicCount >
0
);
}
export function uiEditorOperationError(reason: string) {
if (reason === 'duplicate') return '所选资源已经存在,本批次未加入编辑器。';
if (reason === 'limit') return '界面图最多 4 张,本批次未加入编辑器。';
if (reason === 'locked') return '当前任务正在运行,暂时不能修改 State。';
if (reason === 'invalid') return '资源数据无效,本批次未加入编辑器。';
return '目标资源不存在。';
}
@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react';
import type { ImportedAsset } from '../../components/AssetImporter';
import {
prepareDesignImageBatch,
prepareSpriteAssetBatch,
} from '../../features/ui-editor/importAdapter';
import {
type UiEditorPrerequisiteIssue,
validateAssetRecognitionPrerequisites,
validateComponentRecognitionPrerequisites,
validateLayoutReviewPrerequisites,
} from '../../features/ui-editor/prerequisites';
import type { Node as UiNode } from '../../features/ui-editor/types/Node';
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 {
EMPTY_UI_EDITOR_STATE,
useUiEditorState,
} from '../../features/ui-editor/useUiEditorState';
import {
type PendingResourceRemoval,
removalHasDownstreamReferences,
type UiEditorImportKind,
uiEditorOperationError,
type UiEditorToolId,
} from './model';
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 [selectedSpriteId, setSelectedSpriteId] =
useState<SpriteAssetId | 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 [clearOpen, setClearOpen] = useState(false);
const [pendingRemoval, setPendingRemoval] =
useState<PendingResourceRemoval | null>(null);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
const activeImage = activeImageId ? images[activeImageId] : null;
const selectedSprite = selectedSpriteId ? sprites[selectedSpriteId] : null;
const pageOptions = Object.entries(images).filter(
([, image]) => image.metadata.role === 'Page',
);
const activeToolLabel =
activeTool === 'input'
? '导入并确认输入'
: activeTool === 'components'
? '组件结构'
: activeTool === 'assets'
? '素材绑定'
: '布局验收';
const spriteReferenceCounts = useMemo(() => {
const counts: Record<string, number> = {};
function visit(nodes: UiNode[]) {
for (const node of nodes) {
for (const component of node.components) {
if ('Image' in component && component.Image.target_graphic) {
counts[component.Image.target_graphic] =
(counts[component.Image.target_graphic] ?? 0) + 1;
}
}
visit(node.children);
}
}
for (const tree of editor.state.ui_trees) visit(tree.children);
return counts;
}, [editor.state.ui_trees]);
const treeForActiveImage = activeImageId
? editor.state.ui_trees.find(
(tree) => tree.src_ui_design === activeImageId,
)
: null;
async function importAssets(imported: ImportedAsset[]) {
if (!importKind || !projectPath) return;
setStatus(null);
try {
if (importKind === 'design-image') {
const prepared = await prepareDesignImageBatch(projectPath, imported);
const result = editor.addDesignImages(
prepared.map((item) => item.resource),
);
if (!result.ok) {
setStatus(uiEditorOperationError(result.reason));
return;
}
const ids = prepared.map((item) => item.resource.id);
setImageOrder((current) => [...current, ...ids]);
setActiveImageId((current) => current ?? ids[0] ?? null);
setPreviewUrls((current) => ({
...current,
...Object.fromEntries(
prepared.map((item) => [item.resource.id, item.previewUrl]),
),
}));
setStatus(`已加入 ${prepared.length} 张界面图。`);
return;
}
const prepared = await prepareSpriteAssetBatch(projectPath, imported);
const result = editor.addSpriteAssets(
prepared.map((item) => item.resource),
);
if (!result.ok) {
setStatus(uiEditorOperationError(result.reason));
return;
}
setPreviewUrls((current) => ({
...current,
...Object.fromEntries(
prepared.map((item) => [item.resource.asset_id, item.previewUrl]),
),
}));
setStatus(`已加入 ${prepared.length} 项独立素材。`);
} catch (cause) {
setStatus(cause instanceof Error ? cause.message : String(cause));
}
}
function removeFromUiSession(id: string) {
setPreviewUrls((current) => {
const next = { ...current };
delete next[id];
return next;
});
if (id === selectedSpriteId) setSelectedSpriteId(null);
if (id !== activeImageId) return;
const index = imageOrder.indexOf(id);
const remaining = imageOrder.filter((item) => item !== id);
setImageOrder(remaining);
setActiveImageId(remaining[index] ?? remaining[index - 1] ?? null);
}
function requestDesignImageRemoval(id: UIDesignImageId) {
const result = editor.removeDesignImage(id, { dryRun: true });
if (!result.ok) return;
if (removalHasDownstreamReferences(result.value)) {
setPendingRemoval({ kind: 'design-image', id, impact: result.value });
return;
}
editor.removeDesignImage(id, { dryRun: false });
removeFromUiSession(id);
}
function requestSpriteRemoval(id: SpriteAssetId) {
const result = editor.removeSpriteAsset(id, { dryRun: true });
if (!result.ok) return;
if (removalHasDownstreamReferences(result.value)) {
setPendingRemoval({ kind: 'sprite', id, impact: result.value });
return;
}
editor.removeSpriteAsset(id, { dryRun: false });
removeFromUiSession(id);
}
function confirmRemoval() {
if (!pendingRemoval) return;
if (pendingRemoval.kind === 'design-image') {
editor.removeDesignImage(pendingRemoval.id, { dryRun: false });
} else {
editor.removeSpriteAsset(pendingRemoval.id, { dryRun: false });
}
removeFromUiSession(pendingRemoval.id);
setPendingRemoval(null);
}
function checkPrerequisites() {
const validator =
activeTool === 'input' || activeTool === 'components'
? validateComponentRecognitionPrerequisites
: activeTool === 'assets'
? validateAssetRecognitionPrerequisites
: validateLayoutReviewPrerequisites;
setIssues(validator(editor.state));
}
function clearState() {
editor.clearState();
setImageOrder([]);
setActiveImageId(null);
setSelectedSpriteId(null);
setPreviewUrls({});
setIssues(null);
setClearOpen(false);
}
function selectTool(tool: UiEditorToolId) {
setActiveTool(tool);
setIssues(null);
}
function selectDesignImage(id: UIDesignImageId) {
setActiveImageId(id);
setSelectedSpriteId(null);
}
function selectSprite(id: SpriteAssetId) {
setSelectedSpriteId(id);
}
function setImageName(name: string) {
if (!activeImageId) return;
editor.setImageName(activeImageId, name);
setIssues(null);
}
function setImageRole(role: UIDesignImageRole | null) {
if (!activeImageId) return;
editor.setImageRole(activeImageId, role);
setIssues(null);
}
function setImageSlaveTo(slaveTo: UIDesignImageId | null) {
if (!activeImageId) return;
editor.setImageSlaveTo(activeImageId, slaveTo);
setIssues(null);
}
function setSpriteName(name: string) {
if (!selectedSpriteId) return;
editor.setSpriteName(selectedSpriteId, name);
setIssues(null);
}
function setSpriteAssetType(assetType: string) {
if (!selectedSpriteId) return;
editor.setSpriteAssetType(selectedSpriteId, assetType);
setIssues(null);
}
function setSpriteBorder(border: SpriteBorder) {
if (!selectedSpriteId) return;
editor.setSpriteBorder(selectedSpriteId, border);
}
return {
projectPath,
editor,
activeTool,
activeToolLabel,
imageOrder,
activeImageId,
selectedSpriteId,
importKind,
previewUrls,
status,
issues,
clearOpen,
pendingRemoval,
images,
sprites,
activeImage,
selectedSprite,
pageOptions,
spriteReferenceCounts,
treeForActiveImage,
selectTool,
selectDesignImage,
selectSprite,
openImporter: setImportKind,
closeImporter: () => setImportKind(null),
importAssets,
requestDesignImageRemoval,
requestSpriteRemoval,
confirmRemoval,
cancelRemoval: () => setPendingRemoval(null),
checkPrerequisites,
openClearDialog: () => setClearOpen(true),
closeClearDialog: () => setClearOpen(false),
clearState,
setImageName,
setImageRole,
setImageSlaveTo,
setSpriteName,
setSpriteAssetType,
setSpriteBorder,
};
}
export type UiEditorPageController = ReturnType<typeof useUiEditorPage>;
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../src/components/AssetImporter', () => ({
AssetImporter: ({ open }: { open: boolean }) =>
open ? createElement('div', { role: 'dialog' }, '素材导入器') : null,
}));
vi.mock('../src/components/modal/ThemedModal', () => ({
ThemedModal: ({
open,
children,
}: {
open: boolean;
children: ReactNode;
}) => (open ? createElement('div', { role: 'dialog' }, children) : null),
}));
import UiEditorPage from '../src/view/ui-editor';
describe('UiEditorPage', () => {
it('renders split input tools over a real empty State', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));
expect(screen.getByRole('navigation', { name: 'UI 编辑工具' })).toBeTruthy();
expect(screen.getByRole('button', { name: '导入界面图' })).toBeTruthy();
expect(screen.getByRole('button', { name: '导入独立素材' })).toBeTruthy();
expect(screen.getByText('从界面图开始')).toBeTruthy();
expect(screen.queryByText('Pause Dialog')).toBeNull();
});
it('switches tools freely without inventing completed workflow state', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));
fireEvent.click(screen.getByRole('button', { name: /布局验收/ }));
expect(screen.getByRole('heading', { name: '布局验收' })).toBeTruthy();
expect(screen.getByText('当前界面尚无组件树。')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '检查前置数据' }));
expect(screen.getByText('还不能执行此操作')).toBeTruthy();
expect(screen.getByText('请先导入界面图')).toBeTruthy();
});
it('opens the design-image importer independently', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));
fireEvent.click(screen.getByRole('button', { name: '导入界面图' }));
expect(screen.getByText('素材导入器')).toBeTruthy();
});
});
@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
validateComponentRecognitionPrerequisites,
} from '../src/features/ui-editor/prerequisites';
import type { Node } from '../src/features/ui-editor/types/Node';
import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset';
import type { State } from '../src/features/ui-editor/types/State';
import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage';
import {
EMPTY_UI_EDITOR_STATE,
useUiEditorState,
} from '../src/features/ui-editor/useUiEditorState';
function image(name: string): UIDesignImage {
return {
metadata: { name, role: null, slave_to: null },
path: `assets/${name}.png`,
pixel_size: [100, 80],
pixels_per_unit: 1,
};
}
function sprite(id: string): SpriteAsset {
return {
asset_id: id,
metadata: { name: id, asset_type: '' },
path: `assets/${id}.png`,
pixel_size: [32, 32],
pixels_per_unit: 1,
border: { left: 0, right: 0, top: 0, bottom: 0 },
};
}
function nodeWithSprite(id: string): Node {
return {
transform: {
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [0, 0],
offset_max: [32, 32],
},
metadata: { name: 'Image', description: '', from: '' },
components: [
{
Image: {
target_graphic: id,
image_type: { Simple: { preserve_aspect: false } },
},
},
],
children: [],
};
}
describe('useUiEditorState', () => {
it('exposes focused operations over one shared state', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
expect(
result.current.addDesignImages([
{ id: 'page-a', image: image('Page A') },
{ id: 'section-a', image: image('Section A') },
]),
).toEqual({ ok: true, value: undefined });
result.current.setImageRole('page-a', 'Page');
result.current.setImageRole('section-a', 'Section');
result.current.setImageSlaveTo('section-a', 'page-a');
result.current.setImageName('section-a', '任务页');
});
expect(result.current.state.ui_design_images['section-a']).toMatchObject({
metadata: { name: '任务页', role: 'Section', slave_to: 'page-a' },
});
expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([]);
});
it('adds a batch atomically and rejects duplicates and limits', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([
{ id: 'a', image: image('A') },
{ id: 'b', image: image('B') },
{ id: 'c', image: image('C') },
]);
});
const beforeFailure = structuredClone(result.current.state);
act(() => {
expect(
result.current.addDesignImages([
{ id: 'd', image: image('D') },
{ id: 'e', image: image('E') },
]),
).toEqual({ ok: false, reason: 'limit' });
});
expect(result.current.state).toEqual(beforeFailure);
act(() => {
expect(
result.current.addDesignImages([{ id: 'a', image: image('Again') }]),
).toEqual({ ok: false, reason: 'duplicate' });
});
expect(result.current.state).toEqual(beforeFailure);
expect(result.current.state).toEqual(beforeFailure);
});
it('owns the async State lock and always releases it', async () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([{ id: 'a', image: image('A') }]);
});
let release: (() => void) | undefined;
const waitForRelease = new Promise<void>((resolve) => {
release = resolve;
});
let operation: Promise<string> | undefined;
await act(async () => {
operation = result.current.runWithStateLocked(async (snapshot) => {
expect(snapshot).toEqual(result.current.state);
expect(snapshot).not.toBe(result.current.state);
await waitForRelease;
return 'recognized';
});
await Promise.resolve();
});
expect(result.current.isLocked).toBe(true);
act(() => {
expect(result.current.setImageName('a', 'Locked')).toEqual({
ok: false,
reason: 'locked',
});
});
await expect(
result.current.runWithStateLocked(async () => undefined),
).rejects.toThrow('UI editor State is already locked');
await act(async () => {
release?.();
await operation;
});
expect(result.current.isLocked).toBe(false);
await act(async () => {
await expect(
result.current.runWithStateLocked(async () => {
throw new Error('recognition failed');
}),
).rejects.toThrow('recognition failed');
});
expect(result.current.isLocked).toBe(false);
});
it('dry-runs and then fully cleans referenced resources', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: {
page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } },
child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } },
},
sprite_assets: { panel: sprite('panel') },
ui_trees: [
{ src_ui_design: 'page', children: [nodeWithSprite('panel')] },
],
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
expect(result.current.removeSpriteAsset('panel', { dryRun: true })).toEqual({
ok: true,
value: {
removedResourceCount: 1,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount: 1,
},
});
});
expect(result.current.state.sprite_assets.panel).toBeDefined();
act(() => {
result.current.removeSpriteAsset('panel', { dryRun: false });
result.current.removeDesignImage('page', { dryRun: false });
});
expect(result.current.state.sprite_assets.panel).toBeUndefined();
expect(result.current.state.ui_trees).toEqual([]);
expect(result.current.state.ui_design_images.page).toBeUndefined();
expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBeNull();
});
it('keeps setters local and defers workflow errors to prerequisites', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: {
page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } },
child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } },
},
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageRole('page', null);
});
expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBe('page');
expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([
expect.objectContaining({ code: 'invalid-slave-to', resourceId: 'child' }),
]);
});
});