From 84818f9bd59b991b8bd23c765d610dcdfdc49517 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 14:20:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=94=9F=E6=88=90=E8=A1=A8=E9=9D=A2=E7=BC=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 useImageCanvasGenerationSurface 收口生成浮层编排。 主视图移除生成 Composer 大段 props 胶水。 舞台控制模型移除重复生成锚点派生。 补充生成表面 hook 单测并更新拆分文档与跟踪记录。 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 16 +- .../image-editor/ImageCanvasEditorView.tsx | 205 +++----------- .../ImageCanvasStageControllerModel.test.ts | 29 +- .../ImageCanvasStageControllerModel.ts | 27 -- .../useImageCanvasGenerationSurface.test.tsx | 181 +++++++++++++ .../useImageCanvasGenerationSurface.tsx | 256 ++++++++++++++++++ .../useImageCanvasStageController.test.tsx | 1 - .../useImageCanvasStageController.ts | 5 - src/index.css | 4 +- 10 files changed, 494 insertions(+), 231 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasGenerationSurface.test.tsx create mode 100644 src/components/image-editor/useImageCanvasGenerationSurface.tsx diff --git a/TRACKING.md b/TRACKING.md index ef3b15f8..459a9aa6 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 3644b970..2d419c7d 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -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` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index bbb1278f..3a7e99ec 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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} > - - 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} diff --git a/src/components/image-editor/ImageCanvasStageControllerModel.test.ts b/src/components/image-editor/ImageCanvasStageControllerModel.test.ts index ab134119..a9894930 100644 --- a/src/components/image-editor/ImageCanvasStageControllerModel.test.ts +++ b/src/components/image-editor/ImageCanvasStageControllerModel.test.ts @@ -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 { }; } -function createDialog( - overrides: Partial = {}, -): 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 }, diff --git a/src/components/image-editor/ImageCanvasStageControllerModel.ts b/src/components/image-editor/ImageCanvasStageControllerModel.ts index edbd1d96..5e0291df 100644 --- a/src/components/image-editor/ImageCanvasStageControllerModel.ts +++ b/src/components/image-editor/ImageCanvasStageControllerModel.ts @@ -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; selectedToolbarStyle: ReturnType; 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, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx new file mode 100644 index 00000000..b379ff00 --- /dev/null +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -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 { + 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([createLayer()]); + const [activeTool, setActiveTool] = useState('select'); + const [activeSidebarPanel, setActiveSidebarPanel] = + useState('assets'); + const [, setMetadataLayer] = useState(null); + const [, setImageContextMenu] = useState(null); + const layerCounterRef = useRef(0); + const specToolWrapRef = useRef(null); + const characterSpecButtonRef = useRef(null); + const characterReferenceButtonRef = useRef(null); + const iconSpecButtonRef = useRef(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 ( +
+ 规范入口 + {activeTool} + {activeSidebarPanel ?? '-'} + + {activeDialog + ? `${activeDialog.mode}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.placeholder ? 'placeholder' : '-'}` + : '-'} + + + {surface.generationComposerStyle?.left ?? '-'} + + + {surface.generationComposerStyle?.top ?? '-'} + + + + + + + {surface.generationComposerNode} +
+ ); +} + +describe('useImageCanvasGenerationSurface', () => { + it('switches generation tools while leaving non-generation tools untouched', () => { + render(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx new file mode 100644 index 00000000..14f7204a --- /dev/null +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -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; + specToolWrapRef: RefObject; + characterSpecButtonRef: RefObject; + characterReferenceButtonRef: RefObject; + iconSpecButtonRef: RefObject; + generateDialog: GenerateDialogState | null; + setGenerateDialog: Dispatch>; + activeCanvasGenerationDialog: CanvasGenerationDialogState | null; + openCanvasGenerationDialog: ( + dialog: Omit, + ) => 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>; + setActiveSidebarPanel: Dispatch>; + setMetadataLayer: Dispatch>; + setImageContextMenu: Dispatch>; + 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 = ( + + 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, + }; +} diff --git a/src/components/image-editor/useImageCanvasStageController.test.tsx b/src/components/image-editor/useImageCanvasStageController.test.tsx index a70f3231..546de2f8 100644 --- a/src/components/image-editor/useImageCanvasStageController.test.tsx +++ b/src/components/image-editor/useImageCanvasStageController.test.tsx @@ -53,7 +53,6 @@ function StageControllerHarness({ layers, selectedLayerId, selectedLayerIds, - activeCanvasGenerationDialog: null, imageContextMenu, setImageContextMenu, contextMenu, diff --git a/src/components/image-editor/useImageCanvasStageController.ts b/src/components/image-editor/useImageCanvasStageController.ts index be61b3e5..5d7434d6 100644 --- a/src/components/image-editor/useImageCanvasStageController.ts +++ b/src/components/image-editor/useImageCanvasStageController.ts @@ -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>; 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, diff --git a/src/index.css b/src/index.css index 403a8d8d..12106d6e 100644 --- a/src/index.css +++ b/src/index.css @@ -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);