新增 UI 编辑器核心模块
- 引入 UI 设计图片和精灵素材批处理准备功能 - 新增界面图和组件前置条件校验逻辑 - 添加与 SpriteBorder 相关的验证和字段编辑组件 - 支持 UI 状态管理,包括设计图和精灵素材的增删改功能
This commit is contained in:
@@ -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)">
|
||||
导入 1–4 张界面图
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+205
@@ -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>
|
||||
);
|
||||
}
|
||||
+115
@@ -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>
|
||||
);
|
||||
}
|
||||
+134
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+212
@@ -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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user