拆分编辑器前端画布视图
抽出素材栏、生成器、舞台工具栏和画布世界视图 补充各拆分视图的聚焦测试 更新 TRACKING.md 记录第三十四阶段验证
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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<string>;
|
||||
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<SetStateAction<string>>;
|
||||
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
|
||||
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
|
||||
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
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 (
|
||||
<section
|
||||
className={[
|
||||
'image-canvas-editor__asset-folder',
|
||||
assetMoveDropFolderId === folder.id
|
||||
? 'image-canvas-editor__asset-folder--move-target'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-label={folder.label}
|
||||
data-asset-folder-id={folder.id}
|
||||
onDragOver={(event) => {
|
||||
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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-header"
|
||||
data-asset-folder-header-id={folder.id}
|
||||
>
|
||||
<EditorIconButton
|
||||
label={`${folder.collapsed ? '展开' : '折叠'}${folder.label}`}
|
||||
title={folder.collapsed ? '展开' : '折叠'}
|
||||
icon={folder.collapsed ? ChevronRight : ChevronDown}
|
||||
expanded={!folder.collapsed}
|
||||
onClick={() => toggleAssetFolder(folder.id)}
|
||||
/>
|
||||
<Folder className="h-4 w-4" />
|
||||
{renamingFolder?.folderId === folder.id ? (
|
||||
<PlatformTextField
|
||||
aria-label={`重命名文件夹${folder.label}`}
|
||||
value={renamingFolder.value}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__folder-rename-input"
|
||||
onChange={(event) =>
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{folder.label}</span>
|
||||
)}
|
||||
<span>{folder.assets.length}</span>
|
||||
{renamingFolder?.folderId === folder.id ? (
|
||||
<>
|
||||
<EditorIconButton
|
||||
label={`保存文件夹${folder.label}名称`}
|
||||
title="保存"
|
||||
icon={Check}
|
||||
onClick={() => commitFolderRename(folder)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label={`取消重命名文件夹${folder.label}`}
|
||||
title="取消"
|
||||
icon={X}
|
||||
onClick={() => setRenamingFolder(null)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
label={`重命名文件夹${folder.label}`}
|
||||
title="重命名"
|
||||
icon={PencilLine}
|
||||
onClick={() => startRenamingFolder(folder)}
|
||||
/>
|
||||
)}
|
||||
{!folder.systemDefault ? (
|
||||
<EditorIconButton
|
||||
label={`删除文件夹${folder.label}`}
|
||||
title="删除"
|
||||
icon={Trash2}
|
||||
onClick={() => deleteAssetFolder(folder)}
|
||||
/>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label={`上传到${folder.label}`}
|
||||
title="上传"
|
||||
icon={ImagePlus}
|
||||
onClick={() => {
|
||||
setActiveUploadFolderId(folder.id);
|
||||
requestUpload('asset');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-list"
|
||||
hidden={folder.collapsed}
|
||||
>
|
||||
{folder.assets.map((asset) => (
|
||||
<ImageCanvasAssetRowView
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
assetPointerDragRef={assetPointerDragRef}
|
||||
suppressAssetClickRef={suppressAssetClickRef}
|
||||
isAssetSelectionMode={isAssetSelectionMode}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
renamingAsset={renamingAsset}
|
||||
setRenamingAsset={setRenamingAsset}
|
||||
setUploadDropTarget={setUploadDropTarget}
|
||||
setAssetPointerDrag={setAssetPointerDrag}
|
||||
setSelectedAssetIds={setSelectedAssetIds}
|
||||
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||
addUploadedFiles={addUploadedFiles}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
assetPointerDragRef: { current: AssetPointerDragState | null };
|
||||
suppressAssetClickRef: { current: boolean };
|
||||
groupedAssets: GroupedEditorAssetFolder[];
|
||||
assetFolders: EditorAssetFolder[];
|
||||
isAssetSelectionMode: boolean;
|
||||
selectedAssetIds: Set<string>;
|
||||
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<SetStateAction<boolean>>;
|
||||
setNewFolderName: Dispatch<SetStateAction<string>>;
|
||||
setRenamingFolder: Dispatch<
|
||||
SetStateAction<{ folderId: string; value: string } | null>
|
||||
>;
|
||||
setRenamingAsset: Dispatch<
|
||||
SetStateAction<{ assetId: string; value: string } | null>
|
||||
>;
|
||||
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
|
||||
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
|
||||
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
|
||||
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
onAssetMarqueePointerDown: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
onAssetMarqueePointerMove: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
onAssetMarqueePointerUp: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => 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<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;
|
||||
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 (
|
||||
<div
|
||||
ref={assetListRef}
|
||||
className="image-canvas-editor__asset-list"
|
||||
onPointerDown={onAssetMarqueePointerDown}
|
||||
onPointerMove={onAssetMarqueePointerMove}
|
||||
onPointerUp={onAssetMarqueePointerUp}
|
||||
onPointerCancel={onAssetMarqueePointerUp}
|
||||
>
|
||||
{pinnedAssetMoveFolderId ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-sticky-target"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>
|
||||
{assetFolders.find((folder) => folder.id === pinnedAssetMoveFolderId)
|
||||
?.label ?? '目标文件夹'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{creatingFolder ? (
|
||||
<form
|
||||
className="image-canvas-editor__folder-create"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void commitNewAssetFolder();
|
||||
}}
|
||||
>
|
||||
<PlatformTextField
|
||||
aria-label="素材文件夹名称"
|
||||
value={newFolderName}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__folder-create-input"
|
||||
onChange={(event) => setNewFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EditorIconButton type="submit" label="保存素材文件夹" icon={Check} />
|
||||
<EditorIconButton
|
||||
label="取消新建素材文件夹"
|
||||
icon={X}
|
||||
onClick={() => {
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
{groupedAssets.map((folder) => (
|
||||
<ImageCanvasAssetFolderSectionView
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
assetPointerDragRef={assetPointerDragRef}
|
||||
suppressAssetClickRef={suppressAssetClickRef}
|
||||
isAssetSelectionMode={isAssetSelectionMode}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
assetMoveDropFolderId={assetMoveDropFolderId}
|
||||
renamingFolder={renamingFolder}
|
||||
renamingAsset={renamingAsset}
|
||||
setRenamingFolder={setRenamingFolder}
|
||||
setRenamingAsset={setRenamingAsset}
|
||||
setActiveUploadFolderId={setActiveUploadFolderId}
|
||||
setUploadDropTarget={setUploadDropTarget}
|
||||
setAssetPointerDrag={setAssetPointerDrag}
|
||||
setSelectedAssetIds={setSelectedAssetIds}
|
||||
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||
addUploadedFiles={addUploadedFiles}
|
||||
requestUpload={requestUpload}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
toggleAssetFolder={toggleAssetFolder}
|
||||
startRenamingFolder={startRenamingFolder}
|
||||
commitFolderRename={commitFolderRename}
|
||||
deleteAssetFolder={deleteAssetFolder}
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
/>
|
||||
))}
|
||||
{isAssetSelectionMode ? (
|
||||
<PlatformBatchActionToolbar
|
||||
className="image-canvas-editor__asset-batch-toolbar"
|
||||
label="素材批量操作"
|
||||
>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={toggleAllAssetsSelected}
|
||||
>
|
||||
{allSelectableAssetsSelected ? (
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
{selectedAssetIds.size > 0
|
||||
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
|
||||
: '全选'}
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="warning"
|
||||
size="sm"
|
||||
disabled={selectedAssetIds.size === 0}
|
||||
onClick={deleteSelectedAssets}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={closeAssetSelectionMode}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
</PlatformBatchActionToolbar>
|
||||
) : null}
|
||||
{assetMarquee ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-marquee"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
|
||||
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
|
||||
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
|
||||
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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> = {}): 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<string>(),
|
||||
renamingAsset = null,
|
||||
suppressAssetClick = false,
|
||||
addAssetLayer = vi.fn(),
|
||||
toggleAssetSelected = vi.fn(),
|
||||
commitAssetRename = vi.fn(),
|
||||
}: {
|
||||
asset?: EditorAsset;
|
||||
isAssetSelectionMode?: boolean;
|
||||
selectedAssetIds?: Set<string>;
|
||||
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(<ImageCanvasAssetRowView {...props} />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
renamingAsset: { assetId: string; value: string } | null;
|
||||
setRenamingAsset: Dispatch<
|
||||
SetStateAction<{ assetId: string; value: string } | null>
|
||||
>;
|
||||
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
|
||||
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
|
||||
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
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 ? (
|
||||
<PlatformTextField
|
||||
aria-label={`重命名素材${asset.label}`}
|
||||
value={renamingAsset.value}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__asset-rename-input"
|
||||
onChange={(event) =>
|
||||
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 ? (
|
||||
<div className="image-canvas-editor__asset-upload-status">
|
||||
<span>{asset.uploadMessage ?? '上传中'}</span>
|
||||
<strong>{Math.round(uploadProgress)}%</strong>
|
||||
</div>
|
||||
) : isRenaming ? (
|
||||
<div className="image-canvas-editor__asset-actions">
|
||||
<EditorIconButton
|
||||
label={`保存素材${asset.label}名称`}
|
||||
title="保存"
|
||||
icon={Check}
|
||||
onClick={() => commitAssetRename(asset)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label={`取消重命名素材${asset.label}`}
|
||||
title="取消"
|
||||
icon={X}
|
||||
onClick={() => setRenamingAsset(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-canvas-editor__asset-actions">
|
||||
<EditorIconButton
|
||||
label={`重命名素材${asset.label}`}
|
||||
title="重命名"
|
||||
icon={Pencil}
|
||||
onClick={() => startRenamingAsset(asset)}
|
||||
/>
|
||||
{asset.sourceKind === 'uploaded' ? (
|
||||
<EditorIconButton
|
||||
label={`删除素材${asset.label}`}
|
||||
title="删除"
|
||||
icon={Trash2}
|
||||
onClick={() => deleteUploadedAsset(asset)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-asset-id={asset.id}>
|
||||
<SidebarMediaItem
|
||||
title={asset.label}
|
||||
detail={`${asset.width} x ${asset.height}`}
|
||||
imageSrc={asset.src}
|
||||
imageAlt={`素材:${asset.label}`}
|
||||
primaryLabel={
|
||||
isUploadingAsset
|
||||
? `上传中${asset.label}`
|
||||
: isFailedUpload
|
||||
? `上传失败${asset.label}`
|
||||
: isAssetSelectionMode
|
||||
? `选择素材${asset.label}`
|
||||
: `添加${asset.label}`
|
||||
}
|
||||
onPrimaryClick={() => {
|
||||
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 ? <span>{asset.label}</span> : titleNode
|
||||
}
|
||||
actions={actions}
|
||||
draggable={!isRenaming && !isUploadingAsset && !isFailedUpload}
|
||||
previewOverlay={
|
||||
isUploadingAsset ? (
|
||||
<div className="image-canvas-editor__asset-upload-overlay">
|
||||
<span>上传中</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
footerNode={
|
||||
isUploadingAsset || isFailedUpload ? (
|
||||
<div className="image-canvas-editor__asset-upload-progress">
|
||||
<div>
|
||||
<span>
|
||||
{isFailedUpload
|
||||
? (asset.uploadMessage ?? '上传失败')
|
||||
: (asset.uploadMessage ?? '上传中')}
|
||||
</span>
|
||||
<strong>{Math.round(uploadProgress)}%</strong>
|
||||
</div>
|
||||
<progress
|
||||
aria-label={`素材${asset.label}上传进度`}
|
||||
max={100}
|
||||
value={uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : 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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
|
||||
return dialog ? (
|
||||
<div>
|
||||
<ImageCanvasBasicGenerationComposerView
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
setGenerateDialog={setDialog}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<output aria-label="当前提示词">{dialog.prompt}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
it('updates prompt, clears failed state, uploads references and submits', () => {
|
||||
const requestUpload = vi.fn();
|
||||
const submitGeneration = vi.fn();
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
status: 'failed',
|
||||
errorMessage: '生成失败',
|
||||
})}
|
||||
onRequestUpload={requestUpload}
|
||||
onSubmit={submitGeneration}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<BasicGenerationHarness onClose={closeComposer} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<SetStateAction<GenerateDialogState | null>>;
|
||||
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 (
|
||||
<form
|
||||
className="image-canvas-editor__generation-composer"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="生成图片"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (dialog.status !== 'generating') {
|
||||
onSubmit(dialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlatformIconButton
|
||||
variant="surfaceFloating"
|
||||
className="image-canvas-editor__generation-ref"
|
||||
label="添加参考图"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => onRequestUpload('asset')}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
>
|
||||
<span>参考图</span>
|
||||
</PlatformIconButton>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="今天我们要创作什么"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
prompt: event.target.value,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
aria-label="生成比例 1:1 2k 1张"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('生成参数')}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
>
|
||||
中 · 1:1(2k) · 1张
|
||||
</PlatformInlineOptionButton>
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-model"
|
||||
aria-label="生成模型 GPT Image"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('模型选择')}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
>
|
||||
GPT Im...
|
||||
</PlatformInlineOptionButton>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="生成"
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '12'}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
{dialog.status === 'generating' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="info"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="status"
|
||||
>
|
||||
生成中
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="alert"
|
||||
>
|
||||
{dialog.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__generation-close"
|
||||
label="关闭生成图片"
|
||||
icon={X}
|
||||
variant="surfaceFloating"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={createRef<HTMLSpanElement>()}
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLSpanElement | null>;
|
||||
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 (
|
||||
<div
|
||||
className="image-canvas-editor__bottom-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="AI画布工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canvasTools.map(({ id, label, icon: Icon }) =>
|
||||
id === 'spec' ? (
|
||||
<span
|
||||
key={id}
|
||||
ref={specToolWrapRef}
|
||||
className="image-canvas-editor__spec-tool-wrap"
|
||||
>
|
||||
<EditorIconButton
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
key={id}
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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<CharacterAnimationPanelState | null>(
|
||||
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 ? (
|
||||
<div>
|
||||
<ImageCanvasCharacterAnimationPanelView
|
||||
panel={panel}
|
||||
style={{ left: 12, top: 24 }}
|
||||
price={18}
|
||||
setCharacterAnimationPanel={setPanel}
|
||||
onUpdateDuration={updateDuration}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<output aria-label="当前动画描述">{panel.promptText}</output>
|
||||
<output aria-label="当前分辨率">{panel.resolution}</output>
|
||||
<output aria-label="当前比例">{panel.ratio}</output>
|
||||
<output aria-label="当前帧数">{panel.frameCount}</output>
|
||||
<output aria-label="当前时长">{panel.durationSeconds}</output>
|
||||
<output aria-label="当前状态">{panel.status}</output>
|
||||
<output aria-label="当前错误">{panel.errorMessage ?? '-'}</output>
|
||||
</div>
|
||||
) : (
|
||||
<output aria-label="面板状态">closed</output>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
it('updates prompt, resolution and ratio while clearing failed state', () => {
|
||||
render(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel({
|
||||
status: 'failed',
|
||||
errorMessage: '旧错误',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel({
|
||||
status: 'failed',
|
||||
errorMessage: '旧错误',
|
||||
})}
|
||||
onUpdateDuration={updateDuration}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel()}
|
||||
onSubmit={submitCharacterAnimation}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel({ status: 'generating' })}
|
||||
onSubmit={submitCharacterAnimation}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel({
|
||||
status: 'completed',
|
||||
result,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('已生成 40 帧')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<CharacterAnimationPanelHarness
|
||||
key="failed"
|
||||
initialPanel={createPanel({
|
||||
status: 'failed',
|
||||
errorMessage: '生成失败',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain('生成失败');
|
||||
});
|
||||
});
|
||||
@@ -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<CharacterAnimationPanelState | null>
|
||||
>;
|
||||
onUpdateDuration: (frameCountValue: string) => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function resetFailedPanelStatus<T extends { status: string; errorMessage?: string }>(
|
||||
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 (
|
||||
<form
|
||||
className="image-canvas-editor__character-animation-panel"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="角色动画生成面板"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (panel.status !== 'generating') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__character-animation-head">
|
||||
<strong>角色动画</strong>
|
||||
<EditorIconButton
|
||||
label="关闭角色动画生成面板"
|
||||
title="关闭"
|
||||
icon={X}
|
||||
onClick={() => setCharacterAnimationPanel(null)}
|
||||
/>
|
||||
</div>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="动画描述"
|
||||
value={panel.promptText}
|
||||
maxLength={4000}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__character-animation-textarea"
|
||||
onChange={(event) =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedPanelStatus(currentPanel),
|
||||
promptText: event.target.value.slice(0, 4000),
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="image-canvas-editor__character-animation-presets">
|
||||
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-animation-preset"
|
||||
disabled={panel.status === 'generating'}
|
||||
onClick={() =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...currentPanel,
|
||||
promptText: preset.text,
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="image-canvas-editor__character-animation-grid">
|
||||
<PlatformSelectField
|
||||
aria-label="分辨率"
|
||||
value={panel.resolution}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="xs"
|
||||
density="compact"
|
||||
onChange={(event) =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedPanelStatus(currentPanel),
|
||||
resolution: event.target.value === '720p' ? '720p' : '480p',
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
</PlatformSelectField>
|
||||
<PlatformSelectField
|
||||
aria-label="画面比例"
|
||||
value={panel.ratio}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="xs"
|
||||
density="compact"
|
||||
onChange={(event) =>
|
||||
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) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</PlatformSelectField>
|
||||
<PlatformSelectField
|
||||
aria-label="时长"
|
||||
value={String(panel.frameCount)}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="xs"
|
||||
density="compact"
|
||||
onChange={(event) => onUpdateDuration(event.target.value)}
|
||||
>
|
||||
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
|
||||
<option key={option.frameCount} value={String(option.frameCount)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</PlatformSelectField>
|
||||
</div>
|
||||
<div className="image-canvas-editor__character-animation-summary">
|
||||
<span
|
||||
className="image-canvas-editor__character-animation-summary-text"
|
||||
title={panel.promptText.trim() || undefined}
|
||||
aria-label={`生成文本:${panel.promptText.trim() || '动画描述'}`}
|
||||
>
|
||||
{panel.promptText.trim() ? panel.promptText.trim() : '动画描述'}
|
||||
</span>
|
||||
<strong>{price}泥点</strong>
|
||||
</div>
|
||||
{panel.status === 'completed' && panel.result ? (
|
||||
<PlatformStatusMessage
|
||||
tone="success"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
role="status"
|
||||
>
|
||||
已生成 {panel.result.frameCount} 帧
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{panel.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
role="alert"
|
||||
>
|
||||
{panel.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
className="image-canvas-editor__character-animation-submit"
|
||||
disabled={panel.status === 'generating'}
|
||||
>
|
||||
{panel.status === 'generating' ? '生成中' : '生成'}
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
isCharacterReferenceMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
] = useState(false);
|
||||
const [isPickingSpec, setIsPickingSpec] = useState(false);
|
||||
const [isPickingReference, setIsPickingReference] = useState(false);
|
||||
const characterSpecButtonRef = createRef<HTMLButtonElement>();
|
||||
const characterReferenceButtonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
return dialog ? (
|
||||
<div>
|
||||
<ImageCanvasCharacterGenerationComposerView
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
characterSpecButtonRef={characterSpecButtonRef}
|
||||
characterReferenceButtonRef={characterReferenceButtonRef}
|
||||
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
|
||||
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
|
||||
setGenerateDialog={setDialog}
|
||||
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
|
||||
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
|
||||
setIsPickingCharacterSpecFromCanvas={setIsPickingSpec}
|
||||
setIsPickingCharacterReferenceFromCanvas={setIsPickingReference}
|
||||
renderEditorPortal={(node) => node}
|
||||
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
||||
onOpenSpecDialog={onOpenSpecDialog}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<output aria-label="当前角色设定">{dialog.prompt}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="选择角色规范">{String(isPickingSpec)}</output>
|
||||
<output aria-label="选择常规参考图">{String(isPickingReference)}</output>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
it('updates character prompt, clears failed state and submits', () => {
|
||||
const submitCharacter = vi.fn();
|
||||
render(
|
||||
<CharacterGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
status: 'failed',
|
||||
errorMessage: '角色失败',
|
||||
})}
|
||||
onSubmit={submitCharacter}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<CharacterGenerationHarness
|
||||
onOpenSpecDialog={openSpecDialog}
|
||||
onRequestUpload={requestUpload}
|
||||
/>,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
isCharacterSpecMenuOpen: boolean;
|
||||
isCharacterReferenceMenuOpen: boolean;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
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 (
|
||||
<form
|
||||
className="image-canvas-editor__character-composer"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="生成角色形象"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (dialog.status !== 'generating') {
|
||||
onSubmit(dialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__character-reference-row">
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--spec">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
角色形象规范
|
||||
</PlatformFieldLabel>
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={characterSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
{dialog.characterSpecReference ? (
|
||||
<img
|
||||
src={dialog.characterSpecReference.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ClipboardList className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
{dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{isCharacterSpecMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="角色形象规范来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterSpecButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingCharacterSpecFromCanvas(true);
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
onOpenSpecDialog('character');
|
||||
}}
|
||||
>
|
||||
新建角色形象规范
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
onRequestUpload('character-spec');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--regular">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
常规参考图
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__character-reference-list">
|
||||
{(dialog.characterReferences ?? []).map((reference, index) => (
|
||||
<span
|
||||
key={reference.id}
|
||||
className="image-canvas-editor__character-ref-thumb"
|
||||
title={reference.label}
|
||||
>
|
||||
<img src={reference.src} alt={reference.label} />
|
||||
<span className="image-canvas-editor__character-ref-index">
|
||||
{index + 1}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
ref={characterReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
上传常规参考图
|
||||
</span>
|
||||
</button>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="常规参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingCharacterReferenceFromCanvas(true);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
onRequestUpload('character-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="image-canvas-editor__field-block">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
角色设定
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="角色设定"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
prompt: event.target.value,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="alert"
|
||||
>
|
||||
{dialog.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '生成'}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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> = {}): 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<Parameters<typeof ImageCanvasContextMenusView>[0]> = {},
|
||||
) {
|
||||
const props: Parameters<typeof ImageCanvasContextMenusView>[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(<ImageCanvasContextMenusView {...props} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<div
|
||||
className="image-canvas-editor__context-menu"
|
||||
role="menu"
|
||||
aria-label={
|
||||
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
|
||||
}
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{contextMenu.kind === 'blank' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canvasClipboard?.layers.length}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onUpdateScaleFromCenter(viewport.scale * 1.16);
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
>
|
||||
放大
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onUpdateScaleFromCenter(viewport.scale * 0.86);
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
>
|
||||
缩小
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onFitLayers();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
>
|
||||
显示画布所有元素
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onCopyContextLayers()}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onCopyContextLayers({ cut: true })}
|
||||
>
|
||||
剪切
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canvasClipboard?.layers.length}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onDuplicateContextLayers}
|
||||
>
|
||||
创建副本
|
||||
</button>
|
||||
<hr />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onMoveContextLayers('up')}
|
||||
>
|
||||
上移一层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onMoveContextLayers('down')}
|
||||
>
|
||||
下移一层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onMoveContextLayers('top')}
|
||||
>
|
||||
置于顶层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onMoveContextLayers('bottom')}
|
||||
>
|
||||
移动至底层
|
||||
</button>
|
||||
<hr />
|
||||
<button type="button" role="menuitem" onClick={onGroupContextLayers}>
|
||||
创建组
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onUngroupContextLayers}
|
||||
>
|
||||
解除组
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onToggleContextLayerVisibility}
|
||||
>
|
||||
{contextShouldShowLayer ? '显示' : '隐藏'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onToggleContextLayerLock}
|
||||
>
|
||||
{contextShouldUnlockLayer ? '解锁' : '锁定'}
|
||||
</button>
|
||||
<hr />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onFlipContextLayers('x')}
|
||||
>
|
||||
水平翻转
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onFlipContextLayers('y')}
|
||||
>
|
||||
垂直翻转
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={onExportContextLayer}>
|
||||
导出为
|
||||
</button>
|
||||
<hr />
|
||||
{imageContextMenuLayer ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onOpenQuickEditPanel(imageContextMenuLayer)}
|
||||
>
|
||||
快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenLayerMetadata(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
查看图片信息
|
||||
</button>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() =>
|
||||
onOpenCharacterAnimationPanel(imageContextMenuLayer)
|
||||
}
|
||||
>
|
||||
生成动画
|
||||
</button>
|
||||
) : null}
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="image-canvas-editor__context-menu-danger"
|
||||
onClick={onDeleteContextLayers}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
|
||||
<div
|
||||
className="image-canvas-editor__context-menu"
|
||||
style={{
|
||||
left: imageContextMenu.x,
|
||||
top: imageContextMenu.y,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onOpenQuickEditPanel(imageContextMenuLayer)}
|
||||
>
|
||||
快速编辑
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenLayerMetadata(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
查看图片信息
|
||||
</PlatformFloatingMenuItem>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onOpenCharacterAnimationPanel(imageContextMenuLayer)}
|
||||
>
|
||||
生成动画
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onDeleteLayerById(imageContextMenuLayer.id)}
|
||||
>
|
||||
删除图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ImageCanvasEditGenerationModalView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setDialog}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<output aria-label="弹窗状态">{dialog ? 'open' : 'closed'}</output>
|
||||
<output aria-label="当前提示词">{dialog?.prompt ?? '-'}</output>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditGenerationModalView', () => {
|
||||
it('updates prompt and submits edit generation', () => {
|
||||
const submitEdit = vi.fn();
|
||||
render(<EditGenerationModalHarness onSubmit={submitEdit} />);
|
||||
|
||||
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(
|
||||
<EditGenerationModalHarness
|
||||
initialDialog={createDialog({
|
||||
status: 'failed',
|
||||
errorMessage: '修改失败',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<EditGenerationModalHarness
|
||||
initialDialog={createDialog({ mode: 'generate' })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<EditGenerationModalHarness
|
||||
initialDialog={createDialog({ status: 'generating' })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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<SetStateAction<GenerateDialogState | null>>;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
export function ImageCanvasEditGenerationModalView({
|
||||
dialog,
|
||||
setGenerateDialog,
|
||||
onSubmit,
|
||||
}: ImageCanvasEditGenerationModalViewProps) {
|
||||
const isOpen = dialog?.mode === 'edit' && dialog.status !== 'generating';
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={isOpen}
|
||||
title={dialog?.mode === 'edit' ? '修改图片' : '生成图片'}
|
||||
size="sm"
|
||||
closeLabel={dialog?.mode === 'edit' ? '关闭修改图片' : '关闭生成图片'}
|
||||
closeDisabled={dialog?.status === 'generating'}
|
||||
onClose={() => setGenerateDialog(null)}
|
||||
panelClassName="image-canvas-editor__generate-dialog"
|
||||
bodyClassName="image-canvas-editor__generate-dialog-body"
|
||||
>
|
||||
{dialog?.mode === 'edit' ? (
|
||||
<form
|
||||
className="image-canvas-editor__generate-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (dialog.status !== 'generating') {
|
||||
onSubmit(dialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__generate-body">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
size="sm"
|
||||
density="roomy"
|
||||
className="image-canvas-editor__generate-prompt"
|
||||
placeholder="描述你想如何修改这张图片"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...currentDialog,
|
||||
prompt: event.target.value,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dialog.status === 'generating' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="info"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="status"
|
||||
>
|
||||
修改中
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="alert"
|
||||
>
|
||||
{dialog.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="image-canvas-editor__generate-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
>
|
||||
{dialog.status === 'generating' ? '修改中' : '修改'}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -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> = {}): 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<HTMLDivElement>(),
|
||||
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<HTMLDivElement>(),
|
||||
specToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
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(
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={createRef<HTMLElement>()}
|
||||
uploadInputRef={createRef<HTMLInputElement>()}
|
||||
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(
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={createRef<HTMLElement>()}
|
||||
uploadInputRef={createRef<HTMLInputElement>()}
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement | null>;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
onUploadInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||
assetDragPreview: AssetDragPreview | null;
|
||||
sidebarProps: ComponentProps<typeof ImageCanvasSidebarView>;
|
||||
topbarProps: ComponentProps<typeof ImageCanvasTopbarView>;
|
||||
stageProps: ComponentProps<typeof ImageCanvasStageView>;
|
||||
metadataProps: ComponentProps<typeof ImageCanvasMetadataModalView>;
|
||||
};
|
||||
|
||||
export function ImageCanvasEditorShellView({
|
||||
editorRootRef,
|
||||
uploadInputRef,
|
||||
onUploadInputChange,
|
||||
assetDragPreview,
|
||||
sidebarProps,
|
||||
topbarProps,
|
||||
stageProps,
|
||||
metadataProps,
|
||||
}: ImageCanvasEditorShellViewProps) {
|
||||
return (
|
||||
<section
|
||||
ref={editorRootRef}
|
||||
className="image-canvas-editor"
|
||||
aria-label="图片画布编辑器"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
aria-label="上传图片文件"
|
||||
hidden
|
||||
onChange={onUploadInputChange}
|
||||
/>
|
||||
{assetDragPreview ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-drag-preview"
|
||||
style={{
|
||||
left: Number.isFinite(assetDragPreview.x) ? assetDragPreview.x : 0,
|
||||
top: Number.isFinite(assetDragPreview.y) ? assetDragPreview.y : 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{assetDragPreview.label}
|
||||
</div>
|
||||
) : null}
|
||||
<ImageCanvasSidebarView {...sidebarProps} />
|
||||
|
||||
<div className="image-canvas-editor__main">
|
||||
<ImageCanvasTopbarView {...topbarProps} />
|
||||
<ImageCanvasStageView {...stageProps} />
|
||||
</div>
|
||||
|
||||
<ImageCanvasMetadataModalView {...metadataProps} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
ref={editorRootRef}
|
||||
className="image-canvas-editor"
|
||||
aria-label="图片画布编辑器"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
aria-label="上传图片文件"
|
||||
hidden
|
||||
onChange={handleUploadInputChange}
|
||||
/>
|
||||
{assetPointerDrag?.active ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-drag-preview"
|
||||
style={{
|
||||
left: Number.isFinite(assetPointerDrag.currentClientX)
|
||||
? assetPointerDrag.currentClientX
|
||||
: 0,
|
||||
top: Number.isFinite(assetPointerDrag.currentClientY)
|
||||
? assetPointerDrag.currentClientY
|
||||
: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{assets.find((asset) => asset.id === assetPointerDrag.assetId)
|
||||
?.label ?? '素材'}
|
||||
</div>
|
||||
) : null}
|
||||
<ImageCanvasSidebarView
|
||||
activeSidebarPanel={activeSidebarPanel}
|
||||
assetListRef={assetListRef}
|
||||
assetPointerDragRef={assetPointerDragRef}
|
||||
suppressAssetClickRef={suppressAssetClickRef}
|
||||
assets={assets}
|
||||
groupedAssets={groupedAssets}
|
||||
assetFolders={assetFolders}
|
||||
layers={layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
selectedLayerIds={selectedLayerIds}
|
||||
isAssetSelectionMode={isAssetSelectionMode}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
assetMoveDropFolderId={assetMoveDropFolderId}
|
||||
pinnedAssetMoveFolderId={pinnedAssetMoveFolderId}
|
||||
creatingFolder={creatingFolder}
|
||||
newFolderName={newFolderName}
|
||||
renamingFolder={renamingFolder}
|
||||
renamingAsset={renamingAsset}
|
||||
allSelectableAssetsSelected={allSelectableAssetsSelected}
|
||||
assetMarquee={assetMarquee}
|
||||
setIsAssetSelectionMode={setIsAssetSelectionMode}
|
||||
setCreatingFolder={setCreatingFolder}
|
||||
setNewFolderName={setNewFolderName}
|
||||
setRenamingFolder={setRenamingFolder}
|
||||
setRenamingAsset={setRenamingAsset}
|
||||
setActiveUploadFolderId={setActiveUploadFolderId}
|
||||
setUploadDropTarget={setUploadDropTarget}
|
||||
setAssetPointerDrag={setAssetPointerDrag}
|
||||
setSelectedAssetIds={setSelectedAssetIds}
|
||||
setImageContextMenu={setImageContextMenu}
|
||||
setContextMenu={setContextMenu}
|
||||
onAssetMarqueePointerDown={handleAssetMarqueePointerDown}
|
||||
onAssetMarqueePointerMove={handleAssetMarqueePointerMove}
|
||||
onAssetMarqueePointerUp={handleAssetMarqueePointerUp}
|
||||
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||
addUploadedFiles={addUploadedFiles}
|
||||
requestUpload={requestUpload}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
commitNewAssetFolder={commitNewAssetFolder}
|
||||
toggleAssetFolder={toggleAssetFolder}
|
||||
startRenamingFolder={startRenamingFolder}
|
||||
commitFolderRename={commitFolderRename}
|
||||
deleteAssetFolder={deleteAssetFolder}
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
toggleAllAssetsSelected={toggleAllAssetsSelected}
|
||||
deleteSelectedAssets={deleteSelectedAssets}
|
||||
closeAssetSelectionMode={closeAssetSelectionMode}
|
||||
groupSelectedLayers={groupSelectedLayers}
|
||||
selectSingleLayer={selectSingleLayer}
|
||||
resolveContextMenuPosition={resolveContextMenuPosition}
|
||||
getCanvasPointFromClient={getCanvasPointFromClient}
|
||||
/>
|
||||
|
||||
<div className="image-canvas-editor__main">
|
||||
<ImageCanvasTopbarView
|
||||
projectId={projectId}
|
||||
projectTitle={projectTitle}
|
||||
projectRenameValue={projectRenameValue}
|
||||
isRenamingProject={isRenamingProject}
|
||||
isProjectRenameSaving={isProjectRenameSaving}
|
||||
projectRenameError={projectRenameError}
|
||||
layers={layers}
|
||||
assetExportStatus={assetExportStatus}
|
||||
isExportingAssets={isExportingAssets}
|
||||
setProjectRenameValue={setProjectRenameValue}
|
||||
startProjectRename={startProjectRename}
|
||||
cancelProjectRename={cancelProjectRename}
|
||||
submitProjectRename={submitProjectRename}
|
||||
resetProjectRenameError={resetProjectRenameError}
|
||||
exportCanvasAssets={exportCanvasAssets}
|
||||
/>
|
||||
|
||||
<ImageCanvasStageView
|
||||
canvasViewportRef={canvasViewportRef}
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
isPanning={isPanning}
|
||||
effectiveTool={effectiveTool}
|
||||
canvasBackgroundColor={canvasBackgroundColor}
|
||||
canvasBackgroundHexValue={canvasBackgroundHexValue}
|
||||
viewport={viewport}
|
||||
snapGuide={snapGuide}
|
||||
layers={layers}
|
||||
selectedLayer={selectedLayer}
|
||||
selectedLayerIds={selectedLayerIds}
|
||||
hoveredLayerId={hoveredLayerId}
|
||||
canvasMarquee={canvasMarquee}
|
||||
canvasGenerationDialogs={canvasGenerationDialogs}
|
||||
generateDialog={generateDialog}
|
||||
quickEditPanel={quickEditPanel}
|
||||
generationComposerStyle={generationComposerStyle}
|
||||
selectedToolbarStyle={selectedToolbarStyle}
|
||||
uploadDropTarget={uploadDropTarget}
|
||||
contextMenu={contextMenu}
|
||||
canvasClipboard={canvasClipboard}
|
||||
imageContextMenu={imageContextMenu}
|
||||
imageContextMenuLayer={imageContextMenuLayer}
|
||||
contextShouldShowLayer={contextShouldShowLayer}
|
||||
contextShouldUnlockLayer={contextShouldUnlockLayer}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
isZoomMenuOpen={isZoomMenuOpen}
|
||||
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
|
||||
activeSidebarPanel={activeSidebarPanel}
|
||||
isMinimapOpen={isMinimapOpen}
|
||||
minimapModel={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) =>
|
||||
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}
|
||||
</ImageCanvasStageView>
|
||||
</div>
|
||||
|
||||
<ImageCanvasMetadataModalView
|
||||
layer={metadataLayer}
|
||||
onClose={() => setMetadataLayer(null)}
|
||||
/>
|
||||
</section>
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={editorRootRef}
|
||||
uploadInputRef={uploadInputRef}
|
||||
onUploadInputChange={handleUploadInputChange}
|
||||
assetDragPreview={assetDragPreview}
|
||||
sidebarProps={sidebarProps}
|
||||
topbarProps={topbarProps}
|
||||
stageProps={stageProps}
|
||||
metadataProps={{
|
||||
layer: metadataLayer,
|
||||
onClose: () => setMetadataLayer(null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
|
||||
return dialog ? (
|
||||
<div>
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
/>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
it('updates dimensions and resets failed dialog state', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'character',
|
||||
prompt: '',
|
||||
status: 'failed',
|
||||
errorMessage: '旧错误',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'icon',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '9:16',
|
||||
imageSize: '0.5K',
|
||||
}}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<SetStateAction<GenerateDialogState | null>>;
|
||||
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<GenerateDialogState>) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog && currentDialog.mode === dialog.mode
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
...patch,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{includeDimensions ? (
|
||||
<>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
画面比例
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{selection.options.aspectRatios.map((aspectRatio) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={aspectRatio}
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.aspectRatio === aspectRatio}
|
||||
onClick={() => updateDialog({ aspectRatio })}
|
||||
>
|
||||
{aspectRatio}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
大小尺寸
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{selection.options.imageSizes.map((imageSize) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={imageSize}
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.imageSize === imageSize}
|
||||
onClick={() => updateDialog({ imageSize })}
|
||||
>
|
||||
{imageSize}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
模型
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{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 (
|
||||
<PlatformInlineOptionButton
|
||||
key={option.value}
|
||||
className="image-canvas-editor__generation-model"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.model === option.value}
|
||||
onClick={() => {
|
||||
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}
|
||||
</PlatformInlineOptionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(initialMenuOpen);
|
||||
const [isPickingIconSpec, setIsPickingIconSpec] = useState(false);
|
||||
const iconSpecButtonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
return dialog ? (
|
||||
<div>
|
||||
<ImageCanvasIconSpritesheetComposerView
|
||||
dialog={dialog}
|
||||
style={{ left: 12, top: 24 }}
|
||||
iconSpecButtonRef={iconSpecButtonRef}
|
||||
isIconSpecMenuOpen={isMenuOpen}
|
||||
setGenerateDialog={setDialog}
|
||||
setIsIconSpecMenuOpen={setIsMenuOpen}
|
||||
setIsPickingIconSpecFromCanvas={setIsPickingIconSpec}
|
||||
renderEditorPortal={(node) => node}
|
||||
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
||||
onOpenSpecDialog={onOpenSpecDialog}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onUpdateIconDescription={onUpdateIconDescription}
|
||||
onAddIconDescription={onAddIconDescription}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<output aria-label="当前菜单">{String(isMenuOpen)}</output>
|
||||
<output aria-label="正在选择图标规范">{String(isPickingIconSpec)}</output>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
it('opens icon spec menu and supports each source action', () => {
|
||||
const openSpecDialog = vi.fn();
|
||||
const requestUpload = vi.fn();
|
||||
render(
|
||||
<IconComposerHarness
|
||||
initialDialog={createIconDialog()}
|
||||
onOpenSpecDialog={openSpecDialog}
|
||||
onRequestUpload={requestUpload}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<IconComposerHarness
|
||||
initialDialog={dialog}
|
||||
onUpdateIconDescription={updateIconDescription}
|
||||
onAddIconDescription={addIconDescription}
|
||||
onSubmit={submitIconGeneration}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<IconComposerHarness
|
||||
initialDialog={createIconDialog({
|
||||
imageModel: 'nanobanana2',
|
||||
imageSize: '0.5K',
|
||||
})}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<IconComposerHarness
|
||||
initialDialog={createIconDialog({ status: 'generating' })}
|
||||
onAddIconDescription={addIconDescription}
|
||||
onSubmit={submitIconGeneration}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<IconComposerHarness
|
||||
key="failed"
|
||||
initialDialog={createIconDialog({
|
||||
status: 'failed',
|
||||
errorMessage: '生成失败',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain('生成失败');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user