深化 UI 编辑会话模块

将载入、revision、选择、预览、步骤门禁、异步操作和保存意图收敛到 UI 编辑会话

按输入、画布、检查器、工作流、弹窗和保存用途拆分视图 projection

补充 UI 编辑会话模块边界文档
This commit is contained in:
2026-08-19 15:02:50 +08:00
parent 10317e16e7
commit 34511244c8
9 changed files with 528 additions and 466 deletions
@@ -5,36 +5,36 @@ import {
SPRITE_IMPORTER_SETTINGS,
} from '../../../components/AssetImporter/settings';
import { ThemedModal } from '../../../components/modal/ThemedModal';
import type { UiEditorPageController } from '../useUiEditorPage';
import type { UiEditorDialogsProjection } from '../useUiEditorPage';
export function EditorDialogs({
controller,
dialogs,
}: {
controller: UiEditorPageController;
dialogs: UiEditorDialogsProjection;
}) {
const impact = controller.pendingRemoval?.impact;
const impact = dialogs.pendingRemoval?.impact;
const importerSettings =
controller.importKind === 'font'
dialogs.importKind === 'font'
? FONT_IMPORTER_SETTINGS
: controller.importKind === 'design-image'
: dialogs.importKind === 'design-image'
? DESIGN_IMAGE_IMPORTER_SETTINGS
: SPRITE_IMPORTER_SETTINGS;
return (
<>
<AssetImporter
projectPath={controller.projectPath}
open={controller.importKind !== null}
onClose={controller.closeImporter}
projectPath={dialogs.projectPath}
open={dialogs.importKind !== null}
onClose={dialogs.closeImporter}
onImport={(assets) => {
void controller.importAssets(assets);
controller.closeImporter();
void dialogs.importAssets(assets);
dialogs.closeImporter();
}}
settings={importerSettings}
/>
<ThemedModal
open={controller.clearOpen}
onClose={controller.closeClearDialog}
open={dialogs.clearOpen}
onClose={dialogs.closeClearDialog}
ariaLabel="确认清空 UI Editor State"
panelClassName="w-[420px] rounded-2xl p-5"
>
@@ -47,14 +47,14 @@ export function EditorDialogs({
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={controller.closeClearDialog}
onClick={dialogs.closeClearDialog}
>
取消
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.clearState}
onClick={dialogs.clearState}
>
清空
</button>
@@ -62,8 +62,8 @@ export function EditorDialogs({
</ThemedModal>
<ThemedModal
open={controller.pendingRemoval !== null}
onClose={controller.cancelRemoval}
open={dialogs.pendingRemoval !== null}
onClose={dialogs.cancelRemoval}
ariaLabel="确认删除并清理引用"
panelClassName="w-[440px] rounded-2xl p-5"
>
@@ -82,14 +82,14 @@ export function EditorDialogs({
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={controller.cancelRemoval}
onClick={dialogs.cancelRemoval}
>
取消
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.confirmRemoval}
onClick={dialogs.confirmRemoval}
>
删除并清理
</button>
@@ -5,7 +5,7 @@ import { visitUiNodes } from '../../../features/ui-editor/treeUtils';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UiEditorPageController } from '../useUiEditorPage';
import type { UiEditorInputProjection } from '../useUiEditorPage';
import { BindingOverview } from './BindingOverview';
import { ImportOverview } from './ImportOverview';
import { RecognitionOverview } from './RecognitionOverview';
@@ -13,14 +13,9 @@ import { UiTreePanel } from './UiTreePanel';
const SUPER_ROOT_ID = '__ui-editor-super-root__';
export function InputSidebar({
controller,
}: {
controller: UiEditorPageController;
}) {
export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
const {
projectPath,
editor,
activeStep,
imageOrder,
activeImageId,
@@ -34,20 +29,22 @@ export function InputSidebar({
spriteReferenceCounts,
selectedNodeId,
focusRequest,
} = controller;
uiTrees,
isLocked,
} = input;
const treeIdByNodeId = useMemo(() => {
const result = new Map<NodeId, UIDesignImageId>();
for (const tree of editor.state.ui_trees) {
for (const tree of uiTrees) {
visitUiNodes(tree.root, (node) =>
result.set(node.id, tree.src_ui_design),
);
}
return result;
}, [editor.state.ui_trees]);
}, [uiTrees]);
const superRoot = useMemo<UiNode | null>(() => {
if (editor.state.ui_trees.length === 0) return null;
if (uiTrees.length === 0) return null;
return {
id: SUPER_ROOT_ID,
layout: {
@@ -74,9 +71,9 @@ export function InputSidebar({
},
components: [],
children_display_mode: 'Stack',
children: editor.state.ui_trees.map((tree) => tree.root),
children: uiTrees.map((tree) => tree.root),
};
}, [editor.state.ui_trees]);
}, [uiTrees]);
return (
<aside className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto border-r border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3">
@@ -92,8 +89,8 @@ export function InputSidebar({
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')}
disabled={!projectPath || imageOrder.length >= 4 || isLocked}
onClick={() => input.openImporter('design-image')}
>
<Plus size={15} />
</button>
@@ -112,7 +109,7 @@ export function InputSidebar({
? 'border-orange-300 bg-orange-50'
: 'border-(--platform-subpanel-border) bg-white/45'
}`}
onClick={() => controller.selectDesignImage(id)}
onClick={() => input.selectDesignImage(id)}
>
<div className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md bg-black/5">
{previewUrls[id] ? (
@@ -157,8 +154,8 @@ export function InputSidebar({
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')}
disabled={!projectPath || isLocked}
onClick={() => input.openImporter('sprite')}
>
<Plus size={15} />
</button>
@@ -170,7 +167,7 @@ export function InputSidebar({
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)}
onClick={() => input.selectSprite(sprite.asset_id)}
>
{previewUrls[sprite.asset_id] ? (
<img
@@ -201,8 +198,8 @@ export function InputSidebar({
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('font')}
disabled={!projectPath || isLocked}
onClick={() => input.openImporter('font')}
>
<Plus size={15} />
</button>
@@ -220,7 +217,7 @@ export function InputSidebar({
: 'border-(--platform-subpanel-border) bg-white/45'
}`}
title={`${font.metadata.source_file_name} · 引用 ${fontReferenceCounts[font.asset_id] ?? 0}`}
onClick={() => controller.selectFont(font.asset_id)}
onClick={() => input.selectFont(font.asset_id)}
>
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/5">
<Type size={16} />
@@ -247,27 +244,23 @@ export function InputSidebar({
{activeStep === 'structure-recognition' ? (
<RecognitionOverview
uiTrees={editor.state.ui_trees}
uiTrees={uiTrees}
onFocusStatusNode={(treeId, nodeId) => {
controller.focusNode(treeId, nodeId);
controller.highlightStatusField('layout_status');
input.focusNode(treeId, nodeId);
input.highlightStatusField('layout_status');
}}
/>
) : activeStep === 'visual-binding' ? (
<BindingOverview
uiTrees={editor.state.ui_trees}
uiTrees={uiTrees}
sprites={sprites}
onFocusStatusNode={(treeId, nodeId) => {
controller.focusNode(treeId, nodeId);
controller.highlightStatusField('components_status');
input.focusNode(treeId, nodeId);
input.highlightStatusField('components_status');
}}
/>
) : (
<ImportOverview
images={images}
sprites={sprites}
uiTrees={editor.state.ui_trees}
/>
<ImportOverview images={images} sprites={sprites} uiTrees={uiTrees} />
)}
<UiTreePanel
@@ -275,25 +268,25 @@ export function InputSidebar({
selectedNodeId={selectedNodeId}
focusRequest={focusRequest}
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
isNodePreviewVisible={controller.isNodePreviewVisible}
isNodePreviewVisible={input.isNodePreviewVisible}
onSelectNode={(treeId, nodeId) => {
controller.selectDesignImage(treeId);
controller.selectNode(nodeId);
input.selectDesignImage(treeId);
input.selectNode(nodeId);
}}
onToggleNodeVisibility={(nodeId) =>
controller.toggleNodePreviewVisibility(nodeId)
input.toggleNodePreviewVisibility(nodeId)
}
onInsertNode={(treeId, parentId) => {
controller.insertNode(parentId, treeId);
input.insertNode(parentId, treeId);
}}
onInsertNodeAfter={(treeId, nodeId) => {
controller.insertNodeAfter(nodeId, treeId);
input.insertNodeAfter(nodeId, treeId);
}}
onDeleteNode={(treeId, nodeId) => {
controller.deleteNode(nodeId, treeId);
input.deleteNode(nodeId, treeId);
}}
onMoveNode={(request) => controller.moveNode(request)}
isLocked={controller.editor.isLocked}
onMoveNode={input.moveNode}
isLocked={isLocked}
/>
</aside>
);
@@ -13,7 +13,7 @@ import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDes
import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole';
import { uiEditorPrivateFontFamily } from '../../../../features/ui-editor/useUiEditorFontFaces';
import { UI_DESIGN_IMAGE_ROLES } from '../../model';
import type { UiEditorPageController } from '../../useUiEditorPage';
import type { UiEditorInspectorProjection } from '../../useUiEditorPage';
import { ComponentPanel } from './Components/ComponentPanel';
import {
InspectorReadOnlyContext,
@@ -22,19 +22,25 @@ import {
import { SpriteBorderEditor } from './SpriteBorder/SpriteBorderEditor';
import TransformEditor from './Transform/TransformEditor';
type SelectedNode = NonNullable<UiEditorPageController['selectedNode']>;
type SelectedSprite = NonNullable<UiEditorPageController['selectedSprite']>;
type SelectedFont = NonNullable<UiEditorPageController['selectedFont']>;
type ActiveImage = NonNullable<UiEditorPageController['activeImage']>;
type SelectedSpriteId = NonNullable<UiEditorPageController['selectedSpriteId']>;
type SelectedFontId = NonNullable<UiEditorPageController['selectedFontId']>;
type ActiveImageId = NonNullable<UiEditorPageController['activeImageId']>;
type SelectedNode = NonNullable<UiEditorInspectorProjection['selectedNode']>;
type SelectedSprite = NonNullable<
UiEditorInspectorProjection['selectedSprite']
>;
type SelectedFont = NonNullable<UiEditorInspectorProjection['selectedFont']>;
type ActiveImage = NonNullable<UiEditorInspectorProjection['activeImage']>;
type SelectedSpriteId = NonNullable<
UiEditorInspectorProjection['selectedSpriteId']
>;
type SelectedFontId = NonNullable<
UiEditorInspectorProjection['selectedFontId']
>;
type ActiveImageId = NonNullable<UiEditorInspectorProjection['activeImageId']>;
type InspectorView =
| {
kind: 'node';
node: SelectedNode;
parentSize: UiEditorPageController['selectedNodeParentSize'];
parentSize: UiEditorInspectorProjection['selectedNodeParentSize'];
parentIsContainer: boolean;
}
| {
@@ -55,7 +61,7 @@ type InspectorView =
kind: 'image';
image: ActiveImage;
imageId: ActiveImageId;
pageOptions: UiEditorPageController['pageOptions'];
pageOptions: UiEditorInspectorProjection['pageOptions'];
}
| { kind: 'empty' };
@@ -73,11 +79,11 @@ const INSPECTOR_INPUT_CLASS_NAME = `${INSPECTOR_CONTROL_BASE_CLASS_NAME} h-9 px-
const INSPECTOR_TEXTAREA_CLASS_NAME = `${INSPECTOR_CONTROL_BASE_CLASS_NAME} min-h-20 resize-y px-2 py-2 leading-5`;
export function InspectorSidebar({
controller,
inspector,
}: {
controller: UiEditorPageController;
inspector: UiEditorInspectorProjection;
}) {
const view = getInspectorView(controller);
const view = getInspectorView(inspector);
let inspectorContent: ReactNode;
switch (view.kind) {
case 'node':
@@ -86,34 +92,32 @@ export function InspectorSidebar({
node={view.node}
parentSize={view.parentSize}
parentIsContainer={view.parentIsContainer}
readOnly={controller.editor.isLocked}
isNodePreviewVisible={controller.isNodePreviewVisible(view.node.id)}
readOnly={inspector.isLocked}
isNodePreviewVisible={inspector.isNodePreviewVisible(view.node.id)}
onSetNodePreviewVisible={(visible) =>
controller.setNodePreviewVisible(view.node.id, visible)
inspector.setNodePreviewVisible(view.node.id, visible)
}
onChildrenDisplayModeChange={controller.setNodeChildrenDisplayMode}
onChildrenDisplayModeChange={inspector.setNodeChildrenDisplayMode}
transformReadOnly={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
inspector.isLocked || view.node.id === inspector.tree?.root.id
}
keepChildrenUnchanged={controller.keepChildrenUnchanged}
onKeepChildrenUnchangedChange={controller.setKeepChildrenUnchanged}
onMetadataChange={controller.setNodeMetadata}
highlightedStatusField={controller.highlightedStatusField}
onTransformChange={controller.setNodeTransform}
onLayoutChange={controller.setNodeLayout}
sprites={controller.sprites}
fonts={controller.editor.state.font_assets}
fontFaces={controller.fontFaces}
projectPath={controller.projectPath}
onSetComponents={controller.setNodeComponents}
onInsertComponent={controller.insertNodeComponent}
onDeleteComponent={controller.deleteNodeComponent}
onMoveComponent={controller.moveNodeComponent}
onDeleteNode={() => controller.deleteNode(view.node.id)}
keepChildrenUnchanged={inspector.keepChildrenUnchanged}
onKeepChildrenUnchangedChange={inspector.setKeepChildrenUnchanged}
onMetadataChange={inspector.setNodeMetadata}
highlightedStatusField={inspector.highlightedStatusField}
onTransformChange={inspector.setNodeTransform}
onLayoutChange={inspector.setNodeLayout}
sprites={inspector.sprites}
fonts={inspector.fonts}
fontFaces={inspector.fontFaces}
projectPath={inspector.projectPath}
onSetComponents={inspector.setNodeComponents}
onInsertComponent={inspector.insertNodeComponent}
onDeleteComponent={inspector.deleteNodeComponent}
onMoveComponent={inspector.moveNodeComponent}
onDeleteNode={() => inspector.deleteNode(view.node.id)}
deleteDisabled={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
inspector.isLocked || view.node.id === inspector.tree?.root.id
}
/>
);
@@ -125,11 +129,11 @@ export function InspectorSidebar({
spriteId={view.spriteId}
previewUrl={view.previewUrl}
referenceCount={view.referenceCount}
onNameChange={controller.setSpriteName}
onAssetTypeChange={controller.setSpriteAssetType}
onBorderChange={controller.setSpriteBorder}
onDelete={() => controller.requestSpriteRemoval(view.spriteId)}
deleteDisabled={controller.editor.isLocked}
onNameChange={inspector.setSpriteName}
onAssetTypeChange={inspector.setSpriteAssetType}
onBorderChange={inspector.setSpriteBorder}
onDelete={() => inspector.requestSpriteRemoval(view.spriteId)}
deleteDisabled={inspector.isLocked}
/>
);
break;
@@ -140,8 +144,8 @@ export function InspectorSidebar({
fontId={view.fontId}
referenceCount={view.referenceCount}
loadStatus={view.loadStatus}
onDelete={() => controller.requestFontRemoval(view.fontId)}
deleteDisabled={controller.editor.isLocked}
onDelete={() => inspector.requestFontRemoval(view.fontId)}
deleteDisabled={inspector.isLocked}
/>
);
break;
@@ -151,12 +155,12 @@ export function InspectorSidebar({
image={view.image}
imageId={view.imageId}
pageOptions={view.pageOptions}
onNameChange={controller.setImageName}
onDescriptionChange={controller.setImageDescription}
onRoleChange={controller.setImageRole}
onSlaveToChange={controller.setImageSlaveTo}
onDelete={() => controller.requestDesignImageRemoval(view.imageId)}
deleteDisabled={controller.editor.isLocked}
onNameChange={inspector.setImageName}
onDescriptionChange={inspector.setImageDescription}
onRoleChange={inspector.setImageRole}
onSlaveToChange={inspector.setImageSlaveTo}
onDelete={() => inspector.requestDesignImageRemoval(view.imageId)}
deleteDisabled={inspector.isLocked}
/>
);
break;
@@ -170,11 +174,11 @@ export function InspectorSidebar({
}
return (
<InspectorReadOnlyContext.Provider value={controller.editor.isLocked}>
<InspectorReadOnlyContext.Provider value={inspector.isLocked}>
<aside
aria-readonly={controller.editor.isLocked}
aria-readonly={inspector.isLocked}
className={`flex h-full min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto border-l p-4 ${
controller.editor.isLocked
inspector.isLocked
? 'border-slate-300 bg-slate-100/90'
: 'border-(--platform-subpanel-border) bg-(--platform-subpanel-fill)'
}`}
@@ -186,7 +190,7 @@ export function InspectorSidebar({
{INSPECTOR_VIEW_TITLES[view.kind]}
</h2>
</div>
{controller.editor.isLocked ? (
{inspector.isLocked ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-slate-300 bg-slate-200 px-2 py-1 text-[10px] font-semibold text-slate-600">
<LockKeyhole size={12} aria-hidden="true" />
只读
@@ -204,7 +208,9 @@ export function InspectorSidebar({
);
}
function getInspectorView(controller: UiEditorPageController): InspectorView {
function getInspectorView(
inspector: UiEditorInspectorProjection,
): InspectorView {
const {
activeImage,
activeImageId,
@@ -220,7 +226,7 @@ function getInspectorView(controller: UiEditorPageController): InspectorView {
fontReferenceCounts,
fontFaces,
pageOptions,
} = controller;
} = inspector;
if (selectedNode) {
return {
@@ -290,30 +296,30 @@ function NodeInspector({
deleteDisabled,
}: {
node: SelectedNode;
parentSize: UiEditorPageController['selectedNodeParentSize'];
parentSize: UiEditorInspectorProjection['selectedNodeParentSize'];
parentIsContainer: boolean;
readOnly: boolean;
isNodePreviewVisible: boolean;
onSetNodePreviewVisible: (visible: boolean) => void;
onChildrenDisplayModeChange: UiEditorPageController['setNodeChildrenDisplayMode'];
onChildrenDisplayModeChange: UiEditorInspectorProjection['setNodeChildrenDisplayMode'];
transformReadOnly: boolean;
keepChildrenUnchanged: boolean;
onKeepChildrenUnchangedChange: (value: boolean) => void;
onMetadataChange: UiEditorPageController['setNodeMetadata'];
onMetadataChange: UiEditorInspectorProjection['setNodeMetadata'];
highlightedStatusField: {
field: StageStatusField;
requestId: number;
} | null;
onTransformChange: UiEditorPageController['setNodeTransform'];
onLayoutChange: UiEditorPageController['setNodeLayout'];
sprites: UiEditorPageController['sprites'];
fonts: UiEditorPageController['editor']['state']['font_assets'];
fontFaces: UiEditorPageController['fontFaces'];
onTransformChange: UiEditorInspectorProjection['setNodeTransform'];
onLayoutChange: UiEditorInspectorProjection['setNodeLayout'];
sprites: UiEditorInspectorProjection['sprites'];
fonts: UiEditorInspectorProjection['fonts'];
fontFaces: UiEditorInspectorProjection['fontFaces'];
projectPath: string;
onSetComponents: UiEditorPageController['setNodeComponents'];
onInsertComponent: UiEditorPageController['insertNodeComponent'];
onDeleteComponent: UiEditorPageController['deleteNodeComponent'];
onMoveComponent: UiEditorPageController['moveNodeComponent'];
onSetComponents: UiEditorInspectorProjection['setNodeComponents'];
onInsertComponent: UiEditorInspectorProjection['insertNodeComponent'];
onDeleteComponent: UiEditorInspectorProjection['deleteNodeComponent'];
onMoveComponent: UiEditorInspectorProjection['moveNodeComponent'];
onDeleteNode: () => void;
deleteDisabled: boolean;
}) {
@@ -511,7 +517,7 @@ function LayoutEditor({
}: {
node: SelectedNode;
readOnly: boolean;
onChange: UiEditorPageController['setNodeLayout'];
onChange: UiEditorInspectorProjection['setNodeLayout'];
}) {
const layout = node.layout;
const containerKind =
@@ -852,22 +858,6 @@ function NodeStageSelect({
);
}
function stageStatusFromSelect(
value: string,
): SelectedNode['metadata']['layout_status'] {
switch (value) {
case 'NeedReview':
return { NeedReview: '待审' };
case 'Blocked':
return 'Blocked';
case 'Pending':
return 'Pending';
case 'Passed':
default:
return 'Passed';
}
}
function SpriteInspector({
sprite,
spriteId,
@@ -883,9 +873,9 @@ function SpriteInspector({
spriteId: SelectedSpriteId;
previewUrl: string | undefined;
referenceCount: number;
onNameChange: UiEditorPageController['setSpriteName'];
onAssetTypeChange: UiEditorPageController['setSpriteAssetType'];
onBorderChange: UiEditorPageController['setSpriteBorder'];
onNameChange: UiEditorInspectorProjection['setSpriteName'];
onAssetTypeChange: UiEditorInspectorProjection['setSpriteAssetType'];
onBorderChange: UiEditorInspectorProjection['setSpriteBorder'];
onDelete: () => void;
deleteDisabled: boolean;
}) {
@@ -1023,11 +1013,11 @@ function ImageInspector({
}: {
image: ActiveImage;
imageId: ActiveImageId;
pageOptions: UiEditorPageController['pageOptions'];
onNameChange: UiEditorPageController['setImageName'];
onDescriptionChange: UiEditorPageController['setImageDescription'];
onRoleChange: UiEditorPageController['setImageRole'];
onSlaveToChange: UiEditorPageController['setImageSlaveTo'];
pageOptions: UiEditorInspectorProjection['pageOptions'];
onNameChange: UiEditorInspectorProjection['setImageName'];
onDescriptionChange: UiEditorInspectorProjection['setImageDescription'];
onRoleChange: UiEditorInspectorProjection['setImageRole'];
onSlaveToChange: UiEditorInspectorProjection['setImageSlaveTo'];
onDelete: () => void;
deleteDisabled: boolean;
}) {
@@ -1,29 +1,28 @@
import type { UiEditorPageController } from '../useUiEditorPage';
import type { UiEditorWorkflowProjection } from '../useUiEditorPage';
export function WorkflowActionCard({
controller,
workflow,
}: {
controller: UiEditorPageController;
workflow: UiEditorWorkflowProjection;
}) {
const action = getStepAction(controller);
const action = getStepAction(workflow);
const running =
(controller.activeStep === 'reference-analysis' &&
controller.isSuggesting) ||
(controller.activeStep === 'structure-recognition' &&
controller.isRecognizing) ||
(controller.activeStep === 'visual-binding' && controller.isBinding);
(workflow.activeStep === 'reference-analysis' && workflow.isSuggesting) ||
(workflow.activeStep === 'structure-recognition' &&
workflow.isRecognizing) ||
(workflow.activeStep === 'visual-binding' && workflow.isBinding);
const status =
controller.activeStep === 'reference-analysis'
? controller.suggestionStatus
: controller.activeStep === 'structure-recognition'
? controller.recognitionStatus
: controller.bindingStatus;
workflow.activeStep === 'reference-analysis'
? workflow.suggestionStatus
: workflow.activeStep === 'structure-recognition'
? workflow.recognitionStatus
: workflow.bindingStatus;
const hasRun =
controller.activeStep === 'reference-analysis'
? controller.hasSuggested
: controller.activeStep === 'structure-recognition'
? controller.hasRecognized
: controller.hasBound;
workflow.activeStep === 'reference-analysis'
? workflow.hasSuggested
: workflow.activeStep === 'structure-recognition'
? workflow.hasRecognized
: workflow.hasBound;
return (
<section className="shrink-0 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-4 py-3">
@@ -40,17 +39,17 @@ export function WorkflowActionCard({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
disabled={controller.isAiRunning}
disabled={workflow.isAiRunning}
onClick={() => void action.action()}
>
{running ? action.runningLabel : hasRun ? '重新运行' : action.label}
</button>
{controller.nextStep ? (
{workflow.nextStep ? (
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-1.5 text-xs disabled:cursor-wait disabled:opacity-60"
disabled={controller.isAiRunning}
onClick={controller.continueToNextStep}
disabled={workflow.isAiRunning}
onClick={workflow.continueToNextStep}
>
继续
</button>
@@ -61,24 +60,24 @@ export function WorkflowActionCard({
);
}
function getStepAction(controller: UiEditorPageController) {
if (controller.activeStep === 'reference-analysis') {
function getStepAction(workflow: UiEditorWorkflowProjection) {
if (workflow.activeStep === 'reference-analysis') {
return {
label: '分析参考图',
runningLabel: '分析中…',
action: controller.suggestUiDesignSemantics,
action: workflow.suggestUiDesignSemantics,
};
}
if (controller.activeStep === 'structure-recognition') {
if (workflow.activeStep === 'structure-recognition') {
return {
label: '识别界面结构',
runningLabel: '识别中…',
action: controller.recognizeUi,
action: workflow.recognizeUi,
};
}
return {
label: '绑定视觉素材',
runningLabel: '绑定中…',
action: controller.bindComponents,
action: workflow.bindComponents,
};
}
@@ -25,18 +25,18 @@ import {
import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorPageController } from '../../useUiEditorPage';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
export function PreviewWorkspace({
controller,
canvas,
}: {
controller: UiEditorPageController;
canvas: UiEditorCanvasProjection;
}) {
const { editor, activeImage, activeImageId, previewUrls } = controller;
const { activeImage, activeImageId, previewUrls } = canvas;
const viewportElementRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
const handledFocusRequestIdRef = useRef<number | null>(null);
@@ -55,7 +55,7 @@ export function PreviewWorkspace({
y: number;
isPageRoot: boolean;
} | null>(null);
const tree = controller.treeForActiveImage ?? null;
const tree = canvas.tree ?? null;
const activeImagePixelWidth = activeImage?.pixel_size[0];
const activeImagePixelHeight = activeImage?.pixel_size[1];
const activeImagePixelsPerUnit = activeImage?.pixels_per_unit;
@@ -84,7 +84,7 @@ export function PreviewWorkspace({
const nodeInteractions = useNodeTransformInteraction({
activeImageId,
controller,
canvas,
logicalSize,
spaceHeld,
tree,
@@ -146,7 +146,7 @@ export function PreviewWorkspace({
}, [activeImageId, fitToCanvas]);
useEffect(() => {
const request = controller.focusRequest;
const request = canvas.focusRequest;
if (
!request ||
request.requestId === handledFocusRequestIdRef.current ||
@@ -182,7 +182,7 @@ export function PreviewWorkspace({
activeImageId,
canvasSize.height,
canvasSize.width,
controller.focusRequest,
canvas.focusRequest,
logicalSize,
setViewport,
tree,
@@ -250,7 +250,7 @@ export function PreviewWorkspace({
return;
}
if (event.button === 0 && event.target === event.currentTarget) {
controller.clearNodeSelection();
canvas.clearNodeSelection();
}
};
@@ -341,8 +341,8 @@ export function PreviewWorkspace({
</label>
) : null}
<span className="text-[10px] text-(--platform-text-soft)">
{Object.keys(controller.images).length} 张界面图 ·{' '}
{Object.keys(controller.sprites).length} 项素材
{Object.keys(canvas.images).length} 张界面图 ·{' '}
{Object.keys(canvas.sprites).length} 项素材
</span>
</div>
</header>
@@ -350,14 +350,12 @@ export function PreviewWorkspace({
logicalSize &&
(renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? (
<div className="relative min-h-0 flex-1">
{renderMode === 'final-preview' && controller.selectedNode ? (
{renderMode === 'final-preview' && canvas.selectedNode ? (
<ExclusiveChildrenTabs
parent={controller.selectedNode}
isChildVisible={(nodeId) =>
controller.isNodePreviewVisible(nodeId)
}
parent={canvas.selectedNode}
isChildVisible={(nodeId) => canvas.isNodePreviewVisible(nodeId)}
onToggleChild={(nodeId) =>
controller.toggleNodePreviewVisibility(nodeId)
canvas.toggleNodePreviewVisibility(nodeId)
}
/>
) : null}
@@ -371,7 +369,7 @@ export function PreviewWorkspace({
onWheel={handleWheel}
onClick={(event) => {
if (!(event.target as Element).closest('[data-node-id]')) {
controller.clearNodeSelection();
canvas.clearNodeSelection();
}
}}
>
@@ -400,14 +398,14 @@ export function PreviewWorkspace({
tree={tree}
renderMode={renderMode}
showFrame={showFrame}
hiddenNodeIds={controller.hiddenNodeIds}
selectedNodeId={controller.selectedNodeId}
hiddenNodeIds={canvas.hiddenNodeIds}
selectedNodeId={canvas.selectedNodeId}
resources={{
previewUrls,
sprites: controller.sprites,
fontFaces: controller.fontFaces,
sprites: canvas.sprites,
fontFaces: canvas.fontFaces,
}}
onSelectNode={controller.selectNode}
onSelectNode={canvas.selectNode}
onNodeContextMenu={handleNodeContextMenu}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
@@ -425,11 +423,11 @@ export function PreviewWorkspace({
nodeId={contextMenu.nodeId}
position={contextMenu}
isPageRoot={contextMenu.isPageRoot}
disabled={editor.isLocked}
disabled={canvas.isLocked}
onClose={() => setContextMenu(null)}
onInsertChild={(nodeId) => controller.insertNode(nodeId)}
onInsertSibling={(nodeId) => controller.insertNodeAfter(nodeId)}
onDelete={(nodeId) => controller.deleteNode(nodeId)}
onInsertChild={(nodeId) => canvas.insertNode(nodeId)}
onInsertSibling={(nodeId) => canvas.insertNodeAfter(nodeId)}
onDelete={(nodeId) => canvas.deleteNode(nodeId)}
/>
) : null}
<ZoomControls
@@ -493,9 +491,9 @@ export function PreviewWorkspace({
</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 ? (
{canvas.status ? (
<span className="mr-auto text-xs text-(--platform-text-soft)">
{controller.status}
{canvas.status}
</span>
) : (
<span className="mr-auto" />
@@ -503,8 +501,8 @@ export function PreviewWorkspace({
<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}
disabled={canvas.isLocked}
onClick={canvas.openClearDialog}
>
清空
</button>
@@ -17,9 +17,9 @@ import {
} from '../../../../features/ui-editor/nodeTransformGeometry';
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { UITree } from '../../../../features/ui-editor/types/UITree';
import type { UiEditorPageController } from '../../useUiEditorPage';
type ViewportScale = { scale: number };
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
type ViewportScale = { scale: number };
type GestureBase = {
treeId: string;
nodeId: string;
@@ -80,14 +80,14 @@ function releasePointer(target: HTMLDivElement, pointerId: number) {
export function useNodeTransformInteraction({
activeImageId,
controller,
canvas,
logicalSize,
spaceHeld,
tree,
viewportRef,
}: {
activeImageId: UiEditorPageController['activeImageId'];
controller: UiEditorPageController;
activeImageId: UiEditorCanvasProjection['activeImageId'];
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
logicalSize: { width: number; height: number } | null;
spaceHeld: boolean;
tree: UITree | null;
@@ -140,7 +140,7 @@ export function useNodeTransformInteraction({
}
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
canvas.selectNode(node.id);
activeGestureRef.current = {
kind: 'drag',
treeId: activeImageId,
@@ -153,7 +153,7 @@ export function useNodeTransformInteraction({
hasMoved: false,
};
},
[activeImageId, controller, spaceHeld, tree?.root.id],
[activeImageId, canvas, spaceHeld, tree?.root.id],
);
const onNodePointerMove = useCallback(
@@ -185,13 +185,9 @@ export function useNodeTransformInteraction({
cancelGesture();
return;
}
controller.updateNodeTransform(
gesture.treeId,
gesture.nodeId,
nextTransform,
);
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
},
[acceptsGestureEvent, cancelGesture, controller, viewportRef],
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
);
const onNodePointerUp = useCallback(
@@ -240,7 +236,7 @@ export function useNodeTransformInteraction({
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
canvas.selectNode(node.id);
activeGestureRef.current = {
kind: 'resize',
treeId: activeImageId,
@@ -257,7 +253,7 @@ export function useNodeTransformInteraction({
hasMoved: false,
};
},
[activeImageId, controller, logicalSize, spaceHeld, tree],
[activeImageId, canvas, logicalSize, spaceHeld, tree],
);
const onNodeResizePointerMove = useCallback(
@@ -314,13 +310,9 @@ export function useNodeTransformInteraction({
return;
}
gesture.hasMoved = true;
controller.updateNodeTransform(
gesture.treeId,
gesture.nodeId,
nextTransform,
);
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
},
[acceptsGestureEvent, cancelGesture, controller, viewportRef],
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
);
return {
@@ -1,5 +1,5 @@
import { ChevronLeft } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import { ThemedModal } from '../../components/modal/ThemedModal';
import {
@@ -15,8 +15,8 @@ import { ToolNavigation } from './components/ToolNavigation';
import { WorkflowActionCard } from './components/WorkflowActionCard';
import { UI_EDITOR_STEPS } from './model';
import {
type UiEditorPageController,
useUiEditorPage,
type UiEditorWorkflowProjection,
useUiEditorSession,
} from './useUiEditorPage';
export default function UiEditorPage({
@@ -42,80 +42,26 @@ export default function UiEditorPage({
: mockUiDesignStateStore),
[projectId, projectPath, resourceId, stateStore],
);
const controller = useUiEditorPage(projectPath, resourceId, resolvedStore);
const [savedStateSignature, setSavedStateSignature] = useState<string | null>(
null,
);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const session = useUiEditorSession(projectPath, resourceId, resolvedStore);
const [saveWarningOpen, setSaveWarningOpen] = useState(false);
const [saveAfterReturn, setSaveAfterReturn] = useState(false);
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
const loadedResourceIdRef = useRef<string | undefined>(undefined);
const stateSignature = JSON.stringify(controller.editor.state);
useEffect(() => {
if (!resourceId || controller.isLoading || controller.loadError) return;
if (loadedResourceIdRef.current !== resourceId) {
loadedResourceIdRef.current = resourceId;
setSavedStateSignature(stateSignature);
setSaveError(null);
}
}, [controller.isLoading, controller.loadError, resourceId, stateSignature]);
const isDirty =
resourceId !== undefined &&
savedStateSignature !== null &&
savedStateSignature !== stateSignature;
function saveFailureMessage() {
return '保存失败,请稍后重试。';
}
async function save(afterReturn = false) {
if (
!resourceId ||
isSaving ||
controller.isLoading ||
controller.loadError ||
controller.persistedRevision === null ||
controller.editor.isLocked
) {
return;
}
setSaveError(null);
setIsSaving(true);
try {
const snapshot = structuredClone(controller.editor.state);
const snapshotSignature = JSON.stringify(snapshot);
const result = await resolvedStore.save(
resourceId,
controller.persistedRevision,
snapshot,
);
if (result.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。');
return;
}
controller.setPersistedRevision(result.revision);
if (JSON.stringify(controller.editor.state) === snapshotSignature) {
setSavedStateSignature(snapshotSignature);
}
if (await session.save.save()) {
if (afterReturn) {
setReturnConfirmOpen(false);
onBack?.();
}
} catch {
setSaveError(saveFailureMessage());
} finally {
setIsSaving(false);
}
}
function requestSave(afterReturn = false) {
if (!resourceId || isSaving || controller.isAiRunning) return;
if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) {
return;
}
setSaveAfterReturn(afterReturn);
if (controller.postCheckIssuesForSave().length > 0) {
if (session.save.hasWarnings()) {
setSaveWarningOpen(true);
return;
}
@@ -123,7 +69,7 @@ export default function UiEditorPage({
}
function requestBack() {
if (isDirty) {
if (session.save.isDirty) {
setReturnConfirmOpen(true);
return;
}
@@ -147,49 +93,41 @@ export default function UiEditorPage({
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60"
disabled={
isSaving ||
controller.isLoading ||
Boolean(controller.loadError) ||
controller.persistedRevision === null ||
controller.editor.isLocked
session.save.isSaving ||
session.save.isLoading ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked
}
onClick={() => requestSave()}
>
{isSaving ? '保存中…' : '保存'}
{session.save.isSaving ? '保存中…' : '保存'}
</button>
</header>
) : null}
{saveError ? (
{session.save.saveError ? (
<div
className="mx-4 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800"
role="alert"
>
<span>{saveError}</span>
<button
type="button"
className="shrink-0 rounded px-1 py-0.5 text-red-700 hover:bg-red-100"
aria-label="关闭保存错误提示"
onClick={() => setSaveError(null)}
>
关闭
</button>
<span>{session.save.saveError}</span>
</div>
) : null}
<ToolNavigation
activeStep={controller.activeStep}
furthestStepIndex={controller.furthestStepIndex}
disabled={controller.isAiRunning}
onChange={controller.requestStepChange}
activeStep={session.workflow.activeStep}
furthestStepIndex={session.workflow.furthestStepIndex}
disabled={session.workflow.isAiRunning}
onChange={session.workflow.requestStepChange}
/>
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-[minmax(0,24fr)_minmax(0,44fr)_minmax(0,32fr)] overflow-hidden">
<InputSidebar controller={controller} />
<InputSidebar input={session.input} />
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
<WorkflowActionCard controller={controller} />
<PreviewWorkspace controller={controller} />
<WorkflowActionCard workflow={session.workflow} />
<PreviewWorkspace canvas={session.canvas} />
</div>
<InspectorSidebar controller={controller} />
<InspectorSidebar inspector={session.inspector} />
</div>
<EditorDialogs controller={controller} />
<EditorDialogs dialogs={session.dialogs} />
<ThemedModal
open={returnConfirmOpen}
onClose={() => setReturnConfirmOpen(false)}
@@ -197,8 +135,8 @@ export default function UiEditorPage({
panelClassName="w-[420px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold">返回资源?</h2>
{saveError ? (
<p className="mt-3 text-sm text-red-600">{saveError}</p>
{session.save.saveError ? (
<p className="mt-3 text-sm text-red-600">{session.save.saveError}</p>
) : null}
<div className="mt-5 flex flex-wrap justify-end gap-2">
<button
@@ -222,44 +160,44 @@ export default function UiEditorPage({
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
disabled={
isSaving ||
Boolean(controller.loadError) ||
controller.persistedRevision === null ||
controller.editor.isLocked
session.save.isSaving ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked
}
onClick={() => requestSave(true)}
>
{isSaving ? '保存中…' : '保存并返回'}
{session.save.isSaving ? '保存中…' : '保存并返回'}
</button>
</div>
</ThemedModal>
<ThemedModal
open={controller.pendingWorkflowStepChange !== null}
onClose={controller.cancelStepChange}
open={session.workflow.pendingStepChange !== null}
onClose={session.workflow.cancelStepChange}
ariaLabel="步骤检查"
panelClassName="w-[440px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold">检查发现问题</h2>
<WorkflowIssues
title={`离开${stepLabel(controller.pendingWorkflowStepChange?.from)}`}
issues={controller.pendingWorkflowStepChange?.leavingIssues ?? []}
title={`离开${stepLabel(session.workflow.pendingStepChange?.from)}`}
issues={session.workflow.pendingStepChange?.leavingIssues ?? []}
/>
<WorkflowIssues
title={`进入${stepLabel(controller.pendingWorkflowStepChange?.to)}`}
issues={controller.pendingWorkflowStepChange?.enteringIssues ?? []}
title={`进入${stepLabel(session.workflow.pendingStepChange?.to)}`}
issues={session.workflow.pendingStepChange?.enteringIssues ?? []}
/>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={controller.cancelStepChange}
onClick={session.workflow.cancelStepChange}
>
取消
</button>
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.confirmStepChange}
onClick={session.workflow.confirmStepChange}
>
仍然继续
</button>
@@ -274,7 +212,7 @@ export default function UiEditorPage({
<h2 className="m-0 text-base font-semibold">检查发现问题</h2>
<WorkflowIssues
title="保存当前编辑结果"
issues={controller.postCheckIssuesForSave()}
issues={session.save.warnings()}
/>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<button
@@ -300,7 +238,7 @@ export default function UiEditorPage({
);
}
function stepLabel(step: UiEditorPageController['activeStep'] | undefined) {
function stepLabel(step: UiEditorWorkflowProjection['activeStep'] | undefined) {
return UI_EDITOR_STEPS.find((item) => item.id === step)?.label ?? '当前步骤';
}
@@ -9,11 +9,6 @@ import {
prepareSpriteAssetBatch,
} from '../../features/ui-editor/importAdapter';
import { applyMergeResult } from '../../features/ui-editor/merge';
import {
findNodePageContext,
pageRectFromSize,
pageRectSize,
} from '../../features/ui-editor/nodeTransformGeometry';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview';
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
@@ -112,6 +107,31 @@ function isUiNodeEffectivelyVisible(
return null;
}
function findNodeContext(
node: UiNode,
nodeId: NodeId,
parentSize: { width: number; height: number },
): { node: UiNode; parentSize: { width: number; height: number } } | null {
if (node.id === nodeId) return { node, parentSize };
const width =
parentSize.width *
(node.layout.transform.anchor_max[0] -
node.layout.transform.anchor_min[0]) +
node.layout.transform.offset_max[0] -
node.layout.transform.offset_min[0];
const height =
parentSize.height *
(node.layout.transform.anchor_max[1] -
node.layout.transform.anchor_min[1]) +
node.layout.transform.offset_max[1] -
node.layout.transform.offset_min[1];
for (const child of node.children) {
const found = findNodeContext(child, nodeId, { width, height });
if (found) return found;
}
return null;
}
function isSlaveToDescendant(
images: State['ui_design_images'],
candidateId: UIDesignImageId,
@@ -138,7 +158,13 @@ export type PendingWorkflowStepChange = {
enteringIssues: UiEditorPrerequisiteIssue[];
};
export function useUiEditorPage(
/**
* UI 编辑会话是视图与状态/平台 adapter 之间唯一的协调边界。
*
* 视图只能取得与其职责匹配的 projection;不要把这个会话对象整体
* 继续向下传递,否则会重新形成与实现等大的 controller interface。
*/
export function useUiEditorSession(
projectPath: string,
resourceId?: string,
stateStore: UiDesignStateStore = mockUiDesignStateStore,
@@ -150,6 +176,11 @@ export function useUiEditorPage(
const [persistedRevision, setPersistedRevision] = useState<number | null>(
resourceId ? null : 0,
);
const [savedStateSignature, setSavedStateSignature] = useState<string | null>(
resourceId ? null : JSON.stringify(EMPTY_UI_EDITOR_STATE),
);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [activeStep, setActiveStep] =
useState<UiEditorStepId>('reference-analysis');
const [furthestStepIndex, setFurthestStepIndex] = useState(0);
@@ -203,6 +234,8 @@ export function useUiEditorPage(
setIsLoading(false);
setLoadError(null);
setPersistedRevision(0);
setSavedStateSignature(JSON.stringify(EMPTY_UI_EDITOR_STATE));
setSaveError(null);
setHiddenNodeIds(new Set());
return;
}
@@ -225,6 +258,8 @@ export function useUiEditorPage(
setHiddenNodeIds(new Set());
setPreviewUrls({});
setPersistedRevision(revision);
setSavedStateSignature(JSON.stringify(state));
setSaveError(null);
if (projectPath) {
const scopeId = createProjectResourcePreviewScopeId();
const resources = [
@@ -299,6 +334,11 @@ export function useUiEditorPage(
!isSlaveToDescendant(images, id as UIDesignImageId, activeImageId),
);
const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging;
const stateSignature = JSON.stringify(editor.state);
const isDirty =
resourceId !== undefined &&
savedStateSignature !== null &&
savedStateSignature !== stateSignature;
const nextStep: UiEditorStepId | null =
activeStep === 'reference-analysis'
? 'structure-recognition'
@@ -447,17 +487,10 @@ export function useUiEditorPage(
const width = activeImage.pixel_size[0] / ppu;
const height = activeImage.pixel_size[1] / ppu;
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
const context = findNodePageContext(
treeForActiveImage.root,
selectedNodeId,
pageRectFromSize([width, height]),
);
if (!context) return null;
const [parentWidth, parentHeight] = pageRectSize(context.parentRect);
return {
node: context.node,
parentSize: { width: parentWidth, height: parentHeight },
};
return findNodeContext(treeForActiveImage.root, selectedNodeId, {
width,
height,
});
}, [activeImage, selectedNodeId, treeForActiveImage]);
async function importAssets(imported: ImportedAsset[]) {
@@ -955,117 +988,220 @@ export function useUiEditorPage(
}
}
async function save() {
if (
!resourceId ||
isSaving ||
isLoading ||
loadError ||
persistedRevision === null ||
editor.isLocked
) {
return false;
}
setSaveError(null);
setIsSaving(true);
const snapshot = structuredClone(editor.state);
const snapshotSignature = JSON.stringify(snapshot);
try {
const result = await stateStore.save(
resourceId,
persistedRevision,
snapshot,
);
if (result.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。');
return false;
}
setPersistedRevision(result.revision);
if (JSON.stringify(editor.state) === snapshotSignature) {
setSavedStateSignature(snapshotSignature);
}
return true;
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error));
return false;
} finally {
setIsSaving(false);
}
}
return {
projectPath,
resourceId,
isLoading,
loadError,
persistedRevision,
setPersistedRevision,
editor,
activeStep,
furthestStepIndex,
nextStep,
isAiRunning,
imageOrder,
activeImageId,
selectedSpriteId,
selectedFontId,
selectedNodeId,
highlightedStatusField,
focusRequest,
importKind,
previewUrls,
status,
pendingWorkflowStepChange,
clearOpen,
pendingRemoval,
images,
sprites,
fonts,
fontFaces,
activeImage,
selectedSprite,
selectedFont,
hiddenNodeIds,
isNodePreviewVisible,
setNodePreviewVisible,
toggleNodePreviewVisibility,
pageOptions,
spriteReferenceCounts,
fontReferenceCounts,
treeForActiveImage,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeParentSize: selectedNodeContext?.parentSize,
selectedNodeParent:
selectedNodeId && treeForActiveImage
? (findUiNodeLocation(treeForActiveImage.root, selectedNodeId)
?.parent ?? null)
: null,
keepChildrenUnchanged,
setKeepChildrenUnchanged,
requestStepChange,
continueToNextStep: () => {
if (nextStep) requestStepChange(nextStep);
input: {
projectPath,
isLocked: editor.isLocked,
uiTrees: editor.state.ui_trees,
activeStep,
imageOrder,
activeImageId,
previewUrls,
images,
sprites,
fonts,
fontFaces,
fontReferenceCounts,
selectedFontId,
spriteReferenceCounts,
selectedNodeId,
focusRequest,
operations: {
isBinding,
isMerging,
isRecognizing,
isSuggesting,
bindingStatus,
mergeStatus,
recognitionStatus,
suggestionStatus,
},
checkPrerequisites,
bindComponents,
mergeUi,
recognizeUi,
suggestUiDesignSemantics,
openImporter: setImportKind,
selectDesignImage,
selectSprite,
selectFont,
focusNode,
highlightStatusField,
isNodePreviewVisible,
toggleNodePreviewVisibility,
selectNode,
insertNode,
insertNodeAfter,
deleteNode,
moveNode,
},
canvas: {
isLocked: editor.isLocked,
activeImage,
activeImageId,
previewUrls,
images,
sprites,
fontFaces,
tree: treeForActiveImage ?? null,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeId,
hiddenNodeIds,
focusRequest,
status,
isNodePreviewVisible,
toggleNodePreviewVisibility,
selectNode,
clearNodeSelection,
updateNodeTransform,
insertNode,
insertNodeAfter,
deleteNode,
openClearDialog: () => setClearOpen(true),
},
inspector: {
isLocked: editor.isLocked,
projectPath,
activeImage,
activeImageId,
selectedSprite,
selectedSpriteId,
selectedFont,
selectedFontId,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeParentSize: selectedNodeContext?.parentSize,
selectedNodeParent:
selectedNodeId && treeForActiveImage
? (findUiNodeLocation(treeForActiveImage.root, selectedNodeId)
?.parent ?? null)
: null,
tree: treeForActiveImage ?? null,
previewUrls,
sprites,
fonts,
fontFaces,
pageOptions,
spriteReferenceCounts,
fontReferenceCounts,
keepChildrenUnchanged,
highlightedStatusField,
isNodePreviewVisible,
setNodePreviewVisible,
setKeepChildrenUnchanged,
setNodeChildrenDisplayMode,
setNodeMetadata,
setNodeTransform,
setNodeLayout,
setNodeComponents,
insertNodeComponent,
deleteNodeComponent,
moveNodeComponent,
deleteNode,
setSpriteName,
setSpriteAssetType,
setSpriteBorder,
requestSpriteRemoval,
requestFontRemoval,
setImageName,
setImageDescription,
setImageRole,
setImageSlaveTo,
requestDesignImageRemoval,
},
workflow: {
activeStep,
furthestStepIndex,
nextStep,
isAiRunning,
pendingStepChange: pendingWorkflowStepChange,
isSuggesting,
hasSuggested,
suggestionStatus,
suggestUiDesignSemantics,
isRecognizing,
hasRecognized,
recognitionStatus,
recognizeUi,
isBinding,
hasBound,
bindingStatus,
bindComponents,
requestStepChange,
continueToNextStep: () => {
if (nextStep) requestStepChange(nextStep);
},
confirmStepChange,
cancelStepChange: () => setPendingWorkflowStepChange(null),
},
dialogs: {
projectPath,
importKind,
pendingRemoval,
clearOpen,
closeImporter: () => setImportKind(null),
importAssets,
closeClearDialog: () => setClearOpen(false),
clearState,
confirmRemoval,
cancelRemoval: () => setPendingRemoval(null),
},
save: {
isLoading,
loadError,
isLocked: editor.isLocked,
persistedRevision,
isSaving,
isDirty,
saveError,
hasWarnings: () => postCheckIssuesForSave(editor.state).length > 0,
warnings: () => postCheckIssuesForSave(editor.state),
save,
},
confirmStepChange,
cancelStepChange: () => setPendingWorkflowStepChange(null),
postCheckIssuesForSave: () => postCheckIssuesForSave(editor.state),
checkPrerequisites,
selectDesignImage,
selectSprite,
selectFont,
requestFontRemoval,
selectNode,
focusNode,
clearNodeSelection,
highlightStatusField,
setNodeTransform,
updateNodeTransform,
setNodeMetadata,
setNodeLayout,
setNodeChildrenDisplayMode,
setNodeComponents,
insertNodeComponent,
deleteNodeComponent,
moveNodeComponent,
moveNode,
insertNode,
insertNodeAfter,
deleteNode,
openImporter: setImportKind,
closeImporter: () => setImportKind(null),
importAssets,
requestDesignImageRemoval,
requestSpriteRemoval,
confirmRemoval,
cancelRemoval: () => setPendingRemoval(null),
openClearDialog: () => setClearOpen(true),
closeClearDialog: () => setClearOpen(false),
clearState,
setImageName,
setImageDescription,
setImageRole,
setImageSlaveTo,
setSpriteName,
setSpriteAssetType,
setSpriteBorder,
isSuggesting,
hasSuggested,
suggestionStatus,
suggestUiDesignSemantics,
isRecognizing,
hasRecognized,
recognitionStatus,
recognizeUi,
isMerging,
mergeStatus,
mergeUi,
isBinding,
hasBound,
bindingStatus,
bindComponents,
};
}
export type UiEditorPageController = ReturnType<typeof useUiEditorPage>;
export type UiEditorSession = ReturnType<typeof useUiEditorSession>;
export type UiEditorInputProjection = UiEditorSession['input'];
export type UiEditorCanvasProjection = UiEditorSession['canvas'];
export type UiEditorInspectorProjection = UiEditorSession['inspector'];
export type UiEditorWorkflowProjection = UiEditorSession['workflow'];
export type UiEditorDialogsProjection = UiEditorSession['dialogs'];
export type UiEditorSaveIntent = UiEditorSession['save'];
@@ -0,0 +1,16 @@
# UI 编辑会话模块边界
更新时间:`2026-08-19`
`apps/ai-game-creator-shell` 的 UI 编辑器以 `useUiEditorSession` 作为视图与 adapter 的唯一协调边界。会话持有资源加载、revision、预览请求、选择、节点可见性、步骤 gate、异步操作和保存意图;`UiDesignStateStore` 与 Tauri 调用仍由该模块注入,不进入视图组件。
页面与子视图不得再传递完整 controller。它们按职责读取以下小 projection:
- `input`:资源输入、节点树及调试操作。
- `canvas`:当前画布、选择、聚焦与节点变换。
- `inspector`:当前检查对象及其编辑动作。
- `workflow`:步骤 gate 与正式 AI 操作生命周期。
- `dialogs`:导入、清空和删除确认。
- `save`:加载 / revision 状态、脏状态、保存警告和保存意图。
预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find` 的 `undefined` 传播到视图接口。