拆分图片画布生成表面编排

新增 useImageCanvasGenerationSurface 收口生成浮层编排。

主视图移除生成 Composer 大段 props 胶水。

舞台控制模型移除重复生成锚点派生。

补充生成表面 hook 单测并更新拆分文档与跟踪记录。
This commit is contained in:
2026-06-17 14:20:29 +08:00
parent 05a47816b0
commit 84818f9bd5
10 changed files with 494 additions and 231 deletions

View File

@@ -142,3 +142,4 @@
- 2026-06-17 前端拆分第二十四阶段:新增 `useImageCanvasAssetCanvasBridge`,把删除素材清理画布图层、素材加入画布、素材 pointer 拖入画布 / 文件夹、画布 drag/drop 分流和文件拖入画布参数组装从主视图抽成素材到画布桥接 hook主视图继续保留素材库事实、上传读取、工程资源持久化和历史触发。新增 hook 单测覆盖素材建层、pointer drop 入画布和删除素材清理关联图层 / 选中态;主视图从 1133 行降至 1086 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 是完整设置面板包含当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`HEX 输入 `#ffffff` 后变为白色,按 Escape 关闭面板;登录后上传图片请求 `/api/editor/assets` 返回 200素材库出现上传素材`AI画布工具栏` 保持可见。
- 2026-06-17 前端拆分第二十五阶段:新增 `ImageCanvasStageControllerModel``useImageCanvasStageController`,把舞台派生状态、生成 / 选中浮层位置、右键菜单目标、空白画布右键、图层右键和清空画布焦点从主视图抽出;主视图继续保留工具切换、上传 / 生成入口、图层命令、项目持久化和舞台 pointer 状态机。新增模型和 hook 单测覆盖选中 / 生成锚定、右键菜单位置、显示 / 解锁判断、清空焦点和空白 / 图层右键菜单;主视图从 1086 行降至 993 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx``npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/routing/appPageRoutes.test.ts``npm run typecheck`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`,关闭登录后 `画布背景色` 打开完整 `画布背景设置` dialog包含色相、自定义颜色、预设、HEX 和恢复默认;点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``AI画布工具栏` 保持可见,截图留存于 `output/playwright/editor-stage-controller-smoke-20260617.png`
- 2026-06-17 前端拆分第二十六阶段:新增 `ImageCanvasTopbarView`,把返回项目入口、项目标题展示 / 重命名表单、下载画布素材按钮和导出状态提示从主视图抽出;主视图继续保留 chrome hook、项目持久化、导出工作流和实际导出副作用。新增组件单测覆盖返回入口、标题编辑入口、重命名提交 / 取消、导出按钮禁用 / 启用和导出状态提示;主视图从 993 行降至 905 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasTopbarView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录直接显示 `账号入口`,顶栏返回项目入口、项目名、`画布` 标签和下载按钮均可见;关闭登录后打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``AI画布工具栏` 保持可见,截图留存于 `output/playwright/editor-topbar-smoke-20260617.png`
- 2026-06-17 前端拆分第二十七阶段:新增 `useImageCanvasGenerationSurface`,把生成 Composer JSX、生成工具切换分流、普通生图 / 图标生成 / 快速编辑 / 角色动画浮层定位从主视图抽出;`useImageCanvasGenerationWorkflow` 继续负责生成状态机和真实 API 提交。同步移除 `ImageCanvasStageControllerModel` 中重复的生成锚点 / Composer 位置派生,避免舞台控制器和生成表面重复持有生成浮层职责;主视图从 905 行降至 793 行。rebase 到远端 `支持规范参考图输入` 后,生成表面继续透传角色形象规范和常规参考图入口,并把左下 dock / 底部工具栏层级提到 Composer 之上,避免生成输入框盖住常用工具和背景面板。验证命令:`npm run test -- src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录直接显示 `账号入口`,关闭登录后 `画布背景设置` 保持完整面板,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,再点击 `生成角色形象` 能打开包含 `角色形象规范``常规参考图` 的对话框,控制台仅有未登录 refresh 401。

View File

@@ -210,9 +210,9 @@
## 第二十五阶段模块
- `ImageCanvasStageControllerModel.ts`
- 承载舞台派生状态和右键菜单模型:选中图层、生成对象锚点、生成输入框位置、选中浮动工具栏位置、图片菜单图层、右键菜单目标图层,以及显示 / 解锁菜单文案判断。
- 该模型复用既有图层命令模型与浮层定位模型,不重新实现坐标公式,避免拆分后出现第二套右键目标和浮层定位规则
- 新增单测覆盖生成锚定、选中工具栏位置、右键目标集合、显示 / 解锁判断和菜单位置限制。
- 承载舞台派生状态和右键菜单模型:选中图层、选中浮动工具栏位置、图片菜单图层、右键菜单目标图层,以及显示 / 解锁菜单文案判断。
- 该模型复用既有图层命令模型与浮层定位模型,不重新实现右键目标和选中工具栏坐标规则;生成 Composer 锚点不属于舞台控制器,后续由生成表面编排统一负责
- 新增单测覆盖选中工具栏位置、右键目标集合、显示 / 解锁判断和菜单位置限制。
- `useImageCanvasStageController.ts`
- 承载舞台控制胶水:清空画布焦点、空白画布右键菜单和图层右键菜单处理。
@@ -227,6 +227,14 @@
- 新增组件单测覆盖返回入口、标题编辑入口、重命名提交 / 取消、导出按钮禁用 / 启用和导出状态提示。
- 本阶段主视图从 993 行降至 905 行;后续可继续评估隐藏上传 input / 侧栏拖拽预览这类根级浮层是否值得抽为编辑器 shell 视图。
## 第二十七阶段模块
- `useImageCanvasGenerationSurface.tsx`
- 承载 Lovart 式生成表面编排:组合 `useImageCanvasGenerationWorkflow`,统一计算普通生图、图标生成、快速编辑和角色动画的浮层位置,并构建 `ImageCanvasGenerationComposerView` 节点。
- `useImageCanvasGenerationWorkflow` 继续作为生成状态机和真实 API 提交入口;生成表面 hook 只收口 Composer JSX、工具切换分流和浮层定位避免主视图继续维护大段生成 props 胶水。
- 本阶段同步把生成 Composer 锚点从 `ImageCanvasStageControllerModel` 移出,避免舞台控制器和生成表面各自计算一套生成浮层状态。
- 新增 hook 单测覆盖生成工具切换、Composer 位置、关闭生成输入框和规范菜单;主视图从 905 行降至 793 行。
## 后续阶段
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
@@ -234,7 +242,7 @@
## 验证计划
- `npm run test -- src/components/image-editor/ImageCanvasTopbarView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`
- `npm run test -- src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
- `git diff --check`

View File

@@ -1,18 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAuthUi } from '../auth/AuthUiContext';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView';
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
import {
isCanvasGenerationComposerVisible,
resolveCharacterAnimationPanelStyle,
resolveIconComposerStyle,
resolveQuickEditPanelStyle,
} from './ImageCanvasOverlayModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
import type {
AssetPointerDragState,
CanvasContextMenuState,
@@ -30,7 +24,7 @@ import {
} from './useImageCanvasAssetCanvasBridge';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
@@ -330,13 +324,44 @@ export function ImageCanvasEditorView() {
projectId,
projectTitle,
});
const generationWorkflow = useImageCanvasGenerationWorkflow({
const {
uploadInputRef,
setUploadTarget,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
} = useImageCanvasUploadWorkflow({
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
assetFolders,
activeUploadFolderId,
canvasSize,
viewport,
activeTool,
allocateUploadIndex: () => {
layerCounterRef.current += 1;
return layerCounterRef.current;
},
setAssetFolders,
setAssets,
setLayers,
setGenerateDialog,
setActiveSidebarPanel,
appendCanvasLayersWithResources,
selectSingleLayer,
});
const generationSurface = useImageCanvasGenerationSurface({
layers,
canvasSize,
viewport,
layerCounterRef,
specToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
@@ -349,58 +374,35 @@ export function ImageCanvasEditorView() {
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
requestUpload,
});
const {
quickEditPanel,
setQuickEditPanel,
quickEditSourceLayer,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
setCharacterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPrice,
iconDescriptionValues,
isSpecMenuOpen,
setIsSpecMenuOpen,
isCharacterSpecMenuOpen,
setIsCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
setIsCharacterReferenceMenuOpen,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
isIconSpecMenuOpen,
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
openGenerateDialog,
openSpecDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
openIconGenerationDialog,
openEditDialog,
openQuickEditPanel,
pickCharacterSpecFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
submitIconSpritesheetGeneration,
submitQuickEdit,
submitImageGeneration,
updateSpecFormValue,
updateIconDescription,
addIconDescription,
updateCharacterAnimationDuration,
rememberImageModel,
submitCharacterAnimation,
hideGeneratedLayerPanelAfterBlur,
closeGenerateComposer,
clearDeletedLayerGenerationState,
} = generationWorkflow;
generationComposerStyle,
generationComposerNode,
switchGenerationTool,
} = generationSurface;
const {
selectedLayer,
generationComposerStyle,
selectedToolbarStyle,
imageContextMenuLayer,
contextShouldShowLayer,
@@ -412,7 +414,6 @@ export function ImageCanvasEditorView() {
layers,
selectedLayerId,
selectedLayerIds,
activeCanvasGenerationDialog,
imageContextMenu,
setImageContextMenu,
contextMenu,
@@ -423,23 +424,6 @@ export function ImageCanvasEditorView() {
hideGeneratedLayerPanelAfterBlur,
getCanvasPointFromClient,
});
const iconComposerStyle = resolveIconComposerStyle({
dialog: activeCanvasGenerationDialog,
composerStyle: generationComposerStyle,
iconDescriptionCount: iconDescriptionValues.length,
});
const quickEditPanelStyle = resolveQuickEditPanelStyle({
panel: quickEditPanel,
sourceLayer: quickEditSourceLayer,
viewport,
canvasSize,
});
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
panel: characterAnimationPanel,
sourceLayer: characterAnimationSourceLayer,
viewport,
canvasSize,
});
const {
canvasClipboard,
pasteCanvasClipboard,
@@ -474,32 +458,6 @@ export function ImageCanvasEditorView() {
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
exportLayerImage,
});
const {
uploadInputRef,
setUploadTarget,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
} = useImageCanvasUploadWorkflow({
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
assetFolders,
activeUploadFolderId,
canvasSize,
viewport,
activeTool,
allocateUploadIndex: () => {
layerCounterRef.current += 1;
return layerCounterRef.current;
},
setAssetFolders,
setAssets,
setLayers,
setGenerateDialog,
setActiveSidebarPanel,
appendCanvasLayersWithResources,
selectSingleLayer,
});
const {
canvasMarquee,
isPanning,
@@ -627,21 +585,7 @@ export function ImageCanvasEditorView() {
requestUpload('asset');
return;
}
if (tool === 'generate') {
openGenerateDialog();
return;
}
if (tool === 'spec') {
setIsSpecMenuOpen((open) => !open);
setActiveTool('spec');
return;
}
if (tool === 'character') {
openCharacterGenerationDialog();
return;
}
if (tool === 'icon') {
openIconGenerationDialog();
if (switchGenerationTool(tool)) {
return;
}
setActiveTool(tool);
@@ -844,74 +788,7 @@ export function ImageCanvasEditorView() {
onMinimapPointerDown={handleMinimapPointerDown}
onSwitchTool={switchTool}
>
<ImageCanvasGenerationComposerView
specToolWrapRef={specToolWrapRef}
characterSpecButtonRef={characterSpecButtonRef}
characterReferenceButtonRef={characterReferenceButtonRef}
iconSpecButtonRef={iconSpecButtonRef}
isSpecMenuOpen={isSpecMenuOpen}
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
isIconSpecMenuOpen={isIconSpecMenuOpen}
isPickingCharacterSpecFromCanvas={isPickingCharacterSpecFromCanvas}
isPickingCharacterReferenceFromCanvas={
isPickingCharacterReferenceFromCanvas
}
isPickingIconSpecFromCanvas={isPickingIconSpecFromCanvas}
generateDialog={generateDialog}
generationComposerStyle={generationComposerStyle}
iconComposerStyle={iconComposerStyle}
quickEditPanel={quickEditPanel}
quickEditSourceLayer={quickEditSourceLayer}
quickEditPanelStyle={quickEditPanelStyle}
quickEditSizeOptions={quickEditSizeOptions}
quickEditModelOptions={quickEditModelOptions}
characterAnimationPanel={characterAnimationPanel}
characterAnimationSourceLayer={characterAnimationSourceLayer}
characterAnimationPanelStyle={characterAnimationPanelStyle}
characterAnimationPrice={characterAnimationPrice}
setGenerateDialog={setGenerateDialog}
setQuickEditPanel={setQuickEditPanel}
setCharacterAnimationPanel={setCharacterAnimationPanel}
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
setIsPickingCharacterSpecFromCanvas={
setIsPickingCharacterSpecFromCanvas
}
setIsPickingCharacterReferenceFromCanvas={
setIsPickingCharacterReferenceFromCanvas
}
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
onOpenSpecDialog={openSpecDialog}
onRequestUpload={requestUpload}
onSubmitImageGeneration={(dialog) =>
void submitImageGeneration(dialog)
}
onSubmitIconSpritesheetGeneration={(dialog) =>
void submitIconSpritesheetGeneration(dialog)
}
onSubmitQuickEdit={() => void submitQuickEdit()}
onSubmitCharacterAnimation={() => void submitCharacterAnimation()}
onCloseGenerateComposer={() => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
setActiveTool('select');
}}
onUpdateSpecFormValue={updateSpecFormValue}
onUpdateIconDescription={updateIconDescription}
onAddIconDescription={addIconDescription}
onUpdateCharacterAnimationDuration={
updateCharacterAnimationDuration
}
onRememberImageModel={rememberImageModel}
/>
{generationComposerNode}
</ImageCanvasStageView>
</div>

View File

@@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest';
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
@@ -32,28 +31,8 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
};
}
function createDialog(
overrides: Partial<CanvasGenerationDialogState> = {},
): CanvasGenerationDialogState {
return {
id: 'dialog-a',
mode: 'generate',
prompt: '',
status: 'idle',
placeholder: {
x: 300,
y: 200,
width: 360,
height: 260,
originalWidth: 1024,
originalHeight: 1024,
},
...overrides,
};
}
describe('ImageCanvasStageControllerModel', () => {
it('derives selected layer, generation anchor, and overlay positions', () => {
it('derives selected layer, selected toolbar, and context image target', () => {
const selectedLayer = createLayer({ id: 'selected', x: 40, y: 60 });
const generatedLayer = createLayer({
id: 'generated',
@@ -62,13 +41,11 @@ describe('ImageCanvasStageControllerModel', () => {
width: 320,
height: 180,
});
const dialog = createDialog({ generatedLayerId: generatedLayer.id });
const model = resolveImageCanvasStageControllerModel({
layers: [selectedLayer, generatedLayer],
selectedLayerId: selectedLayer.id,
selectedLayerIds: [selectedLayer.id, generatedLayer.id],
activeCanvasGenerationDialog: dialog,
imageContextMenu: { layerId: generatedLayer.id, x: 0, y: 0 },
contextMenu: null,
viewport: { x: 10, y: 20, scale: 2 },
@@ -78,9 +55,6 @@ describe('ImageCanvasStageControllerModel', () => {
expect(model.selectedLayer).toBe(selectedLayer);
expect(model.selectedLayerCount).toBe(2);
expect(model.hasMultipleSelectedLayers).toBe(true);
expect(model.activeGenerationLayer).toBe(generatedLayer);
expect(model.generationAnchor).toBe(generatedLayer);
expect(model.generationComposerStyle).toEqual({ left: 730, top: 710 });
expect(model.selectedToolbarStyle).toEqual({ left: 330, top: 128 });
expect(model.imageContextMenuLayer).toBe(generatedLayer);
});
@@ -100,7 +74,6 @@ describe('ImageCanvasStageControllerModel', () => {
layers: [visibleLayer, hiddenLayer],
selectedLayerId: visibleLayer.id,
selectedLayerIds: [visibleLayer.id, hiddenLayer.id],
activeCanvasGenerationDialog: null,
imageContextMenu: null,
contextMenu,
viewport: { x: 0, y: 0, scale: 1 },

View File

@@ -7,14 +7,11 @@ import {
} from './ImageCanvasEditorModel';
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
import {
resolveGenerationAnchor,
resolveGenerationComposerStyle,
resolveSelectedToolbarStyle,
} from './ImageCanvasOverlayModel';
@@ -22,9 +19,6 @@ export type ImageCanvasStageControllerModel = {
selectedLayer: CanvasLayer | null;
selectedLayerCount: number;
hasMultipleSelectedLayers: boolean;
activeGenerationLayer: CanvasLayer | null;
generationAnchor: CanvasLayer | CanvasGenerationDialogState['placeholder'] | null;
generationComposerStyle: ReturnType<typeof resolveGenerationComposerStyle>;
selectedToolbarStyle: ReturnType<typeof resolveSelectedToolbarStyle>;
imageContextMenuLayer: CanvasLayer | null;
contextTargetIds: string[];
@@ -37,7 +31,6 @@ type ResolveImageCanvasStageControllerModelOptions = {
layers: CanvasLayer[];
selectedLayerId: string | null;
selectedLayerIds: string[];
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
imageContextMenu: ImageContextMenuState | null;
contextMenu: CanvasContextMenuState | null;
viewport: CanvasViewport;
@@ -48,7 +41,6 @@ export function resolveImageCanvasStageControllerModel({
layers,
selectedLayerId,
selectedLayerIds,
activeCanvasGenerationDialog,
imageContextMenu,
contextMenu,
viewport,
@@ -57,18 +49,6 @@ export function resolveImageCanvasStageControllerModel({
const selectedLayer =
layers.find((layer) => layer.id === selectedLayerId) ?? null;
const selectedLayerCount = selectedLayerIds.length;
const activeGenerationLayer =
activeCanvasGenerationDialog?.generatedLayerId
? (layers.find(
(layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId,
) ?? null)
: null;
const generationAnchor = activeCanvasGenerationDialog
? resolveGenerationAnchor({
dialog: activeCanvasGenerationDialog,
generatedLayer: activeGenerationLayer,
})
: null;
const imageContextMenuLayer = imageContextMenu
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
: null;
@@ -82,13 +62,6 @@ export function resolveImageCanvasStageControllerModel({
selectedLayer,
selectedLayerCount,
hasMultipleSelectedLayers: selectedLayerCount > 1,
activeGenerationLayer,
generationAnchor,
generationComposerStyle: resolveGenerationComposerStyle({
dialog: activeCanvasGenerationDialog,
anchor: generationAnchor,
viewport,
}),
selectedToolbarStyle: resolveSelectedToolbarStyle({
selectedLayer,
viewport,

View File

@@ -0,0 +1,181 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { useRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
vi.mock('../../services/image-editor/editorImageReference', () => ({
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
}));
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: vi.fn(),
generateEditorCharacterAnimation: vi.fn(),
generateEditorIconSpritesheet: vi.fn(),
generateEditorImage: vi.fn(),
};
});
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
const id = overrides.id ?? 'layer-a';
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `data:image/png;base64,${id}`,
x: 80,
y: 90,
width: 240,
height: 180,
originalWidth: 240,
originalHeight: 180,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
function GenerationSurfaceHarness() {
const [layers, setLayers] = useState<CanvasLayer[]>([createLayer()]);
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [, setImageContextMenu] = useState<ImageContextMenuState | null>(null);
const layerCounterRef = useRef(0);
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const dialogs = useCanvasGenerationDialogs();
const activeDialog = dialogs.generateDialog;
const activeCanvasDialog =
activeDialog && 'id' in activeDialog
? (activeDialog as CanvasGenerationDialogState)
: null;
const surface = useImageCanvasGenerationSurface({
layers,
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
layerCounterRef,
specToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
generateDialog: dialogs.generateDialog,
setGenerateDialog: dialogs.setGenerateDialog,
activeCanvasGenerationDialog: activeCanvasDialog,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
selectSingleLayer: () => {},
fitLayers: () => {},
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
requestUpload: () => {},
});
return (
<div>
<span ref={specToolWrapRef}></span>
<span data-testid="tool">{activeTool}</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="dialog">
{activeDialog
? `${activeDialog.mode}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
: '-'}
</span>
<span data-testid="composer-left">
{surface.generationComposerStyle?.left ?? '-'}
</span>
<span data-testid="composer-top">
{surface.generationComposerStyle?.top ?? '-'}
</span>
<button
type="button"
onClick={() => surface.switchGenerationTool('generate')}
>
</button>
<button type="button" onClick={() => surface.switchGenerationTool('spec')}>
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('character')}
>
</button>
<button type="button" onClick={() => surface.switchGenerationTool('icon')}>
</button>
<button type="button" onClick={() => surface.switchGenerationTool('text')}>
</button>
{surface.generationComposerNode}
</div>
);
}
describe('useImageCanvasGenerationSurface', () => {
it('switches generation tools while leaving non-generation tools untouched', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换文字' }));
expect(screen.getByTestId('tool').textContent).toBe('select');
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
expect(screen.getByTestId('tool').textContent).toBe('generate');
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:open:placeholder',
);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
});
it('derives composer position and closes the generated image composer', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
expect(screen.getByTestId('composer-left').textContent).toBe('450');
expect(screen.getByTestId('composer-top').textContent).toBe('750');
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:closed:placeholder',
);
});
it('opens the spec menu through the generation surface', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换规范' }));
expect(screen.getByTestId('tool').textContent).toBe('spec');
const menu = screen.getByRole('menu', { name: '生成规范类型' });
expect(within(menu).getByRole('menuitem', { name: '角色形象规范' })).toBeTruthy();
});
});

View File

@@ -0,0 +1,256 @@
import {
type Dispatch,
type MutableRefObject,
type RefObject,
type SetStateAction,
useCallback,
} from 'react';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import {
resolveCharacterAnimationPanelStyle,
resolveGenerationAnchor,
resolveGenerationComposerStyle,
resolveIconComposerStyle,
resolveQuickEditPanelStyle,
} from './ImageCanvasOverlayModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
CanvasViewport,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
type ImageCanvasGenerationSurfaceOptions = {
layers: CanvasLayer[];
canvasSize: { width: number; height: number };
viewport: CanvasViewport;
layerCounterRef: MutableRefObject<number>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
generateDialog: GenerateDialogState | null;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => void;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
removeCanvasGenerationDialogById: (dialogId: string) => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
requestUpload: (target: UploadTarget) => void;
};
export function useImageCanvasGenerationSurface({
layers,
canvasSize,
viewport,
layerCounterRef,
specToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
requestUpload,
}: ImageCanvasGenerationSurfaceOptions) {
const generationWorkflow = useImageCanvasGenerationWorkflow({
layers,
canvasSize,
viewport,
layerCounterRef,
generateDialog,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
});
const activeGenerationLayer =
activeCanvasGenerationDialog?.generatedLayerId
? (layers.find(
(layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId,
) ?? null)
: null;
const generationAnchor = resolveGenerationAnchor({
dialog: activeCanvasGenerationDialog,
generatedLayer: activeGenerationLayer,
});
const generationComposerStyle = resolveGenerationComposerStyle({
dialog: activeCanvasGenerationDialog,
anchor: generationAnchor,
viewport,
});
const iconComposerStyle = resolveIconComposerStyle({
dialog: activeCanvasGenerationDialog,
composerStyle: generationComposerStyle,
iconDescriptionCount: generationWorkflow.iconDescriptionValues.length,
});
const quickEditPanelStyle = resolveQuickEditPanelStyle({
panel: generationWorkflow.quickEditPanel,
sourceLayer: generationWorkflow.quickEditSourceLayer,
viewport,
canvasSize,
});
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
panel: generationWorkflow.characterAnimationPanel,
sourceLayer: generationWorkflow.characterAnimationSourceLayer,
viewport,
canvasSize,
});
const switchGenerationTool = useCallback(
(tool: CanvasTool) => {
if (tool === 'generate') {
generationWorkflow.openGenerateDialog();
return true;
}
if (tool === 'spec') {
generationWorkflow.setIsSpecMenuOpen((open) => !open);
setActiveTool('spec');
return true;
}
if (tool === 'character') {
generationWorkflow.openCharacterGenerationDialog();
return true;
}
if (tool === 'icon') {
generationWorkflow.openIconGenerationDialog();
return true;
}
return false;
},
[generationWorkflow, setActiveTool],
);
const generationComposerNode = (
<ImageCanvasGenerationComposerView
specToolWrapRef={specToolWrapRef}
characterSpecButtonRef={characterSpecButtonRef}
characterReferenceButtonRef={characterReferenceButtonRef}
iconSpecButtonRef={iconSpecButtonRef}
isSpecMenuOpen={generationWorkflow.isSpecMenuOpen}
isCharacterSpecMenuOpen={generationWorkflow.isCharacterSpecMenuOpen}
isCharacterReferenceMenuOpen={
generationWorkflow.isCharacterReferenceMenuOpen
}
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
isPickingCharacterSpecFromCanvas={
generationWorkflow.isPickingCharacterSpecFromCanvas
}
isPickingCharacterReferenceFromCanvas={
generationWorkflow.isPickingCharacterReferenceFromCanvas
}
isPickingIconSpecFromCanvas={
generationWorkflow.isPickingIconSpecFromCanvas
}
generateDialog={generateDialog}
generationComposerStyle={generationComposerStyle}
iconComposerStyle={iconComposerStyle}
quickEditPanel={generationWorkflow.quickEditPanel}
quickEditSourceLayer={generationWorkflow.quickEditSourceLayer}
quickEditPanelStyle={quickEditPanelStyle}
quickEditSizeOptions={generationWorkflow.quickEditSizeOptions}
quickEditModelOptions={generationWorkflow.quickEditModelOptions}
characterAnimationPanel={generationWorkflow.characterAnimationPanel}
characterAnimationSourceLayer={
generationWorkflow.characterAnimationSourceLayer
}
characterAnimationPanelStyle={characterAnimationPanelStyle}
characterAnimationPrice={generationWorkflow.characterAnimationPrice}
setGenerateDialog={setGenerateDialog}
setQuickEditPanel={generationWorkflow.setQuickEditPanel}
setCharacterAnimationPanel={
generationWorkflow.setCharacterAnimationPanel
}
setIsCharacterSpecMenuOpen={
generationWorkflow.setIsCharacterSpecMenuOpen
}
setIsCharacterReferenceMenuOpen={
generationWorkflow.setIsCharacterReferenceMenuOpen
}
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
setIsPickingCharacterSpecFromCanvas={
generationWorkflow.setIsPickingCharacterSpecFromCanvas
}
setIsPickingCharacterReferenceFromCanvas={
generationWorkflow.setIsPickingCharacterReferenceFromCanvas
}
setIsPickingIconSpecFromCanvas={
generationWorkflow.setIsPickingIconSpecFromCanvas
}
onOpenSpecDialog={generationWorkflow.openSpecDialog}
onRequestUpload={requestUpload}
onSubmitImageGeneration={(dialog) =>
void generationWorkflow.submitImageGeneration(dialog)
}
onSubmitIconSpritesheetGeneration={(dialog) =>
void generationWorkflow.submitIconSpritesheetGeneration(dialog)
}
onSubmitQuickEdit={() => void generationWorkflow.submitQuickEdit()}
onSubmitCharacterAnimation={() =>
void generationWorkflow.submitCharacterAnimation()
}
onCloseGenerateComposer={generationWorkflow.closeGenerateComposer}
onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue}
onUpdateIconDescription={generationWorkflow.updateIconDescription}
onAddIconDescription={generationWorkflow.addIconDescription}
onUpdateCharacterAnimationDuration={
generationWorkflow.updateCharacterAnimationDuration
}
onRememberImageModel={generationWorkflow.rememberImageModel}
/>
);
return {
...generationWorkflow,
generationComposerStyle,
generationComposerNode,
switchGenerationTool,
};
}

View File

@@ -53,7 +53,6 @@ function StageControllerHarness({
layers,
selectedLayerId,
selectedLayerIds,
activeCanvasGenerationDialog: null,
imageContextMenu,
setImageContextMenu,
contextMenu,

View File

@@ -8,7 +8,6 @@ import {
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
ImageContextMenuState,
@@ -23,7 +22,6 @@ type UseImageCanvasStageControllerOptions = {
layers: CanvasLayer[];
selectedLayerId: string | null;
selectedLayerIds: string[];
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
imageContextMenu: ImageContextMenuState | null;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
contextMenu: CanvasContextMenuState | null;
@@ -42,7 +40,6 @@ export function useImageCanvasStageController({
layers,
selectedLayerId,
selectedLayerIds,
activeCanvasGenerationDialog,
imageContextMenu,
setImageContextMenu,
contextMenu,
@@ -59,14 +56,12 @@ export function useImageCanvasStageController({
layers,
selectedLayerId,
selectedLayerIds,
activeCanvasGenerationDialog,
imageContextMenu,
contextMenu,
viewport,
canvasSize,
}),
[
activeCanvasGenerationDialog,
canvasSize,
contextMenu,
imageContextMenu,

View File

@@ -4414,7 +4414,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
position: absolute;
left: 0.85rem;
bottom: 0.85rem;
z-index: 11;
z-index: 18;
display: inline-flex;
gap: 0.35rem;
border: 1px solid #d9dee8;
@@ -4702,7 +4702,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
position: absolute;
left: 50%;
bottom: 0.85rem;
z-index: 10;
z-index: 18;
display: inline-flex;
gap: 0.35rem;
max-width: min(calc(100% - 6.6rem), 34rem);