diff --git a/TRACKING.md b/TRACKING.md
index b3698a2b..173f757f 100644
--- a/TRACKING.md
+++ b/TRACKING.md
@@ -116,3 +116,5 @@
- 2026-06-17 侧栏拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`;`画布背景色` 打开 `画布背景设置` dialog,包含预设、自定义颜色、HEX 和恢复默认;使用临时开发账号登录后上传图片成功进入 `项目素材`,点击素材可添加到画布,切换 `图层` 侧栏后能看到同一图片图层,`AI画布工具栏` 保持可见。
- 2026-06-17 前端拆分第三阶段:新增 `ImageCanvasStageView`,把画布工作区视觉树、图层渲染、生成占位框、右键菜单、左下 dock、小地图和底部 AI 工具栏从主视图抽出;拖拽 / 缩放、历史、上传、登录、生成提交、素材持久化和右键命令仍保留在主视图,避免拆散状态机。
- 2026-06-17 舞台拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`;关闭登录后点击 `画布背景色` 打开完整 `画布背景设置` dialog,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`;使用临时开发账号密码登录后上传 `smoke.png` 成功进入 `项目素材`,点击素材添加到画布,切换 `图层` 后显示同一图层,图片浮动工具栏、小地图和 `AI画布工具栏` 保持可见。
+- 2026-06-17 前端拆分第四阶段:新增 `ImageCanvasGenerationComposerView`,把生成图片、生成规范、生成角色形象、生成图标素材、快速编辑、角色动画和修改图片弹窗从主视图抽出;生成提交、上传 input、引用选择、占位框拖拽、结果回写、历史和画布状态机仍保留在主视图。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`。
+- 2026-06-17 生成面板拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空浏览器数据后未登录刷新弹出 `账号入口`;关闭登录后 `画布背景色` 打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`;点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 跟随对话框,`AI画布工具栏` 保持可见;使用临时开发账号密码登录后上传素材成功,点击素材可添加到画布,切换 `图层` 面板可看到对应图层。
diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
index b246398d..478a7274 100644
--- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
+++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
@@ -50,9 +50,17 @@
第三阶段以后,主视图仍是画布编排入口。继续拆分前应优先选择能形成稳定边界的深模块,避免把上传链路、DataTransfer、画布坐标和历史快照拆成互相回调的小碎片。
+## 第四阶段模块
+
+- `ImageCanvasGenerationComposerView.tsx`
+ - 承载生成图片、生成规范、生成角色形象、生成图标素材、快速编辑图片、角色动画和修改图片弹窗的视觉表单。
+ - 保留生成对象状态机、提交 API、上传文件 input、引用选择、生成结果回写、图层历史和坐标锚定在主视图内,避免把 Lovart 式生成对象拆成不可追踪的远程状态。
+ - 该组件可以管理局部字段输入和菜单展示,但所有会影响画布事实的动作都通过主视图回调落回原有状态机。
+
## 后续阶段
-- `ImageCanvasGenerationDock`:底部 AI 工具栏和生成面板族,等生成对象状态机进一步收口后再拆。
+- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
+- 画布命令模型:右键菜单、图层层级、分组、锁定和隐藏命令可在保持历史快照一致后继续收口。
## 验证计划
diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx
index 1ce17e8a..525d7aae 100644
--- a/src/components/image-editor/ImageCanvasEditorView.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.tsx
@@ -1,11 +1,7 @@
import {
Check,
- ChevronDown,
ChevronLeft,
- ClipboardList,
Download,
- ImageIcon,
- ImagePlus,
Pencil,
X,
} from 'lucide-react';
@@ -15,7 +11,6 @@ import {
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
- type ReactNode,
useCallback,
useEffect,
useMemo,
@@ -23,7 +18,6 @@ import {
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
-import { createPortal } from 'react-dom';
import { ApiClientError } from '../../services/apiClient';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
@@ -48,22 +42,12 @@ import {
updateEditorAsset,
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
-import { PlatformActionButton } from '../common/PlatformActionButton';
-import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
-import {
- PlatformFloatingMenu,
- PlatformFloatingMenuItem,
-} from '../common/PlatformFloatingMenu';
-import { PlatformIconButton } from '../common/PlatformIconButton';
-import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
-import {
- PlatformSelectField,
- PlatformTextField,
-} from '../common/PlatformTextField';
+import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { useAuthUi } from '../auth/AuthUiContext';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
+import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView';
import {
@@ -106,13 +90,10 @@ import {
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import {
- CHARACTER_ANIMATION_ACTION_PROMPTS,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
- CHARACTER_ANIMATION_RATIO_OPTIONS,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
- CHARACTER_SPEC_VIEW_OPTIONS,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SPEC_FORM_VALUES,
@@ -124,7 +105,6 @@ import {
ICON_FRAME_ORIGINAL_SIZE,
SPEC_FRAME_DISPLAY_SIZE,
SPEC_FRAME_ORIGINAL_SIZE,
- SPEC_GENERATION_COST,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
buildCharacterGenerationInputs,
@@ -173,48 +153,6 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
-function triggerPlaceholderAction(label: string) {
- window.alert(`${label}功能建设中`);
-}
-
-function buildPortalMenuStyle(
- anchor: HTMLElement | null,
- placement: 'above' | 'below',
-): CSSProperties {
- const rect = anchor?.getBoundingClientRect();
- if (!rect) {
- return {
- position: 'fixed',
- left: 0,
- top: 0,
- right: 'auto',
- bottom: 'auto',
- zIndex: 70,
- };
- }
-
- return {
- position: 'fixed',
- left: Math.round(rect.left),
- top:
- placement === 'above'
- ? Math.round(rect.top)
- : Math.round(rect.bottom + 8),
- right: 'auto',
- bottom: 'auto',
- zIndex: 70,
- transform:
- placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined,
- };
-}
-
-function renderEditorPortal(node: ReactNode) {
- if (typeof document === 'undefined') {
- return node;
- }
- return createPortal(node, document.body);
-}
-
function isImageFile(file: File) {
return file.type.startsWith('image/');
}
@@ -4446,1128 +4384,62 @@ export function ImageCanvasEditorView() {
onMinimapPointerDown={handleMinimapPointerDown}
onSwitchTool={switchTool}
>
-
- {isSpecMenuOpen
- ? renderEditorPortal(
-
- {(['character', 'ui', 'custom'] as const).map((specType) => (
- openSpecDialog(specType)}
- >
- {SPEC_TYPE_LABEL[specType]}
-
- ))}
- ,
- )
- : null}
-
- {generateDialog?.mode === 'generate' &&
- generateDialog.composerOpen !== false &&
- generationComposerStyle ? (
-
- ) : null}
-
- {generateDialog?.mode === 'spec' &&
- generateDialog.composerOpen !== false &&
- generationComposerStyle ? (
-
- ) : null}
-
- {generateDialog?.mode === 'character' && generationComposerStyle ? (
-
- ) : null}
-
- {generateDialog?.mode === 'icon' &&
- generateDialog.composerOpen !== false &&
- iconComposerStyle ? (
-
- ) : null}
-
- {isPickingCharacterSpecFromCanvas ? (
-
- 请选择画布中的图片作为角色形象规范,按 Esc 退出
-
- ) : null}
- {isPickingIconSpecFromCanvas ? (
-
- 请选择画布中的图标素材规范,按 Esc 退出
-
- ) : null}
-
- {quickEditPanel &&
- quickEditPanel.status !== 'generating' &&
- quickEditSourceLayer &&
- quickEditPanelStyle ? (
-
- ) : null}
-
-
-
- {characterAnimationPanel &&
- characterAnimationSourceLayer &&
- characterAnimationPanelStyle ? (
-
- ) : null}
+ : currentDialog,
+ );
+ setActiveTool('select');
+ }}
+ onUpdateSpecFormValue={updateSpecFormValue}
+ onUpdateIconDescription={updateIconDescription}
+ onAddIconDescription={addIconDescription}
+ onUpdateCharacterAnimationDuration={updateCharacterAnimationDuration}
+ />
+
@@ -5642,97 +4514,6 @@ export function ImageCanvasEditorView() {
) : null}
-
- setGenerateDialog(null)}
- panelClassName="image-canvas-editor__generate-dialog"
- bodyClassName="image-canvas-editor__generate-dialog-body"
- >
- {generateDialog?.mode === 'edit' ? (
-
- ) : null}
-
);
}
diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx
new file mode 100644
index 00000000..444c363f
--- /dev/null
+++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx
@@ -0,0 +1,1318 @@
+import {
+ ChevronDown,
+ ClipboardList,
+ ImageIcon,
+ ImagePlus,
+ X,
+} from 'lucide-react';
+import {
+ type CSSProperties,
+ type Dispatch,
+ type ReactNode,
+ type RefObject,
+ type SetStateAction,
+} from 'react';
+import { createPortal } from 'react-dom';
+
+import { PlatformActionButton } from '../common/PlatformActionButton';
+import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
+import {
+ PlatformFloatingMenu,
+ PlatformFloatingMenuItem,
+} from '../common/PlatformFloatingMenu';
+import { PlatformIconButton } from '../common/PlatformIconButton';
+import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
+import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
+import {
+ PlatformSelectField,
+ PlatformTextField,
+} from '../common/PlatformTextField';
+import { UnifiedModal } from '../common/UnifiedModal';
+import { EditorIconButton } from './ImageCanvasEditorPrimitives';
+import {
+ CHARACTER_ANIMATION_ACTION_PROMPTS,
+ CHARACTER_ANIMATION_DURATION_OPTIONS,
+ CHARACTER_ANIMATION_RATIO_OPTIONS,
+ CHARACTER_SPEC_VIEW_OPTIONS,
+ DEFAULT_ICON_DESCRIPTIONS,
+ ICON_DESCRIPTION_LIMIT,
+ SPEC_GENERATION_COST,
+ SPEC_TYPE_LABEL,
+} from './ImageCanvasGenerationModel';
+import type {
+ CharacterAnimationPanelState,
+ CanvasLayer,
+ GenerateDialogState,
+ QuickEditPanelState,
+ SpecFormValues,
+ SpecGenerationType,
+ UploadTarget,
+} from './ImageCanvasEditorTypes';
+
+type ImageCanvasGenerationComposerViewProps = {
+ specToolWrapRef: RefObject;
+ characterSpecButtonRef: RefObject;
+ iconSpecButtonRef: RefObject;
+ isSpecMenuOpen: boolean;
+ isCharacterSpecMenuOpen: boolean;
+ isIconSpecMenuOpen: boolean;
+ isPickingCharacterSpecFromCanvas: boolean;
+ isPickingIconSpecFromCanvas: boolean;
+ generateDialog: GenerateDialogState | null;
+ generationComposerStyle: CSSProperties | null;
+ iconComposerStyle: CSSProperties | null;
+ quickEditPanel: QuickEditPanelState | null;
+ quickEditSourceLayer: CanvasLayer | null;
+ quickEditPanelStyle: CSSProperties | null;
+ quickEditSizeOptions: string[];
+ quickEditModelOptions: Array<{ label: string; value: string }>;
+ characterAnimationPanel: CharacterAnimationPanelState | null;
+ characterAnimationSourceLayer: CanvasLayer | null;
+ characterAnimationPanelStyle: CSSProperties | null;
+ characterAnimationPrice: number;
+ setGenerateDialog: Dispatch>;
+ setQuickEditPanel: Dispatch>;
+ setCharacterAnimationPanel: Dispatch<
+ SetStateAction
+ >;
+ setIsCharacterSpecMenuOpen: Dispatch>;
+ setIsIconSpecMenuOpen: Dispatch>;
+ setIsPickingCharacterSpecFromCanvas: Dispatch>;
+ setIsPickingIconSpecFromCanvas: Dispatch>;
+ onOpenSpecDialog: (specType: SpecGenerationType) => void;
+ onRequestUpload: (target: UploadTarget) => void;
+ onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
+ onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
+ onSubmitQuickEdit: () => void;
+ onSubmitCharacterAnimation: () => void;
+ onCloseGenerateComposer: () => void;
+ onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
+ onUpdateIconDescription: (index: number, value: string) => void;
+ onAddIconDescription: () => void;
+ onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
+};
+
+function triggerPlaceholderAction(label: string) {
+ window.alert(`${label}功能建设中`);
+}
+
+function buildPortalMenuStyle(
+ anchor: HTMLElement | null,
+ placement: 'above' | 'below',
+): CSSProperties {
+ const rect = anchor?.getBoundingClientRect();
+ if (!rect) {
+ return {
+ position: 'fixed',
+ left: 0,
+ top: 0,
+ right: 'auto',
+ bottom: 'auto',
+ zIndex: 70,
+ };
+ }
+
+ return {
+ position: 'fixed',
+ left: Math.round(rect.left),
+ top:
+ placement === 'above'
+ ? Math.round(rect.top)
+ : Math.round(rect.bottom + 8),
+ right: 'auto',
+ bottom: 'auto',
+ zIndex: 70,
+ transform:
+ placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined,
+ };
+}
+
+function renderEditorPortal(node: ReactNode) {
+ if (typeof document === 'undefined') {
+ return node;
+ }
+ return createPortal(node, document.body);
+}
+
+function resetFailedDialogStatus(dialog: GenerateDialogState) {
+ return {
+ ...dialog,
+ status: dialog.status === 'failed' ? 'idle' : dialog.status,
+ errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
+ };
+}
+
+function resetFailedPanelStatus(
+ panel: T,
+) {
+ return {
+ ...panel,
+ status: panel.status === 'failed' ? 'idle' : panel.status,
+ errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
+ };
+}
+
+export function ImageCanvasGenerationComposerView({
+ specToolWrapRef,
+ characterSpecButtonRef,
+ iconSpecButtonRef,
+ isSpecMenuOpen,
+ isCharacterSpecMenuOpen,
+ isIconSpecMenuOpen,
+ isPickingCharacterSpecFromCanvas,
+ isPickingIconSpecFromCanvas,
+ generateDialog,
+ generationComposerStyle,
+ iconComposerStyle,
+ quickEditPanel,
+ quickEditSourceLayer,
+ quickEditPanelStyle,
+ quickEditSizeOptions,
+ quickEditModelOptions,
+ characterAnimationPanel,
+ characterAnimationSourceLayer,
+ characterAnimationPanelStyle,
+ characterAnimationPrice,
+ setGenerateDialog,
+ setQuickEditPanel,
+ setCharacterAnimationPanel,
+ setIsCharacterSpecMenuOpen,
+ setIsIconSpecMenuOpen,
+ setIsPickingCharacterSpecFromCanvas,
+ setIsPickingIconSpecFromCanvas,
+ onOpenSpecDialog,
+ onRequestUpload,
+ onSubmitImageGeneration,
+ onSubmitIconSpritesheetGeneration,
+ onSubmitQuickEdit,
+ onSubmitCharacterAnimation,
+ onCloseGenerateComposer,
+ onUpdateSpecFormValue,
+ onUpdateIconDescription,
+ onAddIconDescription,
+ onUpdateCharacterAnimationDuration,
+}: ImageCanvasGenerationComposerViewProps) {
+ return (
+ <>
+ {isSpecMenuOpen
+ ? renderEditorPortal(
+
+ {(['character', 'ui', 'custom'] as const).map((specType) => (
+ onOpenSpecDialog(specType)}
+ >
+ {SPEC_TYPE_LABEL[specType]}
+
+ ))}
+ ,
+ )
+ : null}
+
+ {generateDialog?.mode === 'generate' &&
+ generateDialog.composerOpen !== false &&
+ generationComposerStyle ? (
+
+ ) : null}
+
+ {generateDialog?.mode === 'spec' &&
+ generateDialog.composerOpen !== false &&
+ generationComposerStyle ? (
+
+ ) : null}
+
+ {generateDialog?.mode === 'character' && generationComposerStyle ? (
+
+ ) : null}
+
+ {generateDialog?.mode === 'icon' &&
+ generateDialog.composerOpen !== false &&
+ iconComposerStyle ? (
+
+ ) : null}
+
+ {isPickingCharacterSpecFromCanvas ? (
+
+ 请选择画布中的图片作为角色形象规范,按 Esc 退出
+
+ ) : null}
+ {isPickingIconSpecFromCanvas ? (
+
+ 请选择画布中的图标素材规范,按 Esc 退出
+
+ ) : null}
+
+ {quickEditPanel &&
+ quickEditPanel.status !== 'generating' &&
+ quickEditSourceLayer &&
+ quickEditPanelStyle ? (
+
+ ) : null}
+
+ {characterAnimationPanel &&
+ characterAnimationSourceLayer &&
+ characterAnimationPanelStyle ? (
+
+ ) : null}
+
+ setGenerateDialog(null)}
+ panelClassName="image-canvas-editor__generate-dialog"
+ bodyClassName="image-canvas-editor__generate-dialog-body"
+ >
+ {generateDialog?.mode === 'edit' ? (
+
+ ) : null}
+
+ >
+ );
+}