diff --git a/TRACKING.md b/TRACKING.md index dcd44e2f..aaab65fb 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -144,3 +144,10 @@ - 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。 - 2026-06-17 上传侧栏回归修正:上传工作流移除上传到画布后强制切换 `图层` 侧栏的副作用,保留新增素材卡、创建画布图层和选中新图层。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.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` 登录后点击 `上传到项目素材` 上传图片,左侧仍显示 `素材` 且 `打开素材` 为 pressed;把图片文件 drop 到 `画布工作区` 后素材库和画布图层均出现 `canvas-drop-sidebar-smoke.png`,新图层被选中,`打开素材=true`、`打开图层=false`,`AI画布工具栏` 保持可见,登录后控制台无 error。 +- 2026-06-17 前端拆分第二十八阶段:继续收口 `ImageCanvasGenerationComposerView`,新增 `ImageCanvasGenerationImageOptionsView`、`ImageCanvasSpecGenerationPanelView`、`ImageCanvasIconSpritesheetComposerView`、`ImageCanvasQuickEditPanelView` 和 `ImageCanvasCharacterAnimationPanelView`,把图片生成参数、生成规范、图标素材生成、快速编辑图片和角色动画面板从 Composer 内联 JSX 抽出;Composer 降至 707 行,继续保留生成模式分流、角色引用菜单 portal 和修改图片弹窗编排。新增子视图单测覆盖参数切换、规范表单、图标规范菜单 / 描述列表、快速编辑和角色动画状态。验证命令:`npm run test -- src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.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` 未登录显示 `账号入口`,关闭后点击 `生成工具` 能看到 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏`;点击 `生成角色形象` 能看到 `生成角色形象` 面板和 `角色形象规范`;点击 `生成规范` 后选择 `角色形象规范` 能打开 `生成规范` 面板并显示 `提交生成规范`;点击 `生成图标素材` 能打开 `生成图标素材` 面板、图标规范操作和素材描述列表,控制台仅有未登录 refresh 401。 +- 2026-06-17 前端拆分第二十九阶段:继续收口 `ImageCanvasStageView`,新增 `ImageCanvasBottomToolbarView`、`ImageCanvasPanelDockView`、`ImageCanvasContextMenusView` 和 `ImageCanvasSelectedLayerToolbarView`,把底部 AI 工具栏、左下缩放 / 背景 / 小地图控制坞、画布 / 图片右键菜单和选中图片浮动工具栏从 StageView 内联 JSX 抽出;StageView 降至 538 行,继续保留画布世界、图层和生成占位渲染,所有 pointer / 多选 / 拖拽 / 生成状态机仍在既有 hook 中。新增子视图单测覆盖工具切换、缩放 / 背景 / 小地图控制、右键菜单命令和选中图片工具栏接线。验证命令:`npm run test -- src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.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` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录弹出 `账号入口`,关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框、`画布面板入口` 和 `AI画布工具栏` 均可见;点击 `生成规范` 后页面级 `生成规范类型` 菜单可见。控制台仅有预期的未登录 `/api/auth/refresh` 401,截图留存于 `output/playwright/editor-stage-view-split-smoke-20260617.png`。 +- 2026-06-17 前端拆分第三十阶段:新增 `ImageCanvasEditorShellView`,把编辑器最外层 section、隐藏上传 input、素材拖拽预览、侧栏 / 顶栏 / 舞台 / 元数据弹窗组合从 `ImageCanvasEditorView` 抽成页面壳;主视图继续保留所有 hook 状态编排、上传 / 生成 / 拖拽 / 项目持久化和跨模块副作用,只把已经组装好的 `sidebarProps`、`topbarProps`、`stageProps` 和 `metadataProps` 交给 shell 渲染。新增 shell 单测覆盖上传 input、拖拽预览坐标兜底、顶栏 / 舞台 / 工具栏 / 元数据弹窗装配;主视图降至 777 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.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` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`,关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`返回项目页面`、`画布面板入口` 和 `AI画布工具栏` 均可见;点击 `生成工具` 后 `Image Generator` 和 `生成图片` 对话框可见;点击 `生成规范` 后页面级 `生成规范类型` 菜单可见。控制台仅有预期的未登录 `/api/auth/refresh` 401,截图留存于 `output/playwright/editor-shell-split-smoke-20260617.png`。 +- 2026-06-17 前端拆分第三十一阶段:继续收口 `ImageCanvasSidebarView`,新增 `ImageCanvasAssetLibraryPanelView` 和 `ImageCanvasLayerPanelView`,把素材库文件夹 / 拖拽上传 / 素材选择模式与图层列表 / 右键入口拆成两个完整 surface;侧栏外壳只保留标题、计数和当前 tab 分流。同步把 `ImageCanvasEditorView.test.tsx` 中纯侧栏输入框 chrome 断言迁到 `ImageCanvasSidebarView.test.tsx`,保留主视图重命名、建文件夹、上传、删除、API 调用和画布集成断言。验证命令:`npm run test -- src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "switches the shared sidebar between assets and layers"`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 2026-06-17 前端拆分第三十二阶段:继续收口 `ImageCanvasGenerationComposerView`,新增 `ImageCanvasBasicGenerationComposerView`、`ImageCanvasCharacterGenerationComposerView` 和 `ImageCanvasEditGenerationModalView`,把普通生图跟随框、角色形象生成面板和修改图片弹窗从 Composer 内联 JSX 中抽出;Composer 降至 312 行,只保留生成模式分流、portal 菜单和各面板装配。新增三组子视图单测覆盖普通生图 prompt / 参考图 / 提交 / 关闭、角色参考图菜单 / 状态恢复 / 提交、修改图片弹窗提示词 / 失败 / 关闭。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.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` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口` 且控制台仅有预期 `/api/auth/refresh` 401;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,`AI画布工具栏` 仍可见;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和底部工具栏均可见。 +- 2026-06-17 前端拆分第三十三阶段:继续收口 `ImageCanvasAssetLibraryPanelView`,新增 `ImageCanvasAssetFolderSectionView` 和 `ImageCanvasAssetRowView`,把素材库文件夹头 / 文件夹 drop 区域、素材卡片 / 上传进度 / 重命名 / 选择模式从素材库父面板中拆成两个完整 surface;素材库父面板降至 279 行,只保留素材列表容器、新建文件夹表单、批量操作栏和框选遮罩。新增素材行单测覆盖普通点击加入画布、选择模式改为选中、重命名 Enter 提交和上传中禁用 / 进度显示。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.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` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口` 且控制台仅有预期 `/api/auth/refresh` 401;默认素材栏显示 `项目素材` 文件夹、上传入口和底部 `AI画布工具栏`;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和底部工具栏均可见。 +- 2026-06-17 前端拆分第三十四阶段:继续收口 `ImageCanvasStageView`,新增 `ImageCanvasWorldView`,把画布世界表面、吸附参考线、可见图层排序 / 悬浮 / 选中 / 锁定 / 生成态、元数据角标、框选矩形、生成占位框和浮动生成状态从 StageView 内联 JSX 中抽出;StageView 降至 324 行,继续保留 viewport 宿主、drop overlay、左下 dock、底部工具栏、右键菜单和选中图片工具栏装配。新增 world view 单测覆盖隐藏图层过滤、悬浮尺寸、生成态、元数据按钮、吸附线、框选矩形和生成占位框事件。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasWorldView.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.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` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,`AI画布工具栏` 保持可见;`打开图层` 切换后侧栏显示 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和底部工具栏均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 diff --git a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx new file mode 100644 index 00000000..2404aa51 --- /dev/null +++ b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx @@ -0,0 +1,257 @@ +import { + Check, + ChevronDown, + ChevronRight, + Folder, + ImagePlus, + PencilLine, + Trash2, + X, +} from 'lucide-react'; +import type { Dispatch, SetStateAction } from 'react'; + +import { PlatformTextField } from '../common/PlatformTextField'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import { + ASSET_DRAG_MIME_TYPE, + getDraggedAssetId, + hasDataTransferType, +} from './ImageCanvasEditorModel'; +import type { + AssetPointerDragState, + EditorAsset, + EditorAssetFolder, + UploadTarget, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView'; +import type { + GroupedEditorAssetFolder, + UploadFilesOptions, +} from './ImageCanvasAssetLibraryPanelView'; + +type ImageCanvasAssetFolderSectionViewProps = { + folder: GroupedEditorAssetFolder; + assetPointerDragRef: { current: AssetPointerDragState | null }; + suppressAssetClickRef: { current: boolean }; + isAssetSelectionMode: boolean; + selectedAssetIds: Set; + assetMoveDropFolderId: string | null; + renamingFolder: { folderId: string; value: string } | null; + renamingAsset: { assetId: string; value: string } | null; + setRenamingFolder: Dispatch< + SetStateAction<{ folderId: string; value: string } | null> + >; + setRenamingAsset: Dispatch< + SetStateAction<{ assetId: string; value: string } | null> + >; + setActiveUploadFolderId: Dispatch>; + setUploadDropTarget: Dispatch>; + setAssetPointerDrag: Dispatch>; + setSelectedAssetIds: Dispatch>>; + updateAssetMoveDropFolder: (folderId: string | null) => void; + addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void; + requestUpload: (target: UploadTarget) => void; + moveAssetToFolder: (assetId: string, folderId: string) => void; + toggleAssetFolder: (folderId: string) => void; + startRenamingFolder: (folder: EditorAssetFolder) => void; + commitFolderRename: (folder: EditorAssetFolder) => void; + deleteAssetFolder: (folder: EditorAssetFolder) => void; + startRenamingAsset: (asset: EditorAsset) => void; + commitAssetRename: (asset: EditorAsset) => void; + deleteUploadedAsset: (asset: EditorAsset) => void; + toggleAssetSelected: (assetId: string) => void; + addAssetLayer: (asset: EditorAsset) => void; +}; + +export function ImageCanvasAssetFolderSectionView({ + folder, + assetPointerDragRef, + suppressAssetClickRef, + isAssetSelectionMode, + selectedAssetIds, + assetMoveDropFolderId, + renamingFolder, + renamingAsset, + setRenamingFolder, + setRenamingAsset, + setActiveUploadFolderId, + setUploadDropTarget, + setAssetPointerDrag, + setSelectedAssetIds, + updateAssetMoveDropFolder, + addUploadedFiles, + requestUpload, + moveAssetToFolder, + toggleAssetFolder, + startRenamingFolder, + commitFolderRename, + deleteAssetFolder, + startRenamingAsset, + commitAssetRename, + deleteUploadedAsset, + toggleAssetSelected, + addAssetLayer, +}: ImageCanvasAssetFolderSectionViewProps) { + return ( +
{ + if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(folder.id); + event.dataTransfer.dropEffect = 'move'; + return; + } + if (hasDataTransferType(event.dataTransfer, 'Files')) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget('assets'); + event.dataTransfer.dropEffect = 'copy'; + } + }} + onDrop={(event) => { + const movingAssetId = getDraggedAssetId(event.dataTransfer); + if (movingAssetId) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + moveAssetToFolder(movingAssetId, folder.id); + return; + } + if (!event.dataTransfer.files.length) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + addUploadedFiles(event.dataTransfer.files, { + folderId: folder.id, + }); + }} + > +
+ toggleAssetFolder(folder.id)} + /> + + {renamingFolder?.folderId === folder.id ? ( + + setRenamingFolder({ + folderId: folder.id, + value: event.target.value, + }) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitFolderRename(folder); + } + if (event.key === 'Escape') { + event.preventDefault(); + setRenamingFolder(null); + } + }} + /> + ) : ( + {folder.label} + )} + {folder.assets.length} + {renamingFolder?.folderId === folder.id ? ( + <> + commitFolderRename(folder)} + /> + setRenamingFolder(null)} + /> + + ) : ( + startRenamingFolder(folder)} + /> + )} + {!folder.systemDefault ? ( + deleteAssetFolder(folder)} + /> + ) : null} + { + setActiveUploadFolderId(folder.id); + requestUpload('asset'); + }} + /> +
+ +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx new file mode 100644 index 00000000..dee2ab8c --- /dev/null +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -0,0 +1,279 @@ +import { + Check, + CheckSquare, + Folder, + Square, + Trash2, + X, +} from 'lucide-react'; +import type { + Dispatch, + PointerEvent as ReactPointerEvent, + RefObject, + SetStateAction, +} from 'react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; +import { PlatformTextField } from '../common/PlatformTextField'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView'; +import type { + AssetMarqueeState, + AssetPointerDragState, + EditorAsset, + EditorAssetFolder, + UploadTarget, +} from './ImageCanvasEditorTypes'; + +export type GroupedEditorAssetFolder = EditorAssetFolder & { + assets: EditorAsset[]; +}; + +export type UploadFilesOptions = { + folderId?: string; + canvasPoint?: { x: number; y: number }; + addToCanvas?: boolean; +}; + +export type ImageCanvasAssetLibraryPanelViewProps = { + assetListRef: RefObject; + assetPointerDragRef: { current: AssetPointerDragState | null }; + suppressAssetClickRef: { current: boolean }; + groupedAssets: GroupedEditorAssetFolder[]; + assetFolders: EditorAssetFolder[]; + isAssetSelectionMode: boolean; + selectedAssetIds: Set; + assetMoveDropFolderId: string | null; + pinnedAssetMoveFolderId: string | null; + creatingFolder: boolean; + newFolderName: string; + renamingFolder: { folderId: string; value: string } | null; + renamingAsset: { assetId: string; value: string } | null; + allSelectableAssetsSelected: boolean; + assetMarquee: AssetMarqueeState | null; + setCreatingFolder: Dispatch>; + setNewFolderName: Dispatch>; + setRenamingFolder: Dispatch< + SetStateAction<{ folderId: string; value: string } | null> + >; + setRenamingAsset: Dispatch< + SetStateAction<{ assetId: string; value: string } | null> + >; + setActiveUploadFolderId: Dispatch>; + setUploadDropTarget: Dispatch>; + setAssetPointerDrag: Dispatch>; + setSelectedAssetIds: Dispatch>>; + onAssetMarqueePointerDown: ( + event: ReactPointerEvent, + ) => void; + onAssetMarqueePointerMove: ( + event: ReactPointerEvent, + ) => void; + onAssetMarqueePointerUp: ( + event: ReactPointerEvent, + ) => void; + updateAssetMoveDropFolder: (folderId: string | null) => void; + addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void; + requestUpload: (target: UploadTarget) => void; + moveAssetToFolder: (assetId: string, folderId: string) => void; + commitNewAssetFolder: () => void | Promise; + toggleAssetFolder: (folderId: string) => void; + startRenamingFolder: (folder: EditorAssetFolder) => void; + commitFolderRename: (folder: EditorAssetFolder) => void; + deleteAssetFolder: (folder: EditorAssetFolder) => void; + startRenamingAsset: (asset: EditorAsset) => void; + commitAssetRename: (asset: EditorAsset) => void; + deleteUploadedAsset: (asset: EditorAsset) => void; + toggleAssetSelected: (assetId: string) => void; + addAssetLayer: (asset: EditorAsset) => void; + toggleAllAssetsSelected: () => void; + deleteSelectedAssets: () => void; + closeAssetSelectionMode: () => void; +}; + +export function ImageCanvasAssetLibraryPanelView({ + assetListRef, + assetPointerDragRef, + suppressAssetClickRef, + groupedAssets, + assetFolders, + isAssetSelectionMode, + selectedAssetIds, + assetMoveDropFolderId, + pinnedAssetMoveFolderId, + creatingFolder, + newFolderName, + renamingFolder, + renamingAsset, + allSelectableAssetsSelected, + assetMarquee, + setCreatingFolder, + setNewFolderName, + setRenamingFolder, + setRenamingAsset, + setActiveUploadFolderId, + setUploadDropTarget, + setAssetPointerDrag, + setSelectedAssetIds, + onAssetMarqueePointerDown, + onAssetMarqueePointerMove, + onAssetMarqueePointerUp, + updateAssetMoveDropFolder, + addUploadedFiles, + requestUpload, + moveAssetToFolder, + commitNewAssetFolder, + toggleAssetFolder, + startRenamingFolder, + commitFolderRename, + deleteAssetFolder, + startRenamingAsset, + commitAssetRename, + deleteUploadedAsset, + toggleAssetSelected, + addAssetLayer, + toggleAllAssetsSelected, + deleteSelectedAssets, + closeAssetSelectionMode, +}: ImageCanvasAssetLibraryPanelViewProps) { + return ( +
+ {pinnedAssetMoveFolderId ? ( + + ) : null} + {creatingFolder ? ( +
{ + event.preventDefault(); + void commitNewAssetFolder(); + }} + > + setNewFolderName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + setCreatingFolder(false); + setNewFolderName(''); + } + }} + /> + + { + setCreatingFolder(false); + setNewFolderName(''); + }} + /> + + ) : null} + {groupedAssets.map((folder) => ( + + ))} + {isAssetSelectionMode ? ( + + + {allSelectableAssetsSelected ? ( + + ) : ( + + )} + {selectedAssetIds.size > 0 + ? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}` + : '全选'} + + + + 删除 + + + 取消 + + + ) : null} + {assetMarquee ? ( + + ); +} diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx new file mode 100644 index 00000000..a9f2e03a --- /dev/null +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -0,0 +1,131 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView'; + +function createAsset(overrides: Partial = {}): EditorAsset { + return { + id: 'asset-1', + label: '账号素材A', + src: '/creation-type-references/puzzle.webp', + width: 640, + height: 512, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + ...overrides, + }; +} + +function renderAssetRow({ + asset = createAsset(), + isAssetSelectionMode = false, + selectedAssetIds = new Set(), + renamingAsset = null, + suppressAssetClick = false, + addAssetLayer = vi.fn(), + toggleAssetSelected = vi.fn(), + commitAssetRename = vi.fn(), +}: { + asset?: EditorAsset; + isAssetSelectionMode?: boolean; + selectedAssetIds?: Set; + renamingAsset?: { assetId: string; value: string } | null; + suppressAssetClick?: boolean; + addAssetLayer?: (asset: EditorAsset) => void; + toggleAssetSelected?: (assetId: string) => void; + commitAssetRename?: (asset: EditorAsset) => void; +} = {}) { + const props = { + asset, + assetPointerDragRef: { current: null }, + suppressAssetClickRef: { current: suppressAssetClick }, + isAssetSelectionMode, + selectedAssetIds, + renamingAsset, + setRenamingAsset: vi.fn(), + setUploadDropTarget: vi.fn(), + setAssetPointerDrag: vi.fn(), + setSelectedAssetIds: vi.fn(), + updateAssetMoveDropFolder: vi.fn(), + addUploadedFiles: vi.fn(), + moveAssetToFolder: vi.fn(), + startRenamingAsset: vi.fn(), + commitAssetRename, + deleteUploadedAsset: vi.fn(), + toggleAssetSelected, + addAssetLayer, + }; + + render(); + return props; +} + +describe('ImageCanvasAssetRowView', () => { + it('adds the asset to the canvas in normal mode', () => { + const addAssetLayer = vi.fn(); + const asset = createAsset(); + renderAssetRow({ asset, addAssetLayer }); + + fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' })); + + expect(addAssetLayer).toHaveBeenCalledWith(asset); + }); + + it('selects the asset instead of adding it in selection mode', () => { + const addAssetLayer = vi.fn(); + const toggleAssetSelected = vi.fn(); + renderAssetRow({ + isAssetSelectionMode: true, + addAssetLayer, + toggleAssetSelected, + }); + + fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' })); + + expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1'); + expect(addAssetLayer).not.toHaveBeenCalled(); + }); + + it('renders rename input and commits with Enter', () => { + const commitAssetRename = vi.fn(); + renderAssetRow({ + renamingAsset: { assetId: 'asset-1', value: '主视觉素材' }, + commitAssetRename, + }); + + const input = screen.getByLabelText('重命名素材账号素材A'); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(input.className).toContain('platform-text-field'); + expect(input.className).toContain('image-canvas-editor__asset-rename-input'); + expect(commitAssetRename).toHaveBeenCalledWith( + expect.objectContaining({ id: 'asset-1' }), + ); + }); + + it('keeps uploading assets disabled and shows progress', () => { + const addAssetLayer = vi.fn(); + renderAssetRow({ + asset: createAsset({ + uploadStatus: 'uploading', + uploadProgress: 42, + uploadMessage: '正在上传', + }), + addAssetLayer, + }); + + fireEvent.click(screen.getByRole('button', { name: '上传中账号素材A' })); + + expect(addAssetLayer).not.toHaveBeenCalled(); + expect(screen.getAllByText('正在上传').length).toBeGreaterThan(0); + expect(screen.getAllByText('42%').length).toBeGreaterThan(0); + expect( + screen.getByLabelText('素材账号素材A上传进度').getAttribute('value'), + ).toBe('42'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx new file mode 100644 index 00000000..a6bbdf64 --- /dev/null +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -0,0 +1,321 @@ +import { Check, Pencil, Trash2, X } from 'lucide-react'; +import type { Dispatch, SetStateAction } from 'react'; + +import { PlatformTextField } from '../common/PlatformTextField'; +import { + EditorIconButton, + SidebarMediaItem, +} from './ImageCanvasEditorPrimitives'; +import { + ASSET_DRAG_MIME_TYPE, + clamp, + getDraggedAssetId, + hasDataTransferType, +} from './ImageCanvasEditorModel'; +import type { + AssetPointerDragState, + EditorAsset, +} from './ImageCanvasEditorTypes'; +import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView'; + +export type ImageCanvasAssetRowViewProps = { + asset: EditorAsset; + assetPointerDragRef: { current: AssetPointerDragState | null }; + suppressAssetClickRef: { current: boolean }; + isAssetSelectionMode: boolean; + selectedAssetIds: Set; + renamingAsset: { assetId: string; value: string } | null; + setRenamingAsset: Dispatch< + SetStateAction<{ assetId: string; value: string } | null> + >; + setUploadDropTarget: Dispatch>; + setAssetPointerDrag: Dispatch>; + setSelectedAssetIds: Dispatch>>; + updateAssetMoveDropFolder: (folderId: string | null) => void; + addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void; + moveAssetToFolder: (assetId: string, folderId: string) => void; + startRenamingAsset: (asset: EditorAsset) => void; + commitAssetRename: (asset: EditorAsset) => void; + deleteUploadedAsset: (asset: EditorAsset) => void; + toggleAssetSelected: (assetId: string) => void; + addAssetLayer: (asset: EditorAsset) => void; +}; + +export function ImageCanvasAssetRowView({ + asset, + assetPointerDragRef, + suppressAssetClickRef, + isAssetSelectionMode, + selectedAssetIds, + renamingAsset, + setRenamingAsset, + setUploadDropTarget, + setAssetPointerDrag, + setSelectedAssetIds, + updateAssetMoveDropFolder, + addUploadedFiles, + moveAssetToFolder, + startRenamingAsset, + commitAssetRename, + deleteUploadedAsset, + toggleAssetSelected, + addAssetLayer, +}: ImageCanvasAssetRowViewProps) { + const isRenaming = renamingAsset?.assetId === asset.id; + const isUploadingAsset = asset.uploadStatus === 'uploading'; + const isFailedUpload = asset.uploadStatus === 'failed'; + const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100); + const titleNode = isRenaming ? ( + + setRenamingAsset({ + assetId: asset.id, + value: event.target.value, + }) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitAssetRename(asset); + } + if (event.key === 'Escape') { + event.preventDefault(); + setRenamingAsset(null); + } + }} + /> + ) : undefined; + const actions = isUploadingAsset ? ( +
+ {asset.uploadMessage ?? '上传中'} + {Math.round(uploadProgress)}% +
+ ) : isRenaming ? ( +
+ commitAssetRename(asset)} + /> + setRenamingAsset(null)} + /> +
+ ) : ( +
+ startRenamingAsset(asset)} + /> + {asset.sourceKind === 'uploaded' ? ( + deleteUploadedAsset(asset)} + /> + ) : null} +
+ ); + + return ( +
+ { + if (isUploadingAsset || isFailedUpload) { + return; + } + if (suppressAssetClickRef.current) { + return; + } + if (isAssetSelectionMode) { + toggleAssetSelected(asset.id); + return; + } + addAssetLayer(asset); + }} + selected={selectedAssetIds.has(asset.id)} + rowClassName={[ + 'image-canvas-editor__asset-row', + isUploadingAsset ? 'image-canvas-editor__asset-row--uploading' : '', + isFailedUpload + ? 'image-canvas-editor__asset-row--upload-failed' + : '', + ] + .filter(Boolean) + .join(' ')} + primaryClassName="image-canvas-editor__asset-button" + thumbnailClassName="image-canvas-editor__asset-thumb" + metaClassName="image-canvas-editor__asset-meta" + titleNode={ + isUploadingAsset || isFailedUpload ? {asset.label} : titleNode + } + actions={actions} + draggable={!isRenaming && !isUploadingAsset && !isFailedUpload} + previewOverlay={ + isUploadingAsset ? ( +
+ 上传中 +
+ ) : undefined + } + footerNode={ + isUploadingAsset || isFailedUpload ? ( +
+
+ + {isFailedUpload + ? (asset.uploadMessage ?? '上传失败') + : (asset.uploadMessage ?? '上传中')} + + {Math.round(uploadProgress)}% +
+ +
+ ) : undefined + } + onDragStart={(event) => { + if (isRenaming || isUploadingAsset || isFailedUpload) { + event.preventDefault(); + return; + } + if (assetPointerDragRef.current?.assetId === asset.id) { + event.preventDefault(); + return; + } + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(ASSET_DRAG_MIME_TYPE, asset.id); + event.dataTransfer.setData('text/plain', asset.label); + event.dataTransfer.setData('text/uri-list', asset.src); + updateAssetMoveDropFolder(asset.folderId); + }} + onDragEnd={() => { + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + }} + onPointerDown={(event) => { + if (event.button !== 0) { + return; + } + const target = event.target as HTMLElement; + if (isAssetSelectionMode) { + if (target.closest('button')) { + return; + } + return; + } + if ( + isRenaming || + isUploadingAsset || + isFailedUpload || + target.closest('input, textarea, select') + ) { + return; + } + const primaryAssetButton = target.closest( + '.image-canvas-editor__asset-button', + ); + if (target.closest('button') && !primaryAssetButton) { + return; + } + const nextDrag: AssetPointerDragState = { + pointerId: event.pointerId, + assetId: asset.id, + startClientX: event.clientX, + startClientY: event.clientY, + currentClientX: event.clientX, + currentClientY: event.clientY, + active: false, + dropFolderId: null, + }; + if (!primaryAssetButton) { + try { + event.currentTarget.setPointerCapture?.(event.pointerId); + } catch { + // 自动化环境可能没有 active pointer,拖拽状态仍可走 window 事件完成。 + } + event.preventDefault(); + } + event.stopPropagation(); + assetPointerDragRef.current = nextDrag; + setAssetPointerDrag(nextDrag); + }} + onPointerEnter={(event) => { + if (isAssetSelectionMode && event.buttons === 1) { + setSelectedAssetIds((currentIds) => { + const nextIds = new Set(currentIds); + nextIds.add(asset.id); + return nextIds; + }); + } + }} + onDragOver={(event) => { + if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(asset.folderId); + event.dataTransfer.dropEffect = 'move'; + return; + } + if (hasDataTransferType(event.dataTransfer, 'Files')) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget('assets'); + event.dataTransfer.dropEffect = 'copy'; + } + }} + onDrop={(event) => { + const movingAssetId = getDraggedAssetId(event.dataTransfer); + if (movingAssetId) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + moveAssetToFolder(movingAssetId, asset.folderId); + return; + } + if (!event.dataTransfer.files.length) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + addUploadedFiles(event.dataTransfer.files, { + folderId: asset.folderId, + }); + }} + /> +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx new file mode 100644 index 00000000..39861f18 --- /dev/null +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -0,0 +1,101 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + GenerateDialogState, + UploadTarget, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; + +function createDialog( + patch: Partial = {}, +): GenerateDialogState { + return { + mode: 'generate', + prompt: '初始提示', + status: 'idle', + ...patch, + }; +} + +function BasicGenerationHarness({ + initialDialog = createDialog(), + onRequestUpload = vi.fn(), + onSubmit = vi.fn(), + onClose = vi.fn(), +}: { + initialDialog?: GenerateDialogState; + onRequestUpload?: (target: UploadTarget) => void; + onSubmit?: (dialog: GenerateDialogState) => void; + onClose?: () => void; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + + return dialog ? ( +
+ + {dialog.prompt} + {dialog.status} + {dialog.errorMessage ?? '-'} +
+ ) : null; +} + +describe('ImageCanvasBasicGenerationComposerView', () => { + it('updates prompt, clears failed state, uploads references and submits', () => { + const requestUpload = vi.fn(); + const submitGeneration = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '新的提示' }, + }); + fireEvent.click(screen.getByRole('button', { name: '添加参考图' })); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示'); + expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); + expect(screen.getByLabelText('当前错误').textContent).toBe('-'); + expect(requestUpload).toHaveBeenCalledWith('asset'); + expect(submitGeneration).toHaveBeenCalledWith( + expect.objectContaining({ prompt: '新的提示', status: 'idle' }), + ); + }); + + it('runs placeholder actions and closes through its interface', () => { + const closeComposer = vi.fn(); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成比例 1:1 2k 1张' })); + fireEvent.click(screen.getByRole('button', { name: '生成模型 GPT Image' })); + fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' })); + + expect(alertSpy).toHaveBeenCalledWith('生成参数功能建设中'); + expect(alertSpy).toHaveBeenCalledWith('模型选择功能建设中'); + expect(closeComposer).toHaveBeenCalledTimes(1); + + alertSpy.mockRestore(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx new file mode 100644 index 00000000..913597dd --- /dev/null +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -0,0 +1,151 @@ +import { ChevronDown, ImageIcon, X } from 'lucide-react'; +import { type CSSProperties, type Dispatch, type SetStateAction } from 'react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformIconButton } from '../common/PlatformIconButton'; +import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformTextField } from '../common/PlatformTextField'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import type { + GenerateDialogState, + UploadTarget, +} from './ImageCanvasEditorTypes'; + +type ImageCanvasBasicGenerationComposerViewProps = { + dialog: GenerateDialogState; + style: CSSProperties; + setGenerateDialog: Dispatch>; + onRequestUpload: (target: UploadTarget) => void; + onSubmit: (dialog: GenerateDialogState) => void; + onClose: () => void; +}; + +function triggerPlaceholderAction(label: string) { + window.alert(`${label}功能建设中`); +} + +function resetFailedDialogStatus(dialog: GenerateDialogState) { + return { + ...dialog, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, + }; +} + +export function ImageCanvasBasicGenerationComposerView({ + dialog, + style, + setGenerateDialog, + onRequestUpload, + onSubmit, + onClose, +}: ImageCanvasBasicGenerationComposerViewProps) { + return ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + > + onRequestUpload('asset')} + icon={} + > + 参考图 + + + setGenerateDialog((currentDialog) => + currentDialog + ? { + ...resetFailedDialogStatus(currentDialog), + prompt: event.target.value, + } + : currentDialog, + ) + } + /> +
+ triggerPlaceholderAction('生成参数')} + trailingIcon={} + > + 中 · 1:1(2k) · 1张 + + triggerPlaceholderAction('模型选择')} + trailingIcon={} + > + GPT Im... + + + {dialog.status === 'generating' ? '生成中' : '12'} + +
+ {dialog.status === 'generating' ? ( + + 生成中 + + ) : null} + {dialog.status === 'failed' ? ( + + {dialog.errorMessage} + + ) : null} + + + ); +} diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx new file mode 100644 index 00000000..a67baa28 --- /dev/null +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -0,0 +1,41 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; + +describe('ImageCanvasBottomToolbarView', () => { + it('renders the canvas tools and forwards tool changes', () => { + const switchTool = vi.fn(); + render( + ()} + effectiveTool="generate" + onSwitchTool={switchTool} + />, + ); + + const toolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); + + expect( + within(toolbar) + .getByRole('button', { name: '生成工具' }) + .getAttribute('aria-pressed'), + ).toBe('true'); + expect( + within(toolbar) + .getByRole('button', { name: '选择工具' }) + .getAttribute('aria-pressed'), + ).toBe('false'); + + fireEvent.click(within(toolbar).getByRole('button', { name: '抓手工具' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '生成规范' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '文字工具' })); + + expect(switchTool).toHaveBeenNthCalledWith(1, 'hand'); + expect(switchTool).toHaveBeenNthCalledWith(2, 'spec'); + expect(switchTool).toHaveBeenNthCalledWith(3, 'text'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx new file mode 100644 index 00000000..dd36b4cc --- /dev/null +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx @@ -0,0 +1,81 @@ +import { + ClipboardList, + Download, + Hand, + ImageIcon, + ImagePlus, + MousePointer2, + Shapes, + Sparkles, + Type, + WandSparkles, +} from 'lucide-react'; +import type { RefObject } from 'react'; + +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import type { CanvasTool } from './ImageCanvasEditorTypes'; + +type ImageCanvasBottomToolbarViewProps = { + specToolWrapRef: RefObject; + effectiveTool: CanvasTool; + onSwitchTool: (tool: CanvasTool) => void; +}; + +const canvasTools: Array<{ + id: CanvasTool; + label: string; + icon: typeof MousePointer2; +}> = [ + { id: 'select', label: '选择工具', icon: MousePointer2 }, + { id: 'hand', label: '抓手工具', icon: Hand }, + { id: 'upload', label: '上传工具', icon: ImagePlus }, + { id: 'generate', label: '生成工具', icon: WandSparkles }, + { id: 'spec', label: '生成规范', icon: ClipboardList }, + { id: 'character', label: '生成角色形象', icon: Sparkles }, + { id: 'icon', label: '生成图标素材', icon: ImageIcon }, + { id: 'text', label: '文字工具', icon: Type }, + { id: 'shape', label: '形状标注工具', icon: Shapes }, + { id: 'export', label: '导出工具', icon: Download }, +]; + +export function ImageCanvasBottomToolbarView({ + specToolWrapRef, + effectiveTool, + onSwitchTool, +}: ImageCanvasBottomToolbarViewProps) { + return ( +
event.stopPropagation()} + > + {canvasTools.map(({ id, label, icon: Icon }) => + id === 'spec' ? ( + + onSwitchTool(id)} + /> + + ) : ( + onSwitchTool(id)} + /> + ), + )} +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx new file mode 100644 index 00000000..22d0da94 --- /dev/null +++ b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx @@ -0,0 +1,213 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes'; +import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView'; + +function createPanel( + patch: Partial = {}, +): CharacterAnimationPanelState { + return { + sourceLayerId: 'layer-a', + promptText: '待机动作', + resolution: '480p', + ratio: 'same', + frameCount: 32, + durationSeconds: 4, + status: 'idle', + ...patch, + }; +} + +function CharacterAnimationPanelHarness({ + initialPanel, + onSubmit = vi.fn(), + onUpdateDuration, +}: { + initialPanel: CharacterAnimationPanelState; + onSubmit?: () => void; + onUpdateDuration?: (frameCountValue: string) => void; +}) { + const [panel, setPanel] = useState( + initialPanel, + ); + + const updateDuration = + onUpdateDuration ?? + ((frameCountValue: string) => { + const frameCount = Number(frameCountValue); + setPanel((currentPanel) => + currentPanel + ? { + ...currentPanel, + frameCount: + frameCount === 48 ? 48 : frameCount === 40 ? 40 : 32, + durationSeconds: + frameCount === 48 ? 6 : frameCount === 40 ? 5 : 4, + status: + currentPanel.status === 'failed' + ? 'idle' + : currentPanel.status, + errorMessage: + currentPanel.status === 'failed' + ? undefined + : currentPanel.errorMessage, + } + : currentPanel, + ); + }); + + return panel ? ( +
+ + {panel.promptText} + {panel.resolution} + {panel.ratio} + {panel.frameCount} + {panel.durationSeconds} + {panel.status} + {panel.errorMessage ?? '-'} +
+ ) : ( + closed + ); +} + +describe('ImageCanvasCharacterAnimationPanelView', () => { + it('updates prompt, resolution and ratio while clearing failed state', () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText('动画描述'), { + target: { value: `${'a'.repeat(4001)}` }, + }); + fireEvent.change(screen.getByLabelText('分辨率'), { + target: { value: '720p' }, + }); + fireEvent.change(screen.getByLabelText('画面比例'), { + target: { value: '16:9' }, + }); + + expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(4000); + expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p'); + expect(screen.getByLabelText('当前比例').textContent).toBe('16:9'); + expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); + expect(screen.getByLabelText('当前错误').textContent).toBe('-'); + }); + + it('applies preset prompt and duration updates', () => { + const updateDuration = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '行走' })); + fireEvent.change(screen.getByLabelText('时长'), { + target: { value: '48' }, + }); + + expect(screen.getByLabelText('当前动画描述').textContent).toBe( + '循环行走动作,步伐稳定。', + ); + expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); + expect(screen.getByLabelText('当前错误').textContent).toBe('-'); + expect(updateDuration).toHaveBeenCalledWith('48'); + }); + + it('submits only when idle and closes through its interface', () => { + const submitCharacterAnimation = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '生成' })); + fireEvent.click( + screen.getByRole('button', { name: '关闭角色动画生成面板' }), + ); + + expect(submitCharacterAnimation).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText('面板状态').textContent).toBe('closed'); + }); + + it('keeps generating panels disabled and does not submit', () => { + const submitCharacterAnimation = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '生成中' })); + + expect((screen.getByLabelText('动画描述') as HTMLTextAreaElement).disabled).toBe( + true, + ); + expect((screen.getByRole('button', { name: '待机' }) as HTMLButtonElement).disabled).toBe( + true, + ); + expect(submitCharacterAnimation).not.toHaveBeenCalled(); + }); + + it('renders completed frame count and failed error state', () => { + const result = { + taskId: 'task-a', + model: 'seedance2.0' as const, + prompt: '行走动作', + previewVideoPath: '/preview.mp4', + frames: [], + frameCount: 40, + durationSeconds: 5, + fps: 8, + priceMudPoints: 18, + }; + + const { rerender } = render( + , + ); + + expect(screen.getByText('已生成 40 帧')).toBeTruthy(); + + rerender( + , + ); + + expect(screen.getByRole('alert').textContent).toContain('生成失败'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx new file mode 100644 index 00000000..e58677e2 --- /dev/null +++ b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx @@ -0,0 +1,214 @@ +import { type CSSProperties, type Dispatch, type SetStateAction } from 'react'; +import { X } from 'lucide-react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import { + CHARACTER_ANIMATION_ACTION_PROMPTS, + CHARACTER_ANIMATION_DURATION_OPTIONS, + CHARACTER_ANIMATION_RATIO_OPTIONS, +} from './ImageCanvasGenerationModel'; +import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes'; + +type ImageCanvasCharacterAnimationPanelViewProps = { + panel: CharacterAnimationPanelState; + style: CSSProperties; + price: number; + setCharacterAnimationPanel: Dispatch< + SetStateAction + >; + onUpdateDuration: (frameCountValue: string) => void; + onSubmit: () => void; +}; + +function resetFailedPanelStatus( + panel: T, +) { + return { + ...panel, + status: panel.status === 'failed' ? 'idle' : panel.status, + errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage, + }; +} + +export function ImageCanvasCharacterAnimationPanelView({ + panel, + style, + price, + setCharacterAnimationPanel, + onUpdateDuration, + onSubmit, +}: ImageCanvasCharacterAnimationPanelViewProps) { + return ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (panel.status !== 'generating') { + onSubmit(); + } + }} + > +
+ 角色动画 + setCharacterAnimationPanel(null)} + /> +
+ + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...resetFailedPanelStatus(currentPanel), + promptText: event.target.value.slice(0, 4000), + } + : currentPanel, + ) + } + /> +
+ {CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( + + ))} +
+
+ + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...resetFailedPanelStatus(currentPanel), + resolution: event.target.value === '720p' ? '720p' : '480p', + } + : currentPanel, + ) + } + > + + + + + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...resetFailedPanelStatus(currentPanel), + ratio: + CHARACTER_ANIMATION_RATIO_OPTIONS.find( + (item) => item.value === event.target.value, + )?.value ?? 'same', + } + : currentPanel, + ) + } + > + {CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => ( + + ))} + + onUpdateDuration(event.target.value)} + > + {CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( + + ))} + +
+
+ + {panel.promptText.trim() ? panel.promptText.trim() : '动画描述'} + + {price}泥点 +
+ {panel.status === 'completed' && panel.result ? ( + + 已生成 {panel.result.frameCount} 帧 + + ) : null} + {panel.status === 'failed' ? ( + + {panel.errorMessage} + + ) : null} + + {panel.status === 'generating' ? '生成中' : '生成'} + + + ); +} diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx new file mode 100644 index 00000000..28a73fa5 --- /dev/null +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx @@ -0,0 +1,152 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + GenerateDialogState, + SpecGenerationType, + UploadTarget, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView'; + +function createDialog( + patch: Partial = {}, +): GenerateDialogState { + return { + mode: 'character', + prompt: '旧角色设定', + status: 'idle', + characterSpecReference: { + id: 'spec-a', + label: '角色规范A', + src: 'data:image/png;base64,c3BlYw==', + }, + characterReferences: [ + { + id: 'ref-a', + label: '参考图A', + src: 'data:image/png;base64,cmVm', + }, + ], + ...patch, + }; +} + +function CharacterGenerationHarness({ + initialDialog = createDialog(), + onOpenSpecDialog = vi.fn(), + onRequestUpload = vi.fn(), + onSubmit = vi.fn(), + onRememberImageModel = vi.fn(), +}: { + initialDialog?: GenerateDialogState; + onOpenSpecDialog?: (specType: SpecGenerationType) => void; + onRequestUpload?: (target: UploadTarget) => void; + onSubmit?: (dialog: GenerateDialogState) => void; + onRememberImageModel?: (model: string) => void; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = + useState(false); + const [ + isCharacterReferenceMenuOpen, + setIsCharacterReferenceMenuOpen, + ] = useState(false); + const [isPickingSpec, setIsPickingSpec] = useState(false); + const [isPickingReference, setIsPickingReference] = useState(false); + const characterSpecButtonRef = createRef(); + const characterReferenceButtonRef = createRef(); + + return dialog ? ( +
+ node} + buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })} + onOpenSpecDialog={onOpenSpecDialog} + onRequestUpload={onRequestUpload} + onRememberImageModel={onRememberImageModel} + onSubmit={onSubmit} + /> + {dialog.prompt} + {dialog.status} + {dialog.errorMessage ?? '-'} + {String(isPickingSpec)} + {String(isPickingReference)} +
+ ) : null; +} + +describe('ImageCanvasCharacterGenerationComposerView', () => { + it('updates character prompt, clears failed state and submits', () => { + const submitCharacter = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText('角色设定'), { + target: { value: '新的角色设定' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + expect(screen.getByLabelText('当前角色设定').textContent).toBe( + '新的角色设定', + ); + expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); + expect(screen.getByLabelText('当前错误').textContent).toBe('-'); + expect(submitCharacter).toHaveBeenCalledWith( + expect.objectContaining({ prompt: '新的角色设定', status: 'idle' }), + ); + }); + + it('routes character reference menus to canvas picking, spec creation and upload', () => { + const openSpecDialog = vi.fn(); + const requestUpload = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '角色规范A' })); + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + + expect(screen.getByLabelText('选择角色规范').textContent).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '角色规范A' })); + fireEvent.click(screen.getByRole('menuitem', { name: '新建角色形象规范' })); + + expect(openSpecDialog).toHaveBeenCalledWith('character'); + + fireEvent.click(screen.getByRole('button', { name: '角色规范A' })); + fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); + + expect(requestUpload).toHaveBeenCalledWith('character-spec'); + + fireEvent.click(screen.getByRole('button', { name: '上传常规参考图' })); + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + + expect(screen.getByLabelText('选择常规参考图').textContent).toBe('true'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx new file mode 100644 index 00000000..d580b428 --- /dev/null +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx @@ -0,0 +1,290 @@ +import { ClipboardList, ImagePlus } from 'lucide-react'; +import { + type CSSProperties, + type Dispatch, + type ReactNode, + type RefObject, + type SetStateAction, +} from 'react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; +import { + PlatformFloatingMenu, + PlatformFloatingMenuItem, +} from '../common/PlatformFloatingMenu'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformTextField } from '../common/PlatformTextField'; +import type { + GenerateDialogState, + SpecGenerationType, + UploadTarget, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; + +type ImageCanvasCharacterGenerationComposerViewProps = { + dialog: GenerateDialogState; + style: CSSProperties; + characterSpecButtonRef: RefObject; + characterReferenceButtonRef: RefObject; + isCharacterSpecMenuOpen: boolean; + isCharacterReferenceMenuOpen: boolean; + setGenerateDialog: Dispatch>; + setIsCharacterSpecMenuOpen: Dispatch>; + setIsCharacterReferenceMenuOpen: Dispatch>; + setIsPickingCharacterSpecFromCanvas: Dispatch>; + setIsPickingCharacterReferenceFromCanvas: Dispatch>; + renderEditorPortal: (node: ReactNode) => ReactNode; + buildPortalMenuStyle: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; + onOpenSpecDialog: (specType: SpecGenerationType) => void; + onRequestUpload: (target: UploadTarget) => void; + onRememberImageModel: (model: string) => void; + onSubmit: (dialog: GenerateDialogState) => void; +}; + +function resetFailedDialogStatus(dialog: GenerateDialogState) { + return { + ...dialog, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, + }; +} + +export function ImageCanvasCharacterGenerationComposerView({ + dialog, + style, + characterSpecButtonRef, + characterReferenceButtonRef, + isCharacterSpecMenuOpen, + isCharacterReferenceMenuOpen, + setGenerateDialog, + setIsCharacterSpecMenuOpen, + setIsCharacterReferenceMenuOpen, + setIsPickingCharacterSpecFromCanvas, + setIsPickingCharacterReferenceFromCanvas, + renderEditorPortal, + buildPortalMenuStyle, + onOpenSpecDialog, + onRequestUpload, + onRememberImageModel, + onSubmit, +}: ImageCanvasCharacterGenerationComposerViewProps) { + return ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + > +
+
+ + 角色形象规范 + + + + +
+ {isCharacterSpecMenuOpen + ? renderEditorPortal( + + { + setIsPickingCharacterSpecFromCanvas(true); + setIsCharacterSpecMenuOpen(false); + }} + > + 从画布中选择 + + { + setIsCharacterSpecMenuOpen(false); + onOpenSpecDialog('character'); + }} + > + 新建角色形象规范 + + { + setIsCharacterSpecMenuOpen(false); + onRequestUpload('character-spec'); + }} + > + 上传图片 + + , + ) + : null} +
+ + 常规参考图 + +
+ {(dialog.characterReferences ?? []).map((reference, index) => ( + + {reference.label} + + {index + 1} + + + ))} + + {isCharacterReferenceMenuOpen + ? renderEditorPortal( + + { + setIsPickingCharacterReferenceFromCanvas(true); + setIsCharacterReferenceMenuOpen(false); + }} + > + 从画布中选择 + + { + setIsCharacterReferenceMenuOpen(false); + onRequestUpload('character-reference'); + }} + > + 上传图片 + + , + ) + : null} +
+
+
+ + {dialog.status === 'failed' ? ( + + {dialog.errorMessage} + + ) : null} +
+ + + {dialog.status === 'generating' ? '生成中' : '生成'} + +
+
+ ); +} diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx new file mode 100644 index 00000000..5abcdff9 --- /dev/null +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -0,0 +1,137 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView'; + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-1', + resourceId: 'resource-1', + title: '角色图', + src: 'data:image/png;base64,layer', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +function renderContextMenus( + overrides: Partial[0]> = {}, +) { + const props: Parameters[0] = { + viewport: { x: 0, y: 0, scale: 1 }, + contextMenu: null, + canvasClipboard: null, + imageContextMenu: null, + imageContextMenuLayer: null, + contextShouldShowLayer: false, + contextShouldUnlockLayer: false, + onPasteCanvasClipboard: vi.fn(), + onCopyContextLayers: vi.fn(), + onDuplicateContextLayers: vi.fn(), + onMoveContextLayers: vi.fn(), + onGroupContextLayers: vi.fn(), + onUngroupContextLayers: vi.fn(), + onToggleContextLayerVisibility: vi.fn(), + onToggleContextLayerLock: vi.fn(), + onFlipContextLayers: vi.fn(), + onExportContextLayer: vi.fn(), + onDeleteContextLayers: vi.fn(), + onDeleteLayerById: vi.fn(), + onCloseContextMenu: vi.fn(), + onCloseImageContextMenu: vi.fn(), + onUpdateScaleFromCenter: vi.fn(), + onFitLayers: vi.fn(), + onOpenQuickEditPanel: vi.fn(), + onOpenLayerMetadata: vi.fn(), + onOpenCharacterAnimationPanel: vi.fn(), + ...overrides, + }; + + render(); + + return props; +} + +describe('ImageCanvasContextMenusView', () => { + it('renders blank canvas commands and forwards the canvas point', () => { + const canvasPoint = { x: 18, y: 24 }; + const layer = createLayer(); + const props = renderContextMenus({ + contextMenu: { kind: 'blank', x: 10, y: 12, canvasPoint }, + canvasClipboard: { layers: [layer], mode: 'copy' }, + }); + + fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' })); + fireEvent.click(screen.getByRole('menuitem', { name: '放大' })); + fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' })); + + expect(props.onPasteCanvasClipboard).toHaveBeenCalledWith(canvasPoint); + expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(1.16); + expect(props.onFitLayers).toHaveBeenCalledTimes(1); + expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2); + }); + + it('renders layer context commands and forwards layer operations', () => { + const layer = createLayer({ assetKind: 'character' }); + const props = renderContextMenus({ + contextMenu: { + kind: 'layer', + x: 16, + y: 18, + layerId: layer.id, + canvasPoint: { x: 40, y: 42 }, + }, + canvasClipboard: { layers: [layer], mode: 'copy' }, + imageContextMenuLayer: layer, + contextShouldShowLayer: true, + contextShouldUnlockLayer: true, + }); + + fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); + fireEvent.click(screen.getByRole('menuitem', { name: '剪切' })); + fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' })); + fireEvent.click(screen.getByRole('menuitem', { name: '上移一层' })); + fireEvent.click(screen.getByRole('menuitem', { name: '显示' })); + fireEvent.click(screen.getByRole('menuitem', { name: '解锁' })); + fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' })); + fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' })); + fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); + + expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(1); + expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(2, { cut: true }); + expect(props.onDuplicateContextLayers).toHaveBeenCalledTimes(1); + expect(props.onMoveContextLayers).toHaveBeenCalledWith('up'); + expect(props.onToggleContextLayerVisibility).toHaveBeenCalledTimes(1); + expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1); + expect(props.onFlipContextLayers).toHaveBeenCalledWith('x'); + expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1); + }); + + it('renders the image context menu when no canvas context menu is open', () => { + const layer = createLayer({ assetKind: 'character' }); + const props = renderContextMenus({ + imageContextMenu: { layerId: layer.id, x: 20, y: 22 }, + imageContextMenuLayer: layer, + }); + + fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' })); + fireEvent.click(screen.getByRole('menuitem', { name: '查看图片信息' })); + fireEvent.click(screen.getByRole('menuitem', { name: '删除图片' })); + + expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer); + expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); + expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id); + }); +}); diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx new file mode 100644 index 00000000..e3b9019c --- /dev/null +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -0,0 +1,319 @@ +import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu'; +import type { + CanvasClipboard, + CanvasContextMenuState, + CanvasLayer, + CanvasViewport, + ImageContextMenuState, +} from './ImageCanvasEditorTypes'; + +type ImageCanvasContextMenusViewProps = { + viewport: CanvasViewport; + contextMenu: CanvasContextMenuState | null; + canvasClipboard: CanvasClipboard | null; + imageContextMenu: ImageContextMenuState | null; + imageContextMenuLayer: CanvasLayer | null; + contextShouldShowLayer: boolean; + contextShouldUnlockLayer: boolean; + onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void; + onCopyContextLayers: (options?: { cut?: boolean }) => void; + onDuplicateContextLayers: () => void; + onMoveContextLayers: (mode: 'up' | 'down' | 'top' | 'bottom') => void; + onGroupContextLayers: () => void; + onUngroupContextLayers: () => void; + onToggleContextLayerVisibility: () => void; + onToggleContextLayerLock: () => void; + onFlipContextLayers: (axis: 'x' | 'y') => void; + onExportContextLayer: () => void; + onDeleteContextLayers: () => void; + onDeleteLayerById: (layerId: string | null) => void; + onCloseContextMenu: () => void; + onCloseImageContextMenu: () => void; + onUpdateScaleFromCenter: (nextScale: number) => void; + onFitLayers: () => void; + onOpenQuickEditPanel: (layer: CanvasLayer) => void; + onOpenLayerMetadata: (layer: CanvasLayer) => void; + onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; +}; + +export function ImageCanvasContextMenusView({ + viewport, + contextMenu, + canvasClipboard, + imageContextMenu, + imageContextMenuLayer, + contextShouldShowLayer, + contextShouldUnlockLayer, + onPasteCanvasClipboard, + onCopyContextLayers, + onDuplicateContextLayers, + onMoveContextLayers, + onGroupContextLayers, + onUngroupContextLayers, + onToggleContextLayerVisibility, + onToggleContextLayerLock, + onFlipContextLayers, + onExportContextLayer, + onDeleteContextLayers, + onDeleteLayerById, + onCloseContextMenu, + onCloseImageContextMenu, + onUpdateScaleFromCenter, + onFitLayers, + onOpenQuickEditPanel, + onOpenLayerMetadata, + onOpenCharacterAnimationPanel, +}: ImageCanvasContextMenusViewProps) { + return ( + <> + {contextMenu ? ( +
event.preventDefault()} + onPointerDown={(event) => event.stopPropagation()} + > + {contextMenu.kind === 'blank' ? ( + <> + + + + + + ) : ( + <> + + + + +
+ + + + +
+ + + + +
+ + + +
+ {imageContextMenuLayer ? ( + <> + + + {imageContextMenuLayer.assetKind === 'character' ? ( + + ) : null} +
+ + ) : null} + + + )} +
+ ) : null} + + {imageContextMenu && imageContextMenuLayer && !contextMenu ? ( +
event.stopPropagation()} + > + + onOpenQuickEditPanel(imageContextMenuLayer)} + > + 快速编辑 + + { + onOpenLayerMetadata(imageContextMenuLayer); + onCloseImageContextMenu(); + }} + > + 查看图片信息 + + {imageContextMenuLayer.assetKind === 'character' ? ( + onOpenCharacterAnimationPanel(imageContextMenuLayer)} + > + 生成动画 + + ) : null} + onDeleteLayerById(imageContextMenuLayer.id)} + > + 删除图片 + + +
+ ) : null} + + ); +} diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx new file mode 100644 index 00000000..8b2d1db0 --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx @@ -0,0 +1,98 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView'; + +function createDialog( + patch: Partial = {}, +): GenerateDialogState { + return { + mode: 'edit', + prompt: '旧修改提示', + status: 'idle', + sourceLayerId: 'layer-a', + ...patch, + }; +} + +function EditGenerationModalHarness({ + initialDialog = createDialog(), + onSubmit = vi.fn(), +}: { + initialDialog?: GenerateDialogState | null; + onSubmit?: (dialog: GenerateDialogState) => void; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + + return ( +
+ + {dialog ? 'open' : 'closed'} + {dialog?.prompt ?? '-'} +
+ ); +} + +describe('ImageCanvasEditGenerationModalView', () => { + it('updates prompt and submits edit generation', () => { + const submitEdit = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '新的修改提示' }, + }); + fireEvent.click(screen.getByRole('button', { name: '修改' })); + + expect(screen.getByLabelText('当前提示词').textContent).toBe( + '新的修改提示', + ); + expect(submitEdit).toHaveBeenCalledWith( + expect.objectContaining({ prompt: '新的修改提示' }), + ); + }); + + it('renders failure state and closes through the modal close button', () => { + render( + , + ); + + expect(screen.getByRole('alert').textContent).toContain('修改失败'); + + fireEvent.click(screen.getByRole('button', { name: '关闭修改图片' })); + + expect(screen.getByLabelText('弹窗状态').textContent).toBe('closed'); + }); + + it('stays hidden for non-edit dialogs and generating edit dialogs', () => { + const { rerender } = render( + , + ); + + expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); + + rerender( + , + ); + + expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx new file mode 100644 index 00000000..2f7027b7 --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -0,0 +1,99 @@ +import { type Dispatch, type SetStateAction } from 'react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformTextField } from '../common/PlatformTextField'; +import { UnifiedModal } from '../common/UnifiedModal'; +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; + +type ImageCanvasEditGenerationModalViewProps = { + dialog: GenerateDialogState | null; + setGenerateDialog: Dispatch>; + onSubmit: (dialog: GenerateDialogState) => void; +}; + +export function ImageCanvasEditGenerationModalView({ + dialog, + setGenerateDialog, + onSubmit, +}: ImageCanvasEditGenerationModalViewProps) { + const isOpen = dialog?.mode === 'edit' && dialog.status !== 'generating'; + + return ( + setGenerateDialog(null)} + panelClassName="image-canvas-editor__generate-dialog" + bodyClassName="image-canvas-editor__generate-dialog-body" + > + {dialog?.mode === 'edit' ? ( +
{ + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + > +
+ + setGenerateDialog((currentDialog) => + currentDialog + ? { + ...currentDialog, + prompt: event.target.value, + } + : currentDialog, + ) + } + /> + {dialog.status === 'generating' ? ( + + 修改中 + + ) : null} + {dialog.status === 'failed' ? ( + + {dialog.errorMessage} + + ) : null} + + {dialog.status === 'generating' ? '修改中' : '修改'} + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx new file mode 100644 index 00000000..06234ede --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -0,0 +1,258 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + ImageCanvasMetadataModalViewProps, +} from './ImageCanvasMetadataModalView'; +import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView'; +import type { ImageCanvasStageViewProps } from './ImageCanvasStageView'; +import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView'; +import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-1', + resourceId: 'resource-1', + title: '测试图片', + src: 'data:image/png;base64,layer', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +function createSidebarProps(): ImageCanvasSidebarViewProps { + return { + activeSidebarPanel: null, + assetListRef: createRef(), + assetPointerDragRef: { current: null }, + suppressAssetClickRef: { current: false }, + assets: [], + groupedAssets: [], + assetFolders: [], + layers: [], + selectedLayerId: null, + selectedLayerIds: [], + isAssetSelectionMode: false, + selectedAssetIds: new Set(), + assetMoveDropFolderId: null, + pinnedAssetMoveFolderId: null, + creatingFolder: false, + newFolderName: '', + renamingFolder: null, + renamingAsset: null, + allSelectableAssetsSelected: false, + assetMarquee: null, + setIsAssetSelectionMode: vi.fn(), + setCreatingFolder: vi.fn(), + setNewFolderName: vi.fn(), + setRenamingFolder: vi.fn(), + setRenamingAsset: vi.fn(), + setActiveUploadFolderId: vi.fn(), + setUploadDropTarget: vi.fn(), + setAssetPointerDrag: vi.fn(), + setSelectedAssetIds: vi.fn(), + setImageContextMenu: vi.fn(), + setContextMenu: vi.fn(), + onAssetMarqueePointerDown: vi.fn(), + onAssetMarqueePointerMove: vi.fn(), + onAssetMarqueePointerUp: vi.fn(), + updateAssetMoveDropFolder: vi.fn(), + addUploadedFiles: vi.fn(), + requestUpload: vi.fn(), + moveAssetToFolder: vi.fn(), + commitNewAssetFolder: vi.fn(), + toggleAssetFolder: vi.fn(), + startRenamingFolder: vi.fn(), + commitFolderRename: vi.fn(), + deleteAssetFolder: vi.fn(), + startRenamingAsset: vi.fn(), + commitAssetRename: vi.fn(), + deleteUploadedAsset: vi.fn(), + toggleAssetSelected: vi.fn(), + addAssetLayer: vi.fn(), + toggleAllAssetsSelected: vi.fn(), + deleteSelectedAssets: vi.fn(), + closeAssetSelectionMode: vi.fn(), + groupSelectedLayers: vi.fn(), + selectSingleLayer: vi.fn(), + resolveContextMenuPosition: vi.fn(() => ({ x: 0, y: 0 })), + getCanvasPointFromClient: vi.fn(() => ({ x: 0, y: 0 })), + }; +} + +function createTopbarProps(): ImageCanvasTopbarViewProps { + return { + projectId: 'project-1', + projectTitle: '默认项目', + projectRenameValue: '默认项目', + isRenamingProject: false, + isProjectRenameSaving: false, + projectRenameError: null, + layers: [], + assetExportStatus: null, + isExportingAssets: false, + setProjectRenameValue: vi.fn(), + startProjectRename: vi.fn(), + cancelProjectRename: vi.fn(), + submitProjectRename: vi.fn(), + resetProjectRenameError: vi.fn(), + exportCanvasAssets: vi.fn(), + }; +} + +function createStageProps(): ImageCanvasStageViewProps { + return { + canvasViewportRef: createRef(), + specToolWrapRef: createRef(), + isPanning: false, + effectiveTool: 'select', + canvasBackgroundColor: '#f8fafc', + canvasBackgroundHexValue: '#f8fafc', + viewport: { x: 0, y: 0, scale: 1 }, + snapGuide: null, + layers: [], + selectedLayer: null, + selectedLayerIds: [], + hoveredLayerId: null, + canvasMarquee: null, + canvasGenerationDialogs: [], + generateDialog: null, + quickEditPanel: null, + generationComposerStyle: null, + selectedToolbarStyle: null, + uploadDropTarget: null, + contextMenu: null, + canvasClipboard: null, + imageContextMenu: null, + imageContextMenuLayer: null, + contextShouldShowLayer: false, + contextShouldUnlockLayer: false, + canUndo: false, + canRedo: false, + isZoomMenuOpen: false, + isBackgroundSettingsOpen: false, + activeSidebarPanel: null, + isMinimapOpen: false, + minimapModel: null, + onCanvasPointerDown: vi.fn(), + onCanvasPointerMove: vi.fn(), + onCanvasPointerUp: vi.fn(), + onCanvasDragOver: vi.fn(), + onCanvasDragLeave: vi.fn(), + onCanvasDrop: vi.fn(), + onCanvasContextMenu: vi.fn(), + onLayerPointerDown: vi.fn(), + onLayerClick: vi.fn(), + onLayerContextMenu: vi.fn(), + onLayerMouseEnter: vi.fn(), + onLayerMouseLeave: vi.fn(), + onOpenLayerMetadata: vi.fn(), + onGenerationFramePointerDown: vi.fn(), + onActivateGenerationDialog: vi.fn(), + onDeleteSelectedLayer: vi.fn(), + onOpenQuickEditPanel: vi.fn(), + onOpenEditDialog: vi.fn(), + onOpenCharacterAnimationPanel: vi.fn(), + onPasteCanvasClipboard: vi.fn(), + onCopyContextLayers: vi.fn(), + onDuplicateContextLayers: vi.fn(), + onMoveContextLayers: vi.fn(), + onGroupContextLayers: vi.fn(), + onUngroupContextLayers: vi.fn(), + onToggleContextLayerVisibility: vi.fn(), + onToggleContextLayerLock: vi.fn(), + onFlipContextLayers: vi.fn(), + onExportContextLayer: vi.fn(), + onDeleteContextLayers: vi.fn(), + onDeleteLayerById: vi.fn(), + onCloseContextMenu: vi.fn(), + onCloseImageContextMenu: vi.fn(), + onUpdateScaleFromCenter: vi.fn(), + onFitLayers: vi.fn(), + onUndoCanvasChange: vi.fn(), + onRedoCanvasChange: vi.fn(), + onToggleZoomMenu: vi.fn(), + onCloseZoomMenu: vi.fn(), + onToggleBackgroundSettings: vi.fn(), + onApplyCanvasBackgroundColor: vi.fn(), + onCanvasBackgroundHexChange: vi.fn(), + onToggleSidebarPanel: vi.fn(), + onToggleMinimap: vi.fn(), + onMinimapPointerDown: vi.fn(), + onSwitchTool: vi.fn(), + }; +} + +function createMetadataProps( + layer: CanvasLayer | null = null, +): ImageCanvasMetadataModalViewProps { + return { + layer, + onClose: vi.fn(), + }; +} + +describe('ImageCanvasEditorShellView', () => { + it('composes upload input, topbar, stage and metadata modal', () => { + const handleUploadInputChange = vi.fn(); + render( + ()} + uploadInputRef={createRef()} + onUploadInputChange={handleUploadInputChange} + assetDragPreview={null} + sidebarProps={createSidebarProps()} + topbarProps={createTopbarProps()} + stageProps={createStageProps()} + metadataProps={createMetadataProps(createLayer())} + />, + ); + + expect( + screen.getByRole('region', { name: '图片画布编辑器' }), + ).toBeTruthy(); + expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); + expect(screen.getByLabelText('画布工作区')).toBeTruthy(); + expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '测试图片图片信息' })).toBeTruthy(); + + const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement; + fireEvent.change(uploadInput); + + expect(uploadInput.multiple).toBe(true); + expect(uploadInput.accept).toBe('image/*'); + expect(handleUploadInputChange).toHaveBeenCalledTimes(1); + }); + + it('renders the asset drag preview with sanitized coordinates', () => { + render( + ()} + uploadInputRef={createRef()} + onUploadInputChange={vi.fn()} + assetDragPreview={{ x: Number.NaN, y: 48, label: '拖拽素材' }} + sidebarProps={createSidebarProps()} + topbarProps={createTopbarProps()} + stageProps={createStageProps()} + metadataProps={createMetadataProps()} + />, + ); + + const preview = screen.getByText('拖拽素材'); + + expect(preview.className).toBe('image-canvas-editor__asset-drag-preview'); + expect((preview as HTMLElement).style.left).toBe('0px'); + expect((preview as HTMLElement).style.top).toBe('48px'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.tsx b/src/components/image-editor/ImageCanvasEditorShellView.tsx new file mode 100644 index 00000000..4131d50a --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorShellView.tsx @@ -0,0 +1,77 @@ +import type { + ChangeEventHandler, + ComponentProps, + RefObject, +} from 'react'; + +import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; +import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; +import { ImageCanvasStageView } from './ImageCanvasStageView'; +import { ImageCanvasTopbarView } from './ImageCanvasTopbarView'; + +type AssetDragPreview = { + x: number; + y: number; + label: string; +}; + +type ImageCanvasEditorShellViewProps = { + editorRootRef: RefObject; + uploadInputRef: RefObject; + onUploadInputChange: ChangeEventHandler; + assetDragPreview: AssetDragPreview | null; + sidebarProps: ComponentProps; + topbarProps: ComponentProps; + stageProps: ComponentProps; + metadataProps: ComponentProps; +}; + +export function ImageCanvasEditorShellView({ + editorRootRef, + uploadInputRef, + onUploadInputChange, + assetDragPreview, + sidebarProps, + topbarProps, + stageProps, + metadataProps, +}: ImageCanvasEditorShellViewProps) { + return ( +
event.preventDefault()} + > + + {assetDragPreview ? ( + + ) : null} + + +
+ + +
+ + +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 3ce1795b..617a6d99 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -790,10 +790,6 @@ describe('ImageCanvasEditorView', () => { screen.getByRole('button', { name: '重命名素材拼图素材' }), ); const renameInput = screen.getByLabelText('重命名素材拼图素材'); - expect(renameInput.className).toContain('platform-text-field'); - expect(renameInput.className).toContain( - 'image-canvas-editor__asset-rename-input', - ); await user.clear(renameInput); await user.type(renameInput, '主视觉素材'); await user.click( @@ -822,10 +818,6 @@ describe('ImageCanvasEditorView', () => { await user.click(screen.getByRole('button', { name: '新建素材文件夹' })); const folderNameInput = screen.getByLabelText('素材文件夹名称'); - expect(folderNameInput.className).toContain('platform-text-field'); - expect(folderNameInput.className).toContain( - 'image-canvas-editor__folder-create-input', - ); await user.type(folderNameInput, '角色上传'); await user.click(screen.getByRole('button', { name: '保存素材文件夹' })); @@ -886,10 +878,6 @@ describe('ImageCanvasEditorView', () => { await screen.findByRole('region', { name: '角色' }); await user.click(screen.getByRole('button', { name: '重命名文件夹角色' })); const folderRenameInput = screen.getByLabelText('重命名文件夹角色'); - expect(folderRenameInput.className).toContain('platform-text-field'); - expect(folderRenameInput.className).toContain( - 'image-canvas-editor__folder-rename-input', - ); await user.clear(folderRenameInput); await user.type(folderRenameInput, '角色参考'); await user.click( diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index a6fed626..a3621262 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,10 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAuthUi } from '../auth/AuthUiContext'; -import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; -import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; -import { ImageCanvasStageView } from './ImageCanvasStageView'; -import { ImageCanvasTopbarView } from './ImageCanvasTopbarView'; +import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import { resolveContextMenuPosition } from './ImageCanvasEditorModel'; import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; import type { @@ -584,218 +581,196 @@ export function ImageCanvasEditorView() { requestUpload('asset'); return; } - if (switchGenerationTool(tool)) { + if (switchGenerationTool(tool)) { return; } setActiveTool(tool); }; + const assetDragPreview = assetPointerDrag?.active + ? { + x: assetPointerDrag.currentClientX, + y: assetPointerDrag.currentClientY, + label: + assets.find((asset) => asset.id === assetPointerDrag.assetId) + ?.label ?? '素材', + } + : null; + const sidebarProps = { + activeSidebarPanel, + assetListRef, + assetPointerDragRef, + suppressAssetClickRef, + assets, + groupedAssets, + assetFolders, + layers, + selectedLayerId, + selectedLayerIds, + isAssetSelectionMode, + selectedAssetIds, + assetMoveDropFolderId, + pinnedAssetMoveFolderId, + creatingFolder, + newFolderName, + renamingFolder, + renamingAsset, + allSelectableAssetsSelected, + assetMarquee, + setIsAssetSelectionMode, + setCreatingFolder, + setNewFolderName, + setRenamingFolder, + setRenamingAsset, + setActiveUploadFolderId, + setUploadDropTarget, + setAssetPointerDrag, + setSelectedAssetIds, + setImageContextMenu, + setContextMenu, + onAssetMarqueePointerDown: handleAssetMarqueePointerDown, + onAssetMarqueePointerMove: handleAssetMarqueePointerMove, + onAssetMarqueePointerUp: handleAssetMarqueePointerUp, + updateAssetMoveDropFolder, + addUploadedFiles, + requestUpload, + moveAssetToFolder, + commitNewAssetFolder, + toggleAssetFolder, + startRenamingFolder, + commitFolderRename, + deleteAssetFolder, + startRenamingAsset, + commitAssetRename, + deleteUploadedAsset, + toggleAssetSelected, + addAssetLayer, + toggleAllAssetsSelected, + deleteSelectedAssets, + closeAssetSelectionMode, + groupSelectedLayers, + selectSingleLayer, + resolveContextMenuPosition, + getCanvasPointFromClient, + }; + const topbarProps = { + projectId, + projectTitle, + projectRenameValue, + isRenamingProject, + isProjectRenameSaving, + projectRenameError, + layers, + assetExportStatus, + isExportingAssets, + setProjectRenameValue, + startProjectRename, + cancelProjectRename, + submitProjectRename, + resetProjectRenameError, + exportCanvasAssets, + }; + const stageProps = { + canvasViewportRef, + specToolWrapRef, + isPanning, + effectiveTool, + canvasBackgroundColor, + canvasBackgroundHexValue, + viewport, + snapGuide, + layers, + selectedLayer, + selectedLayerIds, + hoveredLayerId, + canvasMarquee, + canvasGenerationDialogs, + generateDialog, + quickEditPanel, + generationComposerStyle, + selectedToolbarStyle, + uploadDropTarget, + contextMenu, + canvasClipboard, + imageContextMenu, + imageContextMenuLayer, + contextShouldShowLayer, + contextShouldUnlockLayer, + canUndo, + canRedo, + isZoomMenuOpen, + isBackgroundSettingsOpen, + activeSidebarPanel, + isMinimapOpen, + minimapModel, + onCanvasPointerDown: handleCanvasPointerDown, + onCanvasPointerMove: handlePointerMove, + onCanvasPointerUp: finishDrag, + onCanvasDragOver: handleCanvasDragOver, + onCanvasDragLeave: handleCanvasDragLeave, + onCanvasDrop: handleCanvasDrop, + onCanvasContextMenu: handleCanvasContextMenu, + onLayerPointerDown: handleLayerPointerDown, + onLayerClick: handleLayerClick, + onLayerContextMenu: handleLayerContextMenu, + onLayerMouseEnter: setHoveredLayerId, + onLayerMouseLeave: (layerId: string) => + setHoveredLayerId((currentId) => + currentId === layerId ? null : currentId, + ), + onOpenLayerMetadata: (layer: CanvasLayer) => { + setMetadataLayer(layer); + selectSingleLayer(layer.id); + }, + onGenerationFramePointerDown: handleGenerationFramePointerDown, + onActivateGenerationDialog: activateCanvasGenerationDialog, + onDeleteSelectedLayer: deleteSelectedLayer, + onOpenQuickEditPanel: openQuickEditPanel, + onOpenEditDialog: openEditDialog, + onOpenCharacterAnimationPanel: openCharacterAnimationPanel, + onPasteCanvasClipboard: pasteCanvasClipboard, + onCopyContextLayers: copyContextLayers, + onDuplicateContextLayers: duplicateContextLayers, + onMoveContextLayers: moveContextLayers, + onGroupContextLayers: groupContextLayers, + onUngroupContextLayers: ungroupContextLayers, + onToggleContextLayerVisibility: toggleContextLayerVisibility, + onToggleContextLayerLock: toggleContextLayerLock, + onFlipContextLayers: flipContextLayers, + onExportContextLayer: exportContextLayer, + onDeleteContextLayers: deleteContextLayers, + onDeleteLayerById: deleteLayerById, + onCloseContextMenu: () => setContextMenu(null), + onCloseImageContextMenu: () => setImageContextMenu(null), + onUpdateScaleFromCenter: updateScaleFromCenter, + onFitLayers: fitLayers, + onUndoCanvasChange: undoCanvasChange, + onRedoCanvasChange: redoCanvasChange, + onToggleZoomMenu: toggleZoomMenu, + onCloseZoomMenu: closeZoomMenu, + onToggleBackgroundSettings: toggleBackgroundSettings, + onApplyCanvasBackgroundColor: applyCanvasBackgroundColor, + onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange, + onToggleSidebarPanel: toggleSidebarPanel, + onToggleMinimap: toggleMinimap, + onMinimapPointerDown: handleMinimapPointerDown, + onSwitchTool: switchTool, + children: generationComposerNode, + }; return ( -
event.preventDefault()} - > - - {assetPointerDrag?.active ? ( - - ) : null} - - -
- - - - setHoveredLayerId((currentId) => - currentId === layerId ? null : currentId, - ) - } - onOpenLayerMetadata={(layer) => { - setMetadataLayer(layer); - selectSingleLayer(layer.id); - }} - onGenerationFramePointerDown={handleGenerationFramePointerDown} - onActivateGenerationDialog={activateCanvasGenerationDialog} - onDeleteSelectedLayer={deleteSelectedLayer} - onOpenQuickEditPanel={openQuickEditPanel} - onOpenEditDialog={openEditDialog} - onOpenCharacterAnimationPanel={openCharacterAnimationPanel} - onPasteCanvasClipboard={pasteCanvasClipboard} - onCopyContextLayers={copyContextLayers} - onDuplicateContextLayers={duplicateContextLayers} - onMoveContextLayers={moveContextLayers} - onGroupContextLayers={groupContextLayers} - onUngroupContextLayers={ungroupContextLayers} - onToggleContextLayerVisibility={toggleContextLayerVisibility} - onToggleContextLayerLock={toggleContextLayerLock} - onFlipContextLayers={flipContextLayers} - onExportContextLayer={exportContextLayer} - onDeleteContextLayers={deleteContextLayers} - onDeleteLayerById={deleteLayerById} - onCloseContextMenu={() => setContextMenu(null)} - onCloseImageContextMenu={() => setImageContextMenu(null)} - onUpdateScaleFromCenter={updateScaleFromCenter} - onFitLayers={fitLayers} - onUndoCanvasChange={undoCanvasChange} - onRedoCanvasChange={redoCanvasChange} - onToggleZoomMenu={toggleZoomMenu} - onCloseZoomMenu={closeZoomMenu} - onToggleBackgroundSettings={toggleBackgroundSettings} - onApplyCanvasBackgroundColor={applyCanvasBackgroundColor} - onCanvasBackgroundHexChange={handleCanvasBackgroundHexChange} - onToggleSidebarPanel={toggleSidebarPanel} - onToggleMinimap={toggleMinimap} - onMinimapPointerDown={handleMinimapPointerDown} - onSwitchTool={switchTool} - > - {generationComposerNode} - -
- - setMetadataLayer(null)} - /> -
+ setMetadataLayer(null), + }} + /> ); } diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index d8241c17..9e646bda 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -1,10 +1,3 @@ -import { - ChevronDown, - ClipboardList, - ImageIcon, - ImagePlus, - X, -} from 'lucide-react'; import { type CSSProperties, type Dispatch, @@ -14,34 +7,18 @@ import { } 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, - EDITOR_IMAGE_DIMENSION_OPTIONS, - EDITOR_IMAGE_MODEL_OPTIONS, - ICON_DESCRIPTION_LIMIT, - IMAGE_MODEL_NANOBANANA2, - SPEC_GENERATION_COST, - SPEC_TYPE_LABEL, -} from './ImageCanvasGenerationModel'; +import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; +import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView'; +import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView'; +import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView'; +import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView'; +import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView'; +import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView'; +import { SPEC_TYPE_LABEL } from './ImageCanvasGenerationModel'; import type { CharacterAnimationPanelState, CanvasLayer, @@ -101,10 +78,6 @@ type ImageCanvasGenerationComposerViewProps = { onRememberImageModel: (model: string) => void; }; -function triggerPlaceholderAction(label: string) { - window.alert(`${label}功能建设中`); -} - function buildPortalMenuStyle( anchor: HTMLElement | null, placement: 'above' | 'below', @@ -143,168 +116,6 @@ function renderEditorPortal(node: ReactNode) { 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, - }; -} - -function getImageDimensionOptions(model: string | null | undefined) { - return ( - EDITOR_IMAGE_DIMENSION_OPTIONS[ - (model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS - ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2] - ); -} - -function normalizeImageDialogSelection(dialog: GenerateDialogState) { - const model = dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2; - const options = getImageDimensionOptions(model); - const aspectRatios = options.aspectRatios as readonly string[]; - const imageSizes = options.imageSizes as readonly string[]; - return { - model, - aspectRatio: - dialog.aspectRatio && aspectRatios.includes(dialog.aspectRatio) - ? dialog.aspectRatio - : options.aspectRatios[0], - imageSize: - dialog.imageSize && imageSizes.includes(dialog.imageSize) - ? dialog.imageSize - : (options.imageSizes.find((size) => size === '1K') ?? options.imageSizes[0]), - options, - }; -} - -function renderImageOptionButtons({ - dialog, - setGenerateDialog, - includeDimensions, - onRememberImageModel, -}: { - dialog: GenerateDialogState; - setGenerateDialog: Dispatch>; - includeDimensions: boolean; - onRememberImageModel: (model: string) => void; -}) { - const selection = normalizeImageDialogSelection(dialog); - const updateDialog = (patch: Partial) => { - setGenerateDialog((currentDialog) => - currentDialog && currentDialog.mode === dialog.mode - ? { - ...resetFailedDialogStatus(currentDialog), - ...patch, - } - : currentDialog, - ); - }; - - return ( - <> - {includeDimensions ? ( - <> -
- - 画面比例 - -
- {selection.options.aspectRatios.map((aspectRatio) => ( - updateDialog({ aspectRatio })} - > - {aspectRatio} - - ))} -
-
-
- - 大小尺寸 - -
- {selection.options.imageSizes.map((imageSize) => ( - updateDialog({ imageSize })} - > - {imageSize} - - ))} -
-
- - ) : null} -
- - 模型 - -
- {EDITOR_IMAGE_MODEL_OPTIONS.map((option) => { - const nextOptions = getImageDimensionOptions(option.value); - const nextAspectRatios = nextOptions.aspectRatios as readonly string[]; - const nextImageSizes = nextOptions.imageSizes as readonly string[]; - return ( - { - onRememberImageModel(option.value); - updateDialog({ - imageModel: option.value, - aspectRatio: - dialog.aspectRatio && - nextAspectRatios.includes(dialog.aspectRatio) - ? dialog.aspectRatio - : nextOptions.aspectRatios[0], - imageSize: - dialog.imageSize && - nextImageSizes.includes(dialog.imageSize) - ? dialog.imageSize - : (nextOptions.imageSizes.find((size) => size === '1K') ?? - nextOptions.imageSizes[0]), - }); - }} - > - {option.label} - - ); - })} -
-
- - ); -} - export function ImageCanvasGenerationComposerView({ specToolWrapRef, characterSpecButtonRef, @@ -377,746 +188,74 @@ export function ImageCanvasGenerationComposerView({ {generateDialog?.mode === 'generate' && generateDialog.composerOpen !== false && generationComposerStyle ? ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (generateDialog.status !== 'generating') { - onSubmitImageGeneration(generateDialog); - } - }} - > - onRequestUpload('asset')} - icon={} - > - 参考图 - - - setGenerateDialog((currentDialog) => - currentDialog - ? { - ...resetFailedDialogStatus(currentDialog), - prompt: event.target.value, - } - : currentDialog, - ) - } - /> -
- triggerPlaceholderAction('生成参数')} - trailingIcon={} - > - 中 · 1:1(2k) · 1张 - - triggerPlaceholderAction('模型选择')} - trailingIcon={} - > - GPT Im... - - - {generateDialog.status === 'generating' ? '生成中' : '12'} - -
- {generateDialog.status === 'generating' ? ( - - 生成中 - - ) : null} - {generateDialog.status === 'failed' ? ( - - {generateDialog.errorMessage} - - ) : null} - - + setGenerateDialog={setGenerateDialog} + onRequestUpload={onRequestUpload} + onSubmit={onSubmitImageGeneration} + onClose={onCloseGenerateComposer} + /> ) : null} {generateDialog?.mode === 'spec' && generateDialog.composerOpen !== false && generationComposerStyle ? ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (generateDialog.status !== 'generating') { - onSubmitImageGeneration(generateDialog); - } - }} - > -
- {generateDialog.specType === 'custom' ? ( - - ) : ( - <> - - - {generateDialog.specType === 'character' ? ( - <> - - - - ) : null} - - )} - {generateDialog.specType !== 'icon' ? ( -
- - 参考图 - - -
- ) : null} -
- {generateDialog.status === 'failed' ? ( - - {generateDialog.errorMessage} - - ) : null} -
- - {generateDialog.status === 'generating' - ? '生成中' - : `消耗${SPEC_GENERATION_COST}泥点 · 生成`} - -
-
+ onUpdateSpecFormValue={onUpdateSpecFormValue} + onRequestUpload={onRequestUpload} + onSubmit={onSubmitImageGeneration} + /> ) : null} {generateDialog?.mode === 'character' && generationComposerStyle ? ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (generateDialog.status !== 'generating') { - onSubmitImageGeneration(generateDialog); - } - }} - > -
-
- - 角色形象规范 - - - - -
- {isCharacterSpecMenuOpen - ? renderEditorPortal( - - { - setIsPickingCharacterSpecFromCanvas(true); - setIsCharacterSpecMenuOpen(false); - }} - > - 从画布中选择 - - { - setIsCharacterSpecMenuOpen(false); - onOpenSpecDialog('character'); - }} - > - 新建角色形象规范 - - { - setIsCharacterSpecMenuOpen(false); - onRequestUpload('character-spec'); - }} - > - 上传图片 - - , - ) - : null} -
- - 常规参考图 - -
- {(generateDialog.characterReferences ?? []).map( - (reference, index) => ( - - {reference.label} - - {index + 1} - - - ), - )} - - {isCharacterReferenceMenuOpen - ? renderEditorPortal( - - { - setIsPickingCharacterReferenceFromCanvas(true); - setIsCharacterReferenceMenuOpen(false); - }} - > - 从画布中选择 - - { - setIsCharacterReferenceMenuOpen(false); - onRequestUpload('character-reference'); - }} - > - 上传图片 - - , - ) - : null} -
-
-
- - {generateDialog.status === 'failed' ? ( - - {generateDialog.errorMessage} - - ) : null} -
- {renderImageOptionButtons({ - dialog: generateDialog, - setGenerateDialog, - includeDimensions: true, - onRememberImageModel, - })} - - {generateDialog.status === 'generating' ? '生成中' : '生成'} - -
-
+ characterSpecButtonRef={characterSpecButtonRef} + characterReferenceButtonRef={characterReferenceButtonRef} + isCharacterSpecMenuOpen={isCharacterSpecMenuOpen} + isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen} + setGenerateDialog={setGenerateDialog} + setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen} + setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen} + setIsPickingCharacterSpecFromCanvas={ + setIsPickingCharacterSpecFromCanvas + } + setIsPickingCharacterReferenceFromCanvas={ + setIsPickingCharacterReferenceFromCanvas + } + renderEditorPortal={renderEditorPortal} + buildPortalMenuStyle={buildPortalMenuStyle} + onOpenSpecDialog={onOpenSpecDialog} + onRequestUpload={onRequestUpload} + onRememberImageModel={onRememberImageModel} + onSubmit={onSubmitImageGeneration} + /> ) : null} {generateDialog?.mode === 'icon' && generateDialog.composerOpen !== false && iconComposerStyle ? ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (generateDialog.status !== 'generating') { - onSubmitIconSpritesheetGeneration(generateDialog); - } - }} - > -
- - 图标素材规范 - -
- - - - {isIconSpecMenuOpen - ? renderEditorPortal( - - { - setIsPickingIconSpecFromCanvas(true); - setIsIconSpecMenuOpen(false); - }} - > - 从画布中选择 - - { - setIsIconSpecMenuOpen(false); - onOpenSpecDialog('icon'); - }} - > - 新建图标素材规范 - - { - setIsIconSpecMenuOpen(false); - onRequestUpload('icon-spec'); - }} - > - 上传图片 - - , - ) - : null} -
- - - -
-
-
-
- - 素材描述 - -
- {(generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS).map( - (description, index) => ( - - ), - )} -
-
- {generateDialog.status === 'failed' ? ( - - {generateDialog.errorMessage} - - ) : null} -
- - {renderImageOptionButtons({ - dialog: generateDialog, - setGenerateDialog, - includeDimensions: true, - onRememberImageModel, - })} - - {generateDialog.status === 'generating' ? '生成中' : '生成'} - -
-
+ iconSpecButtonRef={iconSpecButtonRef} + isIconSpecMenuOpen={isIconSpecMenuOpen} + setGenerateDialog={setGenerateDialog} + setIsIconSpecMenuOpen={setIsIconSpecMenuOpen} + setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas} + renderEditorPortal={renderEditorPortal} + buildPortalMenuStyle={buildPortalMenuStyle} + onOpenSpecDialog={onOpenSpecDialog} + onRequestUpload={onRequestUpload} + onUpdateIconDescription={onUpdateIconDescription} + onAddIconDescription={onAddIconDescription} + onRememberImageModel={onRememberImageModel} + onSubmit={onSubmitIconSpritesheetGeneration} + /> ) : null} {isPickingCharacterSpecFromCanvas ? ( @@ -1139,377 +278,35 @@ export function ImageCanvasGenerationComposerView({ quickEditPanel.status !== 'generating' && quickEditSourceLayer && quickEditPanelStyle ? ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - onSubmitQuickEdit(); - }} - > -
-
- {`${quickEditSourceLayer.title}参考图`} - {quickEditSourceLayer.title} -
- setQuickEditPanel(null)} - /> -
- - setQuickEditPanel((currentPanel) => - currentPanel - ? { - ...resetFailedPanelStatus(currentPanel), - prompt: event.target.value, - } - : currentPanel, - ) - } - /> -
- - setQuickEditPanel((currentPanel) => - currentPanel - ? { ...currentPanel, size: event.target.value } - : currentPanel, - ) - } - > - {quickEditSizeOptions.map((size) => ( - - ))} - - - setQuickEditPanel((currentPanel) => - currentPanel - ? { ...currentPanel, model: event.target.value } - : currentPanel, - ) - } - > - {quickEditModelOptions.map((option) => ( - - ))} - -
- {quickEditPanel.status === 'failed' ? ( - - {quickEditPanel.errorMessage} - - ) : null} - - 生成 - - + sizeOptions={quickEditSizeOptions} + modelOptions={quickEditModelOptions} + setQuickEditPanel={setQuickEditPanel} + onSubmit={onSubmitQuickEdit} + /> ) : null} {characterAnimationPanel && characterAnimationSourceLayer && characterAnimationPanelStyle ? ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (characterAnimationPanel.status !== 'generating') { - onSubmitCharacterAnimation(); - } - }} - > -
- 角色动画 - setCharacterAnimationPanel(null)} - /> -
- - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...resetFailedPanelStatus(currentPanel), - promptText: event.target.value.slice(0, 4000), - } - : currentPanel, - ) - } - /> -
- {CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( - - ))} -
-
- - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...resetFailedPanelStatus(currentPanel), - resolution: - event.target.value === '720p' ? '720p' : '480p', - } - : currentPanel, - ) - } - > - - - - - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...resetFailedPanelStatus(currentPanel), - ratio: - CHARACTER_ANIMATION_RATIO_OPTIONS.find( - (item) => item.value === event.target.value, - )?.value ?? 'same', - } - : currentPanel, - ) - } - > - {CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => ( - - ))} - - - onUpdateCharacterAnimationDuration(event.target.value) - } - > - {CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( - - ))} - -
-
- - {characterAnimationPanel.promptText.trim() - ? characterAnimationPanel.promptText.trim() - : '动画描述'} - - {characterAnimationPrice}泥点 -
- {characterAnimationPanel.status === 'completed' && - characterAnimationPanel.result ? ( - - 已生成 {characterAnimationPanel.result.frameCount} 帧 - - ) : null} - {characterAnimationPanel.status === 'failed' ? ( - - {characterAnimationPanel.errorMessage} - - ) : null} - - {characterAnimationPanel.status === 'generating' - ? '生成中' - : '生成'} - - + price={characterAnimationPrice} + setCharacterAnimationPanel={setCharacterAnimationPanel} + onUpdateDuration={onUpdateCharacterAnimationDuration} + onSubmit={onSubmitCharacterAnimation} + /> ) : null} - setGenerateDialog(null)} - panelClassName="image-canvas-editor__generate-dialog" - bodyClassName="image-canvas-editor__generate-dialog-body" - > - {generateDialog?.mode === 'edit' ? ( -
{ - event.preventDefault(); - if (generateDialog.status !== 'generating') { - onSubmitImageGeneration(generateDialog); - } - }} - > -
- - setGenerateDialog((currentDialog) => - currentDialog - ? { - ...currentDialog, - prompt: event.target.value, - } - : currentDialog, - ) - } - /> - {generateDialog.status === 'generating' ? ( - - 修改中 - - ) : null} - {generateDialog.status === 'failed' ? ( - - {generateDialog.errorMessage} - - ) : null} - - {generateDialog.status === 'generating' ? '修改中' : '修改'} - -
-
- ) : null} -
+ ); } diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx new file mode 100644 index 00000000..16f1b155 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -0,0 +1,92 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; +import { + IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_NANOBANANA2, +} from './ImageCanvasGenerationModel'; + +function ImageOptionsHarness({ + initialDialog, + onRememberImageModel = vi.fn(), +}: { + initialDialog: GenerateDialogState; + onRememberImageModel?: (model: string) => void; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + + return dialog ? ( +
+ + {dialog.imageModel} + {dialog.aspectRatio} + {dialog.imageSize} + {dialog.status} + {dialog.errorMessage ?? '-'} +
+ ) : null; +} + +describe('ImageCanvasGenerationImageOptionsView', () => { + it('updates dimensions and resets failed dialog state', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '16:9' })); + fireEvent.click(screen.getByRole('button', { name: '2K' })); + + expect(screen.getByLabelText('当前比例').textContent).toBe('16:9'); + expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K'); + expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); + expect(screen.getByLabelText('当前错误').textContent).toBe('-'); + }); + + it('remembers model changes and keeps compatible dimensions', () => { + const rememberImageModel = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); + + expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); + expect(screen.getByLabelText('当前模型').textContent).toBe( + IMAGE_MODEL_GPT_IMAGE_2, + ); + expect(screen.getByLabelText('当前比例').textContent).toBe('9:16'); + expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx new file mode 100644 index 00000000..06136ed8 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx @@ -0,0 +1,165 @@ +import { type Dispatch, type SetStateAction } from 'react'; + +import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; +import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; +import { + EDITOR_IMAGE_DIMENSION_OPTIONS, + EDITOR_IMAGE_MODEL_OPTIONS, + IMAGE_MODEL_NANOBANANA2, +} from './ImageCanvasGenerationModel'; +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; + +type ImageCanvasGenerationImageOptionsViewProps = { + dialog: GenerateDialogState; + setGenerateDialog: Dispatch>; + includeDimensions: boolean; + onRememberImageModel: (model: string) => void; +}; + +function resetFailedDialogStatus(dialog: GenerateDialogState) { + return { + ...dialog, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, + }; +} + +function getImageDimensionOptions(model: string | null | undefined) { + return ( + EDITOR_IMAGE_DIMENSION_OPTIONS[ + (model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2] + ); +} + +function normalizeImageDialogSelection(dialog: GenerateDialogState) { + const model = dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2; + const options = getImageDimensionOptions(model); + const aspectRatios = options.aspectRatios as readonly string[]; + const imageSizes = options.imageSizes as readonly string[]; + return { + model, + aspectRatio: + dialog.aspectRatio && aspectRatios.includes(dialog.aspectRatio) + ? dialog.aspectRatio + : options.aspectRatios[0], + imageSize: + dialog.imageSize && imageSizes.includes(dialog.imageSize) + ? dialog.imageSize + : (options.imageSizes.find((size) => size === '1K') ?? + options.imageSizes[0]), + options, + }; +} + +export function ImageCanvasGenerationImageOptionsView({ + dialog, + setGenerateDialog, + includeDimensions, + onRememberImageModel, +}: ImageCanvasGenerationImageOptionsViewProps) { + const selection = normalizeImageDialogSelection(dialog); + const updateDialog = (patch: Partial) => { + setGenerateDialog((currentDialog) => + currentDialog && currentDialog.mode === dialog.mode + ? { + ...resetFailedDialogStatus(currentDialog), + ...patch, + } + : currentDialog, + ); + }; + + return ( + <> + {includeDimensions ? ( + <> +
+ + 画面比例 + +
+ {selection.options.aspectRatios.map((aspectRatio) => ( + updateDialog({ aspectRatio })} + > + {aspectRatio} + + ))} +
+
+
+ + 大小尺寸 + +
+ {selection.options.imageSizes.map((imageSize) => ( + updateDialog({ imageSize })} + > + {imageSize} + + ))} +
+
+ + ) : null} +
+ + 模型 + +
+ {EDITOR_IMAGE_MODEL_OPTIONS.map((option) => { + const nextOptions = getImageDimensionOptions(option.value); + const nextAspectRatios = nextOptions.aspectRatios as readonly string[]; + const nextImageSizes = nextOptions.imageSizes as readonly string[]; + return ( + { + onRememberImageModel(option.value); + updateDialog({ + imageModel: option.value, + aspectRatio: + dialog.aspectRatio && + nextAspectRatios.includes(dialog.aspectRatio) + ? dialog.aspectRatio + : nextOptions.aspectRatios[0], + imageSize: + dialog.imageSize && + nextImageSizes.includes(dialog.imageSize) + ? dialog.imageSize + : (nextOptions.imageSizes.find((size) => size === '1K') ?? + nextOptions.imageSizes[0]), + }); + }} + > + {option.label} + + ); + })} +
+
+ + ); +} diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx new file mode 100644 index 00000000..a0b025d9 --- /dev/null +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -0,0 +1,181 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView'; + +function createIconDialog( + patch: Partial = {}, +): GenerateDialogState { + return { + mode: 'icon', + prompt: '', + status: 'idle', + iconDescriptions: ['剑', '盾'], + imageModel: 'nanobanana2', + aspectRatio: '1:1', + imageSize: '1K', + ...patch, + }; +} + +function IconComposerHarness({ + initialDialog, + initialMenuOpen = false, + onOpenSpecDialog = vi.fn(), + onRequestUpload = vi.fn(), + onUpdateIconDescription = vi.fn(), + onAddIconDescription = vi.fn(), + onRememberImageModel = vi.fn(), + onSubmit = vi.fn(), +}: { + initialDialog: GenerateDialogState; + initialMenuOpen?: boolean; + onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void; + onRequestUpload?: (target: 'asset' | 'spec-reference' | 'character-spec' | 'character-reference' | 'icon-spec') => void; + onUpdateIconDescription?: (index: number, value: string) => void; + onAddIconDescription?: () => void; + onRememberImageModel?: (model: string) => void; + onSubmit?: (dialog: GenerateDialogState) => void; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + const [isMenuOpen, setIsMenuOpen] = useState(initialMenuOpen); + const [isPickingIconSpec, setIsPickingIconSpec] = useState(false); + const iconSpecButtonRef = createRef(); + + return dialog ? ( +
+ node} + buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })} + onOpenSpecDialog={onOpenSpecDialog} + onRequestUpload={onRequestUpload} + onUpdateIconDescription={onUpdateIconDescription} + onAddIconDescription={onAddIconDescription} + onRememberImageModel={onRememberImageModel} + onSubmit={onSubmit} + /> + {String(isMenuOpen)} + {String(isPickingIconSpec)} + {dialog.imageModel} + {dialog.imageSize} +
+ ) : null; +} + +describe('ImageCanvasIconSpritesheetComposerView', () => { + it('opens icon spec menu and supports each source action', () => { + const openSpecDialog = vi.fn(); + const requestUpload = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '图标素材规范' })); + expect(screen.getByRole('menu', { name: '图标素材规范来源' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + expect(screen.getByLabelText('正在选择图标规范').textContent).toBe('true'); + expect(screen.getByLabelText('当前菜单').textContent).toBe('false'); + + fireEvent.click(screen.getByRole('button', { name: '图标素材规范' })); + fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' })); + expect(openSpecDialog).toHaveBeenCalledWith('icon'); + + fireEvent.click(screen.getByRole('button', { name: '上传' })); + expect(requestUpload).toHaveBeenCalledWith('icon-spec'); + }); + + it('updates descriptions, adds more descriptions and submits', () => { + const updateIconDescription = vi.fn(); + const addIconDescription = vi.fn(); + const submitIconGeneration = vi.fn(); + const dialog = createIconDialog(); + render( + , + ); + + fireEvent.change(screen.getByLabelText('素材描述1'), { + target: { value: '魔法剑' }, + }); + fireEvent.click(screen.getByRole('button', { name: '添加素材描述' })); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + expect(updateIconDescription).toHaveBeenCalledWith(0, '魔法剑'); + expect(addIconDescription).toHaveBeenCalledTimes(1); + expect(submitIconGeneration).toHaveBeenCalledWith(dialog); + }); + + it('keeps image option changes inside the icon dialog', () => { + const rememberImageModel = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); + + expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2'); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); + expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K'); + }); + + it('disables generation controls and renders failure state', () => { + const submitIconGeneration = vi.fn(); + const addIconDescription = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '生成' })); + fireEvent.click(screen.getByRole('button', { name: '添加素材描述' })); + + expect((screen.getByLabelText('素材描述1') as HTMLInputElement).disabled).toBe( + true, + ); + expect(submitIconGeneration).not.toHaveBeenCalled(); + expect(addIconDescription).not.toHaveBeenCalled(); + + rerender( + , + ); + + expect(screen.getByRole('alert').textContent).toContain('生成失败'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx new file mode 100644 index 00000000..e11d4ba2 --- /dev/null +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx @@ -0,0 +1,272 @@ +import { + type CSSProperties, + type Dispatch, + type ReactNode, + type RefObject, + type SetStateAction, +} from 'react'; +import { ImageIcon } from 'lucide-react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; +import { + PlatformFloatingMenu, + PlatformFloatingMenuItem, +} from '../common/PlatformFloatingMenu'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformTextField } from '../common/PlatformTextField'; +import { + DEFAULT_ICON_DESCRIPTIONS, + ICON_DESCRIPTION_LIMIT, +} from './ImageCanvasGenerationModel'; +import type { + GenerateDialogState, + SpecGenerationType, + UploadTarget, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; + +type ImageCanvasIconSpritesheetComposerViewProps = { + dialog: GenerateDialogState; + style: CSSProperties; + iconSpecButtonRef: RefObject; + isIconSpecMenuOpen: boolean; + setGenerateDialog: Dispatch>; + setIsIconSpecMenuOpen: Dispatch>; + setIsPickingIconSpecFromCanvas: Dispatch>; + renderEditorPortal: (node: ReactNode) => ReactNode; + buildPortalMenuStyle: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; + onOpenSpecDialog: (specType: SpecGenerationType) => void; + onRequestUpload: (target: UploadTarget) => void; + onUpdateIconDescription: (index: number, value: string) => void; + onAddIconDescription: () => void; + onRememberImageModel: (model: string) => void; + onSubmit: (dialog: GenerateDialogState) => void; +}; + +export function ImageCanvasIconSpritesheetComposerView({ + dialog, + style, + iconSpecButtonRef, + isIconSpecMenuOpen, + setGenerateDialog, + setIsIconSpecMenuOpen, + setIsPickingIconSpecFromCanvas, + renderEditorPortal, + buildPortalMenuStyle, + onOpenSpecDialog, + onRequestUpload, + onUpdateIconDescription, + onAddIconDescription, + onRememberImageModel, + onSubmit, +}: ImageCanvasIconSpritesheetComposerViewProps) { + const descriptions = dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS; + + const pickIconSpecFromCanvas = () => { + setIsPickingIconSpecFromCanvas(true); + setIsIconSpecMenuOpen(false); + }; + + const openIconSpecDialog = () => { + setIsIconSpecMenuOpen(false); + onOpenSpecDialog('icon'); + }; + + const requestIconSpecUpload = () => { + setIsIconSpecMenuOpen(false); + onRequestUpload('icon-spec'); + }; + + return ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + > +
+ + 图标素材规范 + +
+ + + + {isIconSpecMenuOpen + ? renderEditorPortal( + + + 从画布中选择 + + + 新建图标素材规范 + + + 上传图片 + + , + ) + : null} +
+ + + +
+
+
+
+ + 素材描述 + +
+ {descriptions.map((description, index) => ( + + ))} +
+
+ {dialog.status === 'failed' ? ( + + {dialog.errorMessage} + + ) : null} +
+ + + + {dialog.status === 'generating' ? '生成中' : '生成'} + +
+
+ ); +} diff --git a/src/components/image-editor/ImageCanvasLayerPanelView.tsx b/src/components/image-editor/ImageCanvasLayerPanelView.tsx new file mode 100644 index 00000000..e19e2599 --- /dev/null +++ b/src/components/image-editor/ImageCanvasLayerPanelView.tsx @@ -0,0 +1,90 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import { SidebarMediaItem } from './ImageCanvasEditorPrimitives'; +import type { + CanvasContextMenuState, + CanvasLayer, + ImageContextMenuState, +} from './ImageCanvasEditorTypes'; + +export type ImageCanvasLayerPanelViewProps = { + layers: CanvasLayer[]; + selectedLayerId: string | null; + selectedLayerIds: string[]; + setImageContextMenu: Dispatch>; + setContextMenu: Dispatch>; + selectSingleLayer: (layerId: string | null) => void; + resolveContextMenuPosition: ( + clientX: number, + clientY: number, + menuKind: 'blank' | 'layer', + ) => Omit; + getCanvasPointFromClient: ( + clientX: number, + clientY: number, + ) => { x: number; y: number }; +}; + +export function ImageCanvasLayerPanelView({ + layers, + selectedLayerId, + selectedLayerIds, + setImageContextMenu, + setContextMenu, + selectSingleLayer, + resolveContextMenuPosition, + getCanvasPointFromClient, +}: ImageCanvasLayerPanelViewProps) { + return ( +
+ {layers + .slice() + .sort((left, right) => right.zIndex - left.zIndex) + .map((layer) => ( + selectSingleLayer(layer.id)} + rowClassName="image-canvas-editor__layer-row" + primaryClassName="image-canvas-editor__layer-row-button" + thumbnailClassName="image-canvas-editor__layer-row-thumb" + metaClassName="image-canvas-editor__layer-row-meta" + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (!selectedLayerIds.includes(layer.id)) { + selectSingleLayer(layer.id); + } + const position = resolveContextMenuPosition( + event.clientX, + event.clientY, + 'layer', + ); + setImageContextMenu(null); + setContextMenu({ + kind: 'layer', + layerId: layer.id, + ...position, + canvasPoint: getCanvasPointFromClient( + event.clientX, + event.clientY, + ), + }); + }} + /> + ))} +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 6d9d6c7e..6751cd6a 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -2,7 +2,7 @@ import { UnifiedModal } from '../common/UnifiedModal'; import { formatLayerImageType } from './ImageCanvasGenerationModel'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; -type ImageCanvasMetadataModalViewProps = { +export type ImageCanvasMetadataModalViewProps = { layer: CanvasLayer | null; onClose: () => void; }; diff --git a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx new file mode 100644 index 00000000..f3dc3c4f --- /dev/null +++ b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx @@ -0,0 +1,112 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { StageMinimapModel } from './ImageCanvasInteractionModel'; +import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView'; + +function renderPanelDock( + overrides: Partial[0]> = {}, +) { + const props: Parameters[0] = { + viewport: { x: 0, y: 0, scale: 1 }, + canvasBackgroundColor: '#f8fafc', + canvasBackgroundHexValue: '#f8fafc', + canUndo: true, + canRedo: false, + isZoomMenuOpen: false, + isBackgroundSettingsOpen: false, + activeSidebarPanel: null, + isMinimapOpen: false, + minimapModel: null, + onFitLayers: vi.fn(), + onUndoCanvasChange: vi.fn(), + onRedoCanvasChange: vi.fn(), + onUpdateScaleFromCenter: vi.fn(), + onToggleZoomMenu: vi.fn(), + onCloseZoomMenu: vi.fn(), + onToggleBackgroundSettings: vi.fn(), + onApplyCanvasBackgroundColor: vi.fn(), + onCanvasBackgroundHexChange: vi.fn(), + onToggleSidebarPanel: vi.fn(), + onToggleMinimap: vi.fn(), + onMinimapPointerDown: vi.fn(), + ...overrides, + }; + + render(); + + return props; +} + +describe('ImageCanvasPanelDockView', () => { + it('renders panel dock actions and forwards common controls', () => { + const props = renderPanelDock({ + activeSidebarPanel: 'assets', + isMinimapOpen: true, + minimapModel: { + bounds: { minX: 0, minY: 0, maxX: 100, maxY: 100 }, + scale: 1, + layers: [], + viewport: { left: '10%', top: '12%', width: '20%', height: '24%' }, + } satisfies StageMinimapModel, + }); + + const toolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); + + expect( + within(toolbar).getByRole('button', { name: '打开素材' }).getAttribute( + 'aria-pressed', + ), + ).toBe('true'); + expect( + ( + within(toolbar).getByRole('button', { name: '重做' }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '重置画布视图' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '撤销' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '打开图层' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '切换小地图' })); + + expect(props.onFitLayers).toHaveBeenCalledTimes(1); + expect(props.onUndoCanvasChange).toHaveBeenCalledTimes(1); + expect(props.onToggleSidebarPanel).toHaveBeenCalledWith('layers'); + expect(props.onToggleMinimap).toHaveBeenCalledTimes(1); + }); + + it('renders zoom and background settings with callback wiring', () => { + const props = renderPanelDock({ + isZoomMenuOpen: true, + isBackgroundSettingsOpen: true, + }); + + fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' })); + fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' })); + + expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.5); + expect(props.onFitLayers).toHaveBeenCalledTimes(1); + expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(2); + + const panel = screen.getByRole('dialog', { name: '画布背景设置' }); + + fireEvent.click(within(panel).getByRole('button', { name: '暖灰' })); + fireEvent.change(within(panel).getByLabelText('自定义画布背景色'), { + target: { value: '#ffffff' }, + }); + fireEvent.change(within(panel).getByLabelText('画布背景十六进制颜色'), { + target: { value: '#abc' }, + }); + fireEvent.click(within(panel).getByRole('button', { name: '恢复默认' })); + fireEvent.click(within(panel).getByRole('button', { name: '关闭画布背景设置' })); + + expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea'); + expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff'); + expect(props.onCanvasBackgroundHexChange).toHaveBeenCalledWith('#abc'); + expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f8fafc'); + expect(props.onToggleBackgroundSettings).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx new file mode 100644 index 00000000..b212ab8f --- /dev/null +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -0,0 +1,318 @@ +import { + ImagePlus, + Layers, + Map as MapIcon, + Redo2, + RotateCcw, + Undo2, + X, +} from 'lucide-react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; + +import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu'; +import { PlatformIconButton } from '../common/PlatformIconButton'; +import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import type { StageMinimapModel } from './ImageCanvasInteractionModel'; +import { + CANVAS_BACKGROUND_OPTIONS, + DEFAULT_CANVAS_BACKGROUND_COLOR, + formatPercent, +} from './ImageCanvasEditorModel'; +import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes'; + +type ImageCanvasPanelDockViewProps = { + viewport: CanvasViewport; + canvasBackgroundColor: string; + canvasBackgroundHexValue: string; + canUndo: boolean; + canRedo: boolean; + isZoomMenuOpen: boolean; + isBackgroundSettingsOpen: boolean; + activeSidebarPanel: SidebarPanel | null; + isMinimapOpen: boolean; + minimapModel: StageMinimapModel | null; + onFitLayers: () => void; + onUndoCanvasChange: () => void; + onRedoCanvasChange: () => void; + onUpdateScaleFromCenter: (nextScale: number) => void; + onToggleZoomMenu: () => void; + onCloseZoomMenu: () => void; + onToggleBackgroundSettings: () => void; + onApplyCanvasBackgroundColor: (color: string) => void; + onCanvasBackgroundHexChange: (value: string) => void; + onToggleSidebarPanel: (panel: SidebarPanel) => void; + onToggleMinimap: () => void; + onMinimapPointerDown: (event: ReactPointerEvent) => void; +}; + +export function ImageCanvasPanelDockView({ + viewport, + canvasBackgroundColor, + canvasBackgroundHexValue, + canUndo, + canRedo, + isZoomMenuOpen, + isBackgroundSettingsOpen, + activeSidebarPanel, + isMinimapOpen, + minimapModel, + onFitLayers, + onUndoCanvasChange, + onRedoCanvasChange, + onUpdateScaleFromCenter, + onToggleZoomMenu, + onCloseZoomMenu, + onToggleBackgroundSettings, + onApplyCanvasBackgroundColor, + onCanvasBackgroundHexChange, + onToggleSidebarPanel, + onToggleMinimap, + onMinimapPointerDown, +}: ImageCanvasPanelDockViewProps) { + return ( + <> + onFitLayers()} + /> + +
event.stopPropagation()} + > + + +
+ + {formatPercent(viewport.scale)} + + {isZoomMenuOpen ? ( + + { + onUpdateScaleFromCenter(viewport.scale * 1.16); + onCloseZoomMenu(); + }} + > + 放大 + + { + onUpdateScaleFromCenter(viewport.scale * 0.86); + onCloseZoomMenu(); + }} + > + 缩小 + + { + onFitLayers(); + onCloseZoomMenu(); + }} + > + 显示画布所有元素 + + {[0.5, 1, 2].map((scale) => ( + { + onUpdateScaleFromCenter(scale); + onCloseZoomMenu(); + }} + > + 缩放至{Math.round(scale * 100)}% + + ))} + + ) : null} +
+
+ + } + /> + {isBackgroundSettingsOpen ? ( +
+
+ 画布背景 + +
+
+
+ + +
+ {CANVAS_BACKGROUND_OPTIONS.map((option) => ( + + ))} +
+
+ + +
+
+ ) : null} +
+ onToggleSidebarPanel('assets')} + /> + onToggleSidebarPanel('layers')} + /> + +
+ + {isMinimapOpen && minimapModel ? ( + + ) : null} + + ); +} diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx new file mode 100644 index 00000000..5fc4acea --- /dev/null +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -0,0 +1,117 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + CanvasLayer, + QuickEditPanelState, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView'; + +function createLayer(): CanvasLayer { + return { + id: 'layer-a', + resourceId: 'resource-a', + title: '源图', + src: 'data:image/png;base64,c291cmNl', + x: 0, + y: 0, + width: 320, + height: 180, + originalWidth: 320, + originalHeight: 180, + zIndex: 1, + sourceType: 'uploaded', + }; +} + +function QuickEditPanelHarness({ + initialPanel, + onSubmit = vi.fn(), +}: { + initialPanel: QuickEditPanelState; + onSubmit?: () => void; +}) { + const [panel, setPanel] = useState(initialPanel); + + return panel ? ( +
+ + {panel.prompt} + {panel.size} + {panel.model} + {panel.status} + {panel.errorMessage ?? '-'} +
+ ) : ( + closed + ); +} + +describe('ImageCanvasQuickEditPanelView', () => { + it('updates prompt, size, model and clears failed state', () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText('快速编辑提示词'), { + target: { value: '新提示' }, + }); + fireEvent.change(screen.getByLabelText('快速编辑尺寸'), { + target: { value: '2048x1152' }, + }); + fireEvent.change(screen.getByLabelText('快速编辑模型'), { + target: { value: 'gpt' }, + }); + + expect(screen.getByLabelText('当前提示词').textContent).toBe('新提示'); + expect(screen.getByLabelText('当前尺寸').textContent).toBe('2048x1152'); + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt'); + expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); + expect(screen.getByLabelText('当前错误').textContent).toBe('-'); + }); + + it('submits and closes through its interface', () => { + const submitQuickEdit = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '生成' })); + fireEvent.click(screen.getByRole('button', { name: '关闭快速编辑图片' })); + + expect(submitQuickEdit).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText('面板状态').textContent).toBe('closed'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx new file mode 100644 index 00000000..e5cd0a7c --- /dev/null +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx @@ -0,0 +1,139 @@ +import { type CSSProperties, type Dispatch, type SetStateAction } from 'react'; +import { X } from 'lucide-react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import type { + CanvasLayer, + QuickEditPanelState, +} from './ImageCanvasEditorTypes'; + +type ImageCanvasQuickEditPanelViewProps = { + panel: QuickEditPanelState; + sourceLayer: CanvasLayer; + style: CSSProperties; + sizeOptions: string[]; + modelOptions: Array<{ label: string; value: string }>; + setQuickEditPanel: Dispatch>; + onSubmit: () => void; +}; + +function resetFailedPanelStatus( + panel: T, +) { + return { + ...panel, + status: panel.status === 'failed' ? 'idle' : panel.status, + errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage, + }; +} + +export function ImageCanvasQuickEditPanelView({ + panel, + sourceLayer, + style, + sizeOptions, + modelOptions, + setQuickEditPanel, + onSubmit, +}: ImageCanvasQuickEditPanelViewProps) { + return ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + onSubmit(); + }} + > +
+
+ {`${sourceLayer.title}参考图`} + {sourceLayer.title} +
+ setQuickEditPanel(null)} + /> +
+ + setQuickEditPanel((currentPanel) => + currentPanel + ? { + ...resetFailedPanelStatus(currentPanel), + prompt: event.target.value, + } + : currentPanel, + ) + } + /> +
+ + setQuickEditPanel((currentPanel) => + currentPanel + ? { ...currentPanel, size: event.target.value } + : currentPanel, + ) + } + > + {sizeOptions.map((size) => ( + + ))} + + + setQuickEditPanel((currentPanel) => + currentPanel + ? { ...currentPanel, model: event.target.value } + : currentPanel, + ) + } + > + {modelOptions.map((option) => ( + + ))} + +
+ {panel.status === 'failed' ? ( + + {panel.errorMessage} + + ) : null} + + 生成 + + + ); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx new file mode 100644 index 00000000..fbf3bffc --- /dev/null +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -0,0 +1,105 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView'; + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-1', + resourceId: 'resource-1', + title: '生成主图', + src: 'data:image/png;base64,layer', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +function renderSelectedToolbar( + overrides: Partial[0]> = {}, +) { + const props: Parameters[0] = { + selectedLayer: createLayer(), + selectedToolbarStyle: { left: 12, top: 24 }, + onDeleteSelectedLayer: vi.fn(), + onOpenQuickEditPanel: vi.fn(), + onOpenEditDialog: vi.fn(), + onOpenCharacterAnimationPanel: vi.fn(), + onOpenLayerMetadata: vi.fn(), + ...overrides, + }; + + render(); + + return props; +} + +describe('ImageCanvasSelectedLayerToolbarView', () => { + it('renders common layer actions and forwards callbacks', () => { + const props = renderSelectedToolbar(); + const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' }); + + fireEvent.click(within(toolbar).getByRole('button', { name: '删除图片' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + + expect(props.onDeleteSelectedLayer).toHaveBeenCalledTimes(1); + expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(props.selectedLayer); + }); + + it('renders generated and character actions only when applicable', () => { + const layer = createLayer({ + sourceType: 'generated', + assetKind: 'character', + }); + const props = renderSelectedToolbar({ selectedLayer: layer }); + + fireEvent.click(screen.getByRole('button', { name: '修改图片' })); + fireEvent.click(screen.getByRole('button', { name: '生成动画' })); + fireEvent.click( + screen.getByRole('button', { name: `查看${layer.title}图片信息` }), + ); + + expect(props.onOpenEditDialog).toHaveBeenCalledWith(layer); + expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer); + }); + + it('renders nothing without a selected layer or toolbar position', () => { + const { rerender } = render( + , + ); + + expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); + + rerender( + , + ); + + expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx new file mode 100644 index 00000000..c879a33f --- /dev/null +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -0,0 +1,105 @@ +import { + Copy, + Crop, + Info, + SlidersHorizontal, + Sparkles, + Trash2, + WandSparkles, +} from 'lucide-react'; +import type { CSSProperties } from 'react'; + +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import { isGeneratedLayer } from './ImageCanvasEditorModel'; +import type { CanvasLayer } from './ImageCanvasEditorTypes'; + +type ImageCanvasSelectedLayerToolbarViewProps = { + selectedLayer: CanvasLayer | null; + selectedToolbarStyle: CSSProperties | null; + onDeleteSelectedLayer: () => void; + onOpenQuickEditPanel: (layer: CanvasLayer) => void; + onOpenEditDialog: (layer: CanvasLayer) => void; + onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; + onOpenLayerMetadata: (layer: CanvasLayer) => void; +}; + +const layerToolButtons = [ + { label: '裁剪', icon: Crop }, + { label: '重绘', icon: Sparkles }, + { label: '调整', icon: SlidersHorizontal }, + { label: '复制', icon: Copy }, +]; + +function triggerPlaceholderAction(label: string) { + window.alert(`${label}功能建设中`); +} + +export function ImageCanvasSelectedLayerToolbarView({ + selectedLayer, + selectedToolbarStyle, + onDeleteSelectedLayer, + onOpenQuickEditPanel, + onOpenEditDialog, + onOpenCharacterAnimationPanel, + onOpenLayerMetadata, +}: ImageCanvasSelectedLayerToolbarViewProps) { + if (!selectedLayer || !selectedToolbarStyle) { + return null; + } + + return ( +
event.stopPropagation()} + > + {layerToolButtons.map(({ label, icon: Icon }) => ( + triggerPlaceholderAction(label)} + /> + ))} + + onOpenQuickEditPanel(selectedLayer)} + /> + {isGeneratedLayer(selectedLayer) ? ( + <> + onOpenLayerMetadata(selectedLayer)} + /> + onOpenEditDialog(selectedLayer)} + /> + + ) : null} + {selectedLayer.assetKind === 'character' ? ( + onOpenCharacterAnimationPanel(selectedLayer)} + /> + ) : null} +
+ ); +} diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx new file mode 100644 index 00000000..b8d597fc --- /dev/null +++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx @@ -0,0 +1,275 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView'; +import { + ImageCanvasSidebarView, + type ImageCanvasSidebarViewProps, +} from './ImageCanvasSidebarView'; +import type { + CanvasLayer, + EditorAsset, + EditorAssetFolder, +} from './ImageCanvasEditorTypes'; +import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; + +function createAsset(overrides: Partial = {}): EditorAsset { + return { + id: 'asset-1', + label: '账号素材A', + src: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + ...overrides, + }; +} + +function createFolder( + overrides: Partial = {}, +): EditorAssetFolder { + return { + id: 'project', + label: '项目素材', + collapsed: false, + systemDefault: true, + persisted: true, + ...overrides, + }; +} + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-1', + resourceId: 'resource-1', + title: '图层A', + src: '/creation-type-references/puzzle.webp', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +function createSidebarProps( + overrides: Partial = {}, +): ImageCanvasSidebarViewProps { + const folder = createFolder(); + const asset = createAsset(); + + return { + activeSidebarPanel: 'assets', + assetListRef: createRef(), + assetPointerDragRef: { current: null }, + suppressAssetClickRef: { current: false }, + assets: [asset], + groupedAssets: [{ ...folder, assets: [asset] }], + assetFolders: [folder], + layers: [createLayer()], + selectedLayerId: null, + selectedLayerIds: [], + isAssetSelectionMode: false, + selectedAssetIds: new Set(), + assetMoveDropFolderId: null, + pinnedAssetMoveFolderId: null, + creatingFolder: false, + newFolderName: '', + renamingFolder: null, + renamingAsset: null, + allSelectableAssetsSelected: false, + assetMarquee: null, + setIsAssetSelectionMode: vi.fn(), + setCreatingFolder: vi.fn(), + setNewFolderName: vi.fn(), + setRenamingFolder: vi.fn(), + setRenamingAsset: vi.fn(), + setActiveUploadFolderId: vi.fn(), + setUploadDropTarget: vi.fn(), + setAssetPointerDrag: vi.fn(), + setSelectedAssetIds: vi.fn(), + setImageContextMenu: vi.fn(), + setContextMenu: vi.fn(), + onAssetMarqueePointerDown: vi.fn(), + onAssetMarqueePointerMove: vi.fn(), + onAssetMarqueePointerUp: vi.fn(), + updateAssetMoveDropFolder: vi.fn(), + addUploadedFiles: vi.fn(), + requestUpload: vi.fn(), + moveAssetToFolder: vi.fn(), + commitNewAssetFolder: vi.fn(), + toggleAssetFolder: vi.fn(), + startRenamingFolder: vi.fn(), + commitFolderRename: vi.fn(), + deleteAssetFolder: vi.fn(), + startRenamingAsset: vi.fn(), + commitAssetRename: vi.fn(), + deleteUploadedAsset: vi.fn(), + toggleAssetSelected: vi.fn(), + addAssetLayer: vi.fn(), + toggleAllAssetsSelected: vi.fn(), + deleteSelectedAssets: vi.fn(), + closeAssetSelectionMode: vi.fn(), + groupSelectedLayers: vi.fn(), + selectSingleLayer: vi.fn(), + resolveContextMenuPosition: vi.fn(() => ({ x: 0, y: 0 })), + getCanvasPointFromClient: vi.fn(() => ({ x: 0, y: 0 })), + ...overrides, + }; +} + +function SidebarTabsHarness() { + const chrome = useImageCanvasEditorChrome({ openEditorLoginModal: vi.fn() }); + const asset = createAsset(); + const folder = createFolder(); + const layer = createLayer({ title: '图层B', zIndex: 2 }); + const sidebarProps = createSidebarProps({ + activeSidebarPanel: chrome.activeSidebarPanel, + assets: [asset], + groupedAssets: [{ ...folder, assets: [asset] }], + assetFolders: [folder], + layers: [layer], + selectedLayerId: null, + selectedLayerIds: [], + }); + + return ( +
+ + +
+ ); +} + +describe('ImageCanvasSidebarView', () => { + it('renders the asset library folders and asset actions', () => { + render(); + + const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); + const folder = within(sidebar).getByRole('region', { name: '项目素材' }); + + expect(within(sidebar).getByRole('heading', { name: '素材' })).toBeTruthy(); + expect( + within(folder).getByRole('button', { name: '添加账号素材A' }), + ).toBeTruthy(); + expect( + within(folder).getByRole('button', { name: '上传到项目素材' }), + ).toBeTruthy(); + }); + + it('renders asset editing inputs with sidebar text-field chrome', () => { + const folder = createFolder(); + const asset = createAsset(); + + render( + , + ); + + const folderNameInput = screen.getByLabelText('素材文件夹名称'); + const folderRenameInput = screen.getByLabelText('重命名文件夹项目素材'); + const assetRenameInput = screen.getByLabelText('重命名素材账号素材A'); + + expect(folderNameInput.className).toContain('platform-text-field'); + expect(folderNameInput.className).toContain( + 'image-canvas-editor__folder-create-input', + ); + expect(folderRenameInput.className).toContain('platform-text-field'); + expect(folderRenameInput.className).toContain( + 'image-canvas-editor__folder-rename-input', + ); + expect(assetRenameInput.className).toContain('platform-text-field'); + expect(assetRenameInput.className).toContain( + 'image-canvas-editor__asset-rename-input', + ); + }); + + it('renders the layer panel with grouped and locked layer details', () => { + render( + , + ); + + const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); + + expect(within(sidebar).getByRole('heading', { name: '图层' })).toBeTruthy(); + expect( + within(sidebar).getByRole('button', { name: '选择图层图层A' }), + ).toBeTruthy(); + expect( + within(sidebar).getByText('320 x 240 · 已打组 · 已锁定'), + ).toBeTruthy(); + }); + + it('keeps sidebar tab switching and repeated-click closing behavior', () => { + render(); + + expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '打开图层' })); + + expect(screen.getByRole('heading', { name: '图层' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '选择图层图层B' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '打开图层' })); + + expect( + screen.queryByRole('complementary', { name: '图片资源栏' }), + ).toBeNull(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx index 155ea17b..922e7602 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.tsx @@ -1,16 +1,7 @@ import { - Check, CheckSquare, - ChevronDown, - ChevronRight, - Folder, FolderPlus, - ImagePlus, - Pencil, - PencilLine, Square, - Trash2, - X, } from 'lucide-react'; import type { Dispatch, @@ -19,19 +10,12 @@ import type { SetStateAction, } from 'react'; -import { PlatformActionButton } from '../common/PlatformActionButton'; -import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; -import { PlatformTextField } from '../common/PlatformTextField'; import { - EditorIconButton, - SidebarMediaItem, -} from './ImageCanvasEditorPrimitives'; -import { - ASSET_DRAG_MIME_TYPE, - clamp, - getDraggedAssetId, - hasDataTransferType, -} from './ImageCanvasEditorModel'; + ImageCanvasAssetLibraryPanelView, + type GroupedEditorAssetFolder, + type UploadFilesOptions, +} from './ImageCanvasAssetLibraryPanelView'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { AssetMarqueeState, AssetPointerDragState, @@ -43,18 +27,11 @@ import type { SidebarPanel, UploadTarget, } from './ImageCanvasEditorTypes'; +import { ImageCanvasLayerPanelView } from './ImageCanvasLayerPanelView'; -export type GroupedEditorAssetFolder = EditorAssetFolder & { - assets: EditorAsset[]; -}; +export type { GroupedEditorAssetFolder, UploadFilesOptions }; -type UploadFilesOptions = { - folderId?: string; - canvasPoint?: { x: number; y: number }; - addToCanvas?: boolean; -}; - -type ImageCanvasSidebarViewProps = { +export type ImageCanvasSidebarViewProps = { activeSidebarPanel: SidebarPanel | null; assetListRef: RefObject; assetPointerDragRef: { current: AssetPointerDragState | null }; @@ -234,591 +211,62 @@ export function ImageCanvasSidebarView({
{activeSidebarPanel === 'assets' ? ( -
- {pinnedAssetMoveFolderId ? ( - - ) : null} - {creatingFolder ? ( -
{ - event.preventDefault(); - void commitNewAssetFolder(); - }} - > - setNewFolderName(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - setCreatingFolder(false); - setNewFolderName(''); - } - }} - /> - - { - setCreatingFolder(false); - setNewFolderName(''); - }} - /> - - ) : null} - {groupedAssets.map((folder) => ( -
{ - if ( - hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) - ) { - event.preventDefault(); - event.stopPropagation(); - setUploadDropTarget(null); - updateAssetMoveDropFolder(folder.id); - event.dataTransfer.dropEffect = 'move'; - return; - } - if (hasDataTransferType(event.dataTransfer, 'Files')) { - event.preventDefault(); - event.stopPropagation(); - setUploadDropTarget('assets'); - event.dataTransfer.dropEffect = 'copy'; - } - }} - onDrop={(event) => { - const movingAssetId = getDraggedAssetId(event.dataTransfer); - if (movingAssetId) { - event.preventDefault(); - event.stopPropagation(); - setUploadDropTarget(null); - updateAssetMoveDropFolder(null); - moveAssetToFolder(movingAssetId, folder.id); - return; - } - if (!event.dataTransfer.files.length) { - return; - } - event.preventDefault(); - event.stopPropagation(); - setUploadDropTarget(null); - updateAssetMoveDropFolder(null); - addUploadedFiles(event.dataTransfer.files, { - folderId: folder.id, - }); - }} - > -
- toggleAssetFolder(folder.id)} - /> - - {renamingFolder?.folderId === folder.id ? ( - - setRenamingFolder({ - folderId: folder.id, - value: event.target.value, - }) - } - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - commitFolderRename(folder); - } - if (event.key === 'Escape') { - event.preventDefault(); - setRenamingFolder(null); - } - }} - /> - ) : ( - {folder.label} - )} - {folder.assets.length} - {renamingFolder?.folderId === folder.id ? ( - <> - commitFolderRename(folder)} - /> - setRenamingFolder(null)} - /> - - ) : ( - startRenamingFolder(folder)} - /> - )} - {!folder.systemDefault ? ( - deleteAssetFolder(folder)} - /> - ) : null} - { - setActiveUploadFolderId(folder.id); - requestUpload('asset'); - }} - /> -
- -
- ))} - {isAssetSelectionMode ? ( - - - {allSelectableAssetsSelected ? ( - - ) : ( - - )} - {selectedAssetIds.size > 0 - ? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}` - : '全选'} - - - - 删除 - - - 取消 - - - ) : null} - {assetMarquee ? ( - + ) : ( -
- {layers - .slice() - .sort((left, right) => right.zIndex - left.zIndex) - .map((layer) => ( - selectSingleLayer(layer.id)} - rowClassName="image-canvas-editor__layer-row" - primaryClassName="image-canvas-editor__layer-row-button" - thumbnailClassName="image-canvas-editor__layer-row-thumb" - metaClassName="image-canvas-editor__layer-row-meta" - onContextMenu={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (!selectedLayerIds.includes(layer.id)) { - selectSingleLayer(layer.id); - } - const position = resolveContextMenuPosition( - event.clientX, - event.clientY, - 'layer', - ); - setImageContextMenu(null); - setContextMenu({ - kind: 'layer', - layerId: layer.id, - ...position, - canvasPoint: getCanvasPointFromClient( - event.clientX, - event.clientY, - ), - }); - }} - /> - ))} -
+ )} ); diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx new file mode 100644 index 00000000..f2af6a66 --- /dev/null +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -0,0 +1,166 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + GenerateDialogState, + SpecFormValues, + UploadTarget, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView'; + +function createSpecDialog( + patch: Partial = {}, +): GenerateDialogState { + return { + mode: 'spec', + prompt: '', + status: 'idle', + specType: 'character', + specValues: { + playSetting: 'RPG玩法', + artStyle: '像素风', + bodyRatio: '3', + characterView: '右向斜侧身站姿', + customPrompt: '', + }, + ...patch, + }; +} + +function renderPanel({ + dialog, + onUpdateSpecFormValue = vi.fn(), + onRequestUpload = vi.fn(), + onSubmit = vi.fn(), +}: { + dialog: GenerateDialogState; + onUpdateSpecFormValue?: (key: keyof SpecFormValues, value: string) => void; + onRequestUpload?: (target: UploadTarget) => void; + onSubmit?: (dialog: GenerateDialogState) => void; +}) { + render( + , + ); +} + +describe('ImageCanvasSpecGenerationPanelView', () => { + it('renders character spec fields and forwards updates', () => { + const updateSpecFormValue = vi.fn(); + renderPanel({ + dialog: createSpecDialog(), + onUpdateSpecFormValue: updateSpecFormValue, + }); + + fireEvent.change(screen.getByLabelText('玩法设定'), { + target: { value: '战棋玩法' }, + }); + fireEvent.change(screen.getByLabelText('美术风格'), { + target: { value: '水彩' }, + }); + fireEvent.change(screen.getByLabelText('头身比'), { + target: { value: '5' }, + }); + fireEvent.change(screen.getByLabelText('角色视角'), { + target: { value: '左向三分之二侧身站姿' }, + }); + + expect(updateSpecFormValue).toHaveBeenCalledWith('playSetting', '战棋玩法'); + expect(updateSpecFormValue).toHaveBeenCalledWith('artStyle', '水彩'); + expect(updateSpecFormValue).toHaveBeenCalledWith('bodyRatio', '5'); + expect(updateSpecFormValue).toHaveBeenCalledWith( + 'characterView', + '左向三分之二侧身站姿', + ); + }); + + it('renders custom prompt and hides reference upload for icon specs', () => { + const updateSpecFormValue = vi.fn(); + renderPanel({ + dialog: createSpecDialog({ + specType: 'custom', + specValues: { + playSetting: '', + artStyle: '', + bodyRatio: '3', + characterView: '', + customPrompt: '自定义提示', + }, + }), + onUpdateSpecFormValue: updateSpecFormValue, + }); + + fireEvent.change(screen.getByLabelText('自定义规范提示词'), { + target: { value: '新的规范提示' }, + }); + + expect(updateSpecFormValue).toHaveBeenCalledWith( + 'customPrompt', + '新的规范提示', + ); + + cleanup(); + renderPanel({ dialog: createSpecDialog({ specType: 'icon' }) }); + + expect(screen.queryByText('添加参考图')).toBeNull(); + }); + + it('requests reference upload and submits while idle', () => { + const requestUpload = vi.fn(); + const submitSpec = vi.fn(); + const dialog = createSpecDialog(); + renderPanel({ + dialog, + onRequestUpload: requestUpload, + onSubmit: submitSpec, + }); + + fireEvent.click(screen.getByRole('button', { name: '添加参考图' })); + fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); + + expect(requestUpload).toHaveBeenCalledWith('spec-reference'); + expect(submitSpec).toHaveBeenCalledWith(dialog); + }); + + it('disables controls while generating and renders failure state', () => { + const submitSpec = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); + + expect((screen.getByLabelText('玩法设定') as HTMLInputElement).disabled).toBe( + true, + ); + expect(submitSpec).not.toHaveBeenCalled(); + + rerender( + , + ); + + expect(screen.getByRole('alert').textContent).toContain('生成失败'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx new file mode 100644 index 00000000..5d2aeb72 --- /dev/null +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -0,0 +1,223 @@ +import { type CSSProperties } from 'react'; +import { ImagePlus } from 'lucide-react'; + +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { + PlatformSelectField, + PlatformTextField, +} from '../common/PlatformTextField'; +import { + CHARACTER_SPEC_VIEW_OPTIONS, + SPEC_GENERATION_COST, +} from './ImageCanvasGenerationModel'; +import type { + GenerateDialogState, + SpecFormValues, + UploadTarget, +} from './ImageCanvasEditorTypes'; + +type ImageCanvasSpecGenerationPanelViewProps = { + dialog: GenerateDialogState; + style: CSSProperties; + onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void; + onRequestUpload: (target: UploadTarget) => void; + onSubmit: (dialog: GenerateDialogState) => void; +}; + +export function ImageCanvasSpecGenerationPanelView({ + dialog, + style, + onUpdateSpecFormValue, + onRequestUpload, + onSubmit, +}: ImageCanvasSpecGenerationPanelViewProps) { + return ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + > +
+ {dialog.specType === 'custom' ? ( + + ) : ( + <> + + + {dialog.specType === 'character' ? ( + <> + + + + ) : null} + + )} + {dialog.specType !== 'icon' ? ( +
+ + 参考图 + + +
+ ) : null} +
+ {dialog.status === 'failed' ? ( + + {dialog.errorMessage} + + ) : null} +
+ + {dialog.status === 'generating' + ? '生成中' + : `消耗${SPEC_GENERATION_COST}泥点 · 生成`} + +
+
+ ); +} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 3de2400c..1e04f753 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -1,28 +1,3 @@ -import { - Braces, - ChevronDown, - ClipboardList, - Copy, - Crop, - Download, - Hand, - ImageIcon, - ImagePlus, - Info, - Layers, - Map as MapIcon, - MousePointer2, - Redo2, - RotateCcw, - Shapes, - SlidersHorizontal, - Sparkles, - Trash2, - Type, - Undo2, - WandSparkles, - X, -} from 'lucide-react'; import type { CSSProperties, DragEvent as ReactDragEvent, @@ -32,28 +7,12 @@ import type { RefObject, } from 'react'; -import { - PlatformFloatingMenu, - PlatformFloatingMenuItem, -} from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; -import { PlatformPillBadge } from '../common/PlatformPillBadge'; -import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; -import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; +import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView'; +import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView'; +import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView'; +import { ImageCanvasWorldView } from './ImageCanvasWorldView'; import type { StageMinimapModel } from './ImageCanvasInteractionModel'; -import { - CANVAS_BACKGROUND_OPTIONS, - CANVAS_WORLD_SIZE, - DEFAULT_CANVAS_BACKGROUND_COLOR, - formatPercent, - isGeneratedLayer, -} from './ImageCanvasEditorModel'; -import { - getGenerationFrameAriaLabel, - getGenerationFrameLabel, - getLayerKindLabel, -} from './ImageCanvasGenerationModel'; import type { CanvasClipboard, CanvasContextMenuState, @@ -69,7 +28,7 @@ import type { SnapGuide, } from './ImageCanvasEditorTypes'; -type ImageCanvasStageViewProps = { +export type ImageCanvasStageViewProps = { canvasViewportRef: RefObject; specToolWrapRef: RefObject; isPanning: boolean; @@ -163,34 +122,6 @@ type ImageCanvasStageViewProps = { onSwitchTool: (tool: CanvasTool) => void; }; -const layerToolButtons = [ - { label: '裁剪', icon: Crop }, - { label: '重绘', icon: Sparkles }, - { label: '调整', icon: SlidersHorizontal }, - { label: '复制', icon: Copy }, -]; - -const canvasTools: Array<{ - id: CanvasTool; - label: string; - icon: typeof MousePointer2; -}> = [ - { id: 'select', label: '选择工具', icon: MousePointer2 }, - { id: 'hand', label: '抓手工具', icon: Hand }, - { id: 'upload', label: '上传工具', icon: ImagePlus }, - { id: 'generate', label: '生成工具', icon: WandSparkles }, - { id: 'spec', label: '生成规范', icon: ClipboardList }, - { id: 'character', label: '生成角色形象', icon: Sparkles }, - { id: 'icon', label: '生成图标素材', icon: ImageIcon }, - { id: 'text', label: '文字工具', icon: Type }, - { id: 'shape', label: '形状标注工具', icon: Shapes }, - { id: 'export', label: '导出工具', icon: Download }, -]; - -function triggerPlaceholderAction(label: string) { - window.alert(`${label}功能建设中`); -} - export function ImageCanvasStageView({ canvasViewportRef, specToolWrapRef, @@ -296,807 +227,96 @@ export function ImageCanvasStageView({ 松开即可添加
) : null} -
- {snapGuide?.vertical !== undefined ? ( -
- ) : null} - {snapGuide?.horizontal !== undefined ? ( -
- ) : null} - - {layers - .slice() - .filter((layer) => !layer.hidden) - .sort((left, right) => left.zIndex - right.zIndex) - .map((layer) => { - const isSelected = selectedLayerIds.includes(layer.id); - const isHovered = hoveredLayerId === layer.id; - const kindLabel = getLayerKindLabel(layer); - const layerGeneratingLabel = - generateDialog?.mode === 'edit' && - generateDialog.status === 'generating' && - generateDialog.sourceLayerId === layer.id - ? '修改中' - : quickEditPanel?.status === 'generating' && - quickEditPanel.sourceLayerId === layer.id - ? '生成中' - : null; - return ( - - ); - })} - {canvasMarquee ? ( - - - {selectedLayer && selectedToolbarStyle ? ( -
event.stopPropagation()} - > - {layerToolButtons.map(({ label, icon: Icon }) => ( - triggerPlaceholderAction(label)} - /> - ))} - - onOpenQuickEditPanel(selectedLayer)} - /> - {isGeneratedLayer(selectedLayer) ? ( - <> - onOpenLayerMetadata(selectedLayer)} - /> - onOpenEditDialog(selectedLayer)} - /> - - ) : null} - {selectedLayer.assetKind === 'character' ? ( - onOpenCharacterAnimationPanel(selectedLayer)} - /> - ) : null} -
- ) : null} - - {contextMenu ? ( -
event.preventDefault()} - onPointerDown={(event) => event.stopPropagation()} - > - {contextMenu.kind === 'blank' ? ( - <> - - - - - - ) : ( - <> - - - - -
- - - - -
- - - - -
- - - -
- {imageContextMenuLayer ? ( - <> - - - {imageContextMenuLayer.assetKind === 'character' ? ( - - ) : null} -
- - ) : null} - - - )} -
- ) : null} - - onFitLayers()} + -
event.stopPropagation()} - > - - -
- - {formatPercent(viewport.scale)} - - {isZoomMenuOpen ? ( - - { - onUpdateScaleFromCenter(viewport.scale * 1.16); - onCloseZoomMenu(); - }} - > - 放大 - - { - onUpdateScaleFromCenter(viewport.scale * 0.86); - onCloseZoomMenu(); - }} - > - 缩小 - - { - onFitLayers(); - onCloseZoomMenu(); - }} - > - 显示画布所有元素 - - {[0.5, 1, 2].map((scale) => ( - { - onUpdateScaleFromCenter(scale); - onCloseZoomMenu(); - }} - > - 缩放至{Math.round(scale * 100)}% - - ))} - - ) : null} -
-
- - } - /> - {isBackgroundSettingsOpen ? ( -
-
- 画布背景 - -
-
-
- - -
- {CANVAS_BACKGROUND_OPTIONS.map((option) => ( - - ))} -
-
- - -
-
- ) : null} -
- onToggleSidebarPanel('assets')} - /> - onToggleSidebarPanel('layers')} - /> - -
+ - {isMinimapOpen && minimapModel ? ( - - ) : null} + -
event.stopPropagation()} - > - {canvasTools.map(({ id, label, icon: Icon }) => - id === 'spec' ? ( - - onSwitchTool(id)} - /> - - ) : ( - onSwitchTool(id)} - /> - ), - )} -
+ - {imageContextMenu && imageContextMenuLayer && !contextMenu ? ( -
event.stopPropagation()} - > - - onOpenQuickEditPanel(imageContextMenuLayer)} - > - 快速编辑 - - { - onOpenLayerMetadata(imageContextMenuLayer); - onCloseImageContextMenu(); - }} - > - 查看图片信息 - - {imageContextMenuLayer.assetKind === 'character' ? ( - onOpenCharacterAnimationPanel(imageContextMenuLayer)} - > - 生成动画 - - ) : null} - onDeleteLayerById(imageContextMenuLayer.id)} - > - 删除图片 - - -
- ) : null} + {children}
diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 9eb9edc8..6fbf47dd 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -6,7 +6,7 @@ import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import type { AssetExportStatus } from './useImageCanvasAssetExportWorkflow'; -type ImageCanvasTopbarViewProps = { +export type ImageCanvasTopbarViewProps = { projectId: string | null; projectTitle: string; projectRenameValue: string; diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx new file mode 100644 index 00000000..c030e766 --- /dev/null +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -0,0 +1,221 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + CanvasGenerationDialogState, + CanvasLayer, +} from './ImageCanvasEditorTypes'; +import { ImageCanvasWorldView } from './ImageCanvasWorldView'; + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-1', + resourceId: 'resource-1', + title: '角色主图', + src: 'data:image/png;base64,layer', + x: 120, + y: 160, + width: 320, + height: 240, + originalWidth: 640, + originalHeight: 480, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +function createGenerationDialog( + overrides: Partial = {}, +): CanvasGenerationDialogState { + return { + id: 'dialog-1', + mode: 'generate', + prompt: '生成一张图', + status: 'idle', + placeholder: { + x: 480, + y: 320, + width: 360, + height: 240, + originalWidth: 1024, + originalHeight: 768, + }, + ...overrides, + }; +} + +function renderWorldView( + overrides: Partial[0]> = {}, +) { + const props: Parameters[0] = { + viewport: { x: 10, y: 20, scale: 1.5 }, + snapGuide: null, + layers: [createLayer()], + selectedLayerIds: [], + hoveredLayerId: null, + canvasMarquee: null, + canvasGenerationDialogs: [], + generateDialog: null, + quickEditPanel: null, + generationComposerStyle: null, + onLayerPointerDown: vi.fn(), + onLayerClick: vi.fn(), + onLayerContextMenu: vi.fn(), + onLayerMouseEnter: vi.fn(), + onLayerMouseLeave: vi.fn(), + onOpenLayerMetadata: vi.fn(), + onGenerationFramePointerDown: vi.fn(), + onActivateGenerationDialog: vi.fn(), + ...overrides, + }; + + const result = render(); + + return { props, ...result }; +} + +describe('ImageCanvasWorldView', () => { + it('renders visible layers and filters hidden layers', () => { + const visibleLayer = createLayer({ title: '可见图层', zIndex: 2 }); + const hiddenLayer = createLayer({ + id: 'layer-hidden', + resourceId: 'resource-hidden', + title: '隐藏图层', + hidden: true, + }); + + renderWorldView({ + layers: [hiddenLayer, visibleLayer], + selectedLayerIds: [visibleLayer.id], + hoveredLayerId: visibleLayer.id, + quickEditPanel: { + sourceLayerId: visibleLayer.id, + prompt: '快速编辑', + size: '1024x1024', + model: 'gpt-image-2', + status: 'generating', + }, + }); + + const visibleButton = screen.getByRole('button', { name: '选择可见图层' }); + + expect(screen.queryByRole('button', { name: '选择隐藏图层' })).toBeNull(); + expect(visibleButton.className).toContain( + 'image-canvas-editor__layer--selected', + ); + expect(visibleButton.className).toContain( + 'image-canvas-editor__layer--hovered', + ); + expect(visibleButton.className).toContain( + 'image-canvas-editor__layer--generating', + ); + expect(visibleButton.style.left).toBe('120px'); + expect(visibleButton.style.top).toBe('160px'); + expect(screen.getByText('640 x 480 px')).toBeTruthy(); + expect(screen.getByRole('status', { name: '' }).textContent).toBe('生成中'); + }); + + it('forwards layer pointer, hover, context menu and metadata actions', () => { + const layer = createLayer({ assetKind: 'character' }); + const { props } = renderWorldView({ layers: [layer] }); + + const layerButton = screen.getByRole('button', { name: '选择角色主图' }); + const metadataButton = within(layerButton).getByRole('button', { + name: '查看角色主图图片信息', + }); + + fireEvent.pointerDown(layerButton); + fireEvent.click(layerButton); + fireEvent.contextMenu(layerButton); + fireEvent.mouseEnter(layerButton); + fireEvent.mouseLeave(layerButton); + fireEvent.click(metadataButton); + + expect(props.onLayerPointerDown).toHaveBeenCalledWith( + expect.any(Object), + layer, + ); + expect(props.onLayerClick).toHaveBeenCalledWith(expect.any(Object), layer); + expect(props.onLayerContextMenu).toHaveBeenCalledWith( + expect.any(Object), + layer, + ); + expect(props.onLayerMouseEnter).toHaveBeenCalledWith(layer.id); + expect(props.onLayerMouseLeave).toHaveBeenCalledWith(layer.id); + expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer); + expect(screen.getByText('角色')).toBeTruthy(); + }); + + it('renders snap guides, marquee and floating generation status', () => { + renderWorldView({ + snapGuide: { vertical: 288, horizontal: 344 }, + canvasMarquee: { + pointerId: 1, + startX: 40, + startY: 50, + currentX: 190, + currentY: 230, + }, + generateDialog: { + id: 'dialog-active', + mode: 'generate', + prompt: '生成中', + status: 'generating', + }, + generationComposerStyle: { left: 12, top: 24 }, + }); + + expect( + screen.getByTestId('image-canvas-editor-snap-guide-vertical').style.left, + ).toBe('288px'); + expect( + screen.getByTestId('image-canvas-editor-snap-guide-horizontal').style.top, + ).toBe('344px'); + expect(screen.getByText('生成中')).toBeTruthy(); + + const world = document.querySelector('.image-canvas-editor__world'); + const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee'); + + expect(world?.getAttribute('style')).toContain( + 'transform: translate(10px, 20px) scale(1.5)', + ); + expect((marquee as HTMLElement | null)?.style.width).toBe('100px'); + expect((marquee as HTMLElement | null)?.style.height).toBe('120px'); + }); + + it('renders generation placeholders and forwards frame actions', () => { + const dialog = createGenerationDialog({ + mode: 'icon', + status: 'generating', + }); + const { props } = renderWorldView({ + canvasGenerationDialogs: [ + dialog, + createGenerationDialog({ + id: 'dialog-without-placeholder', + placeholder: undefined, + }), + ], + }); + + const frame = screen.getByRole('button', { name: '图标素材生成占位图' }); + + expect(within(frame).getByText('Icon Generator')).toBeTruthy(); + expect(within(frame).getByText('图标')).toBeTruthy(); + expect(within(frame).getByText('1024 x 768')).toBeTruthy(); + expect(within(frame).getByRole('status').textContent).toBe('生成中'); + + fireEvent.pointerDown(frame); + fireEvent.doubleClick(frame); + + expect(props.onGenerationFramePointerDown).toHaveBeenCalledWith( + expect.any(Object), + dialog, + ); + expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog); + expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx new file mode 100644 index 00000000..69b8a999 --- /dev/null +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -0,0 +1,300 @@ +import { Braces, ImageIcon } from 'lucide-react'; +import type { + CSSProperties, + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from 'react'; + +import { PlatformIconButton } from '../common/PlatformIconButton'; +import { PlatformPillBadge } from '../common/PlatformPillBadge'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { CANVAS_WORLD_SIZE } from './ImageCanvasEditorModel'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasMarqueeState, + CanvasViewport, + GenerateDialogState, + QuickEditPanelState, + SnapGuide, +} from './ImageCanvasEditorTypes'; +import { + getGenerationFrameAriaLabel, + getGenerationFrameLabel, + getLayerKindLabel, +} from './ImageCanvasGenerationModel'; + +export type ImageCanvasWorldViewProps = { + viewport: CanvasViewport; + snapGuide: SnapGuide | null; + layers: CanvasLayer[]; + selectedLayerIds: string[]; + hoveredLayerId: string | null; + canvasMarquee: CanvasMarqueeState | null; + canvasGenerationDialogs: CanvasGenerationDialogState[]; + generateDialog: GenerateDialogState | null; + quickEditPanel: QuickEditPanelState | null; + generationComposerStyle: CSSProperties | null; + onLayerPointerDown: ( + event: ReactPointerEvent, + layer: CanvasLayer, + ) => void; + onLayerClick: ( + event: ReactMouseEvent, + layer: CanvasLayer, + ) => void; + onLayerContextMenu: ( + event: ReactMouseEvent, + layer: CanvasLayer, + ) => void; + onLayerMouseEnter: (layerId: string) => void; + onLayerMouseLeave: (layerId: string) => void; + onOpenLayerMetadata: (layer: CanvasLayer) => void; + onGenerationFramePointerDown: ( + event: ReactPointerEvent, + dialog: CanvasGenerationDialogState, + ) => void; + onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; +}; + +export function ImageCanvasWorldView({ + viewport, + snapGuide, + layers, + selectedLayerIds, + hoveredLayerId, + canvasMarquee, + canvasGenerationDialogs, + generateDialog, + quickEditPanel, + generationComposerStyle, + onLayerPointerDown, + onLayerClick, + onLayerContextMenu, + onLayerMouseEnter, + onLayerMouseLeave, + onOpenLayerMetadata, + onGenerationFramePointerDown, + onActivateGenerationDialog, +}: ImageCanvasWorldViewProps) { + return ( +
+ {snapGuide?.vertical !== undefined ? ( +
+ ) : null} + {snapGuide?.horizontal !== undefined ? ( +
+ ) : null} + + {layers + .slice() + .filter((layer) => !layer.hidden) + .sort((left, right) => left.zIndex - right.zIndex) + .map((layer) => { + const isSelected = selectedLayerIds.includes(layer.id); + const isHovered = hoveredLayerId === layer.id; + const kindLabel = getLayerKindLabel(layer); + const layerGeneratingLabel = + generateDialog?.mode === 'edit' && + generateDialog.status === 'generating' && + generateDialog.sourceLayerId === layer.id + ? '修改中' + : quickEditPanel?.status === 'generating' && + quickEditPanel.sourceLayerId === layer.id + ? '生成中' + : null; + return ( + + ); + })} + {canvasMarquee ? ( + + ); +}