拆分编辑器前端画布视图
抽出素材栏、生成器、舞台工具栏和画布世界视图 补充各拆分视图的聚焦测试 更新 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 前端拆分第二十六阶段:新增 `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 前端拆分第二十七阶段:新增 `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 上传侧栏回归修正:上传工作流移除上传到画布后强制切换 `图层` 侧栏的副作用,保留新增素材卡、创建画布图层和选中新图层。验证命令:`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>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx
Normal file
279
src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/image-editor/ImageCanvasAssetRowView.test.tsx
Normal file
131
src/components/image-editor/ImageCanvasAssetRowView.test.tsx
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
321
src/components/image-editor/ImageCanvasAssetRowView.tsx
Normal file
321
src/components/image-editor/ImageCanvasAssetRowView.tsx
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/components/image-editor/ImageCanvasBottomToolbarView.tsx
Normal file
81
src/components/image-editor/ImageCanvasBottomToolbarView.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/components/image-editor/ImageCanvasContextMenusView.test.tsx
Normal file
137
src/components/image-editor/ImageCanvasContextMenusView.test.tsx
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
319
src/components/image-editor/ImageCanvasContextMenusView.tsx
Normal file
319
src/components/image-editor/ImageCanvasContextMenusView.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
src/components/image-editor/ImageCanvasEditorShellView.test.tsx
Normal file
258
src/components/image-editor/ImageCanvasEditorShellView.test.tsx
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/components/image-editor/ImageCanvasEditorShellView.tsx
Normal file
77
src/components/image-editor/ImageCanvasEditorShellView.tsx
Normal file
@@ -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: '重命名素材拼图素材' }),
|
screen.getByRole('button', { name: '重命名素材拼图素材' }),
|
||||||
);
|
);
|
||||||
const renameInput = screen.getByLabelText('重命名素材拼图素材');
|
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.clear(renameInput);
|
||||||
await user.type(renameInput, '主视觉素材');
|
await user.type(renameInput, '主视觉素材');
|
||||||
await user.click(
|
await user.click(
|
||||||
@@ -822,10 +818,6 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
|
await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
|
||||||
const folderNameInput = screen.getByLabelText('素材文件夹名称');
|
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.type(folderNameInput, '角色上传');
|
||||||
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
|
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
|
||||||
|
|
||||||
@@ -886,10 +878,6 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
await screen.findByRole('region', { name: '角色' });
|
await screen.findByRole('region', { name: '角色' });
|
||||||
await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
|
await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
|
||||||
const folderRenameInput = screen.getByLabelText('重命名文件夹角色');
|
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.clear(folderRenameInput);
|
||||||
await user.type(folderRenameInput, '角色参考');
|
await user.type(folderRenameInput, '角色参考');
|
||||||
await user.click(
|
await user.click(
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
|
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||||
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
|
||||||
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
|
||||||
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
|
|
||||||
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||||
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||||
import type {
|
import type {
|
||||||
@@ -584,218 +581,196 @@ export function ImageCanvasEditorView() {
|
|||||||
requestUpload('asset');
|
requestUpload('asset');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (switchGenerationTool(tool)) {
|
if (switchGenerationTool(tool)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveTool(tool);
|
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 (
|
return (
|
||||||
<section
|
<ImageCanvasEditorShellView
|
||||||
ref={editorRootRef}
|
editorRootRef={editorRootRef}
|
||||||
className="image-canvas-editor"
|
uploadInputRef={uploadInputRef}
|
||||||
aria-label="图片画布编辑器"
|
onUploadInputChange={handleUploadInputChange}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
assetDragPreview={assetDragPreview}
|
||||||
>
|
sidebarProps={sidebarProps}
|
||||||
<input
|
topbarProps={topbarProps}
|
||||||
ref={uploadInputRef}
|
stageProps={stageProps}
|
||||||
type="file"
|
metadataProps={{
|
||||||
accept="image/*"
|
layer: metadataLayer,
|
||||||
multiple
|
onClose: () => setMetadataLayer(null),
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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('生成失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type Dispatch,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
type SetStateAction,
|
||||||
|
} from 'react';
|
||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||||
|
import {
|
||||||
|
PlatformFloatingMenu,
|
||||||
|
PlatformFloatingMenuItem,
|
||||||
|
} from '../common/PlatformFloatingMenu';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { PlatformTextField } from '../common/PlatformTextField';
|
||||||
|
import {
|
||||||
|
DEFAULT_ICON_DESCRIPTIONS,
|
||||||
|
ICON_DESCRIPTION_LIMIT,
|
||||||
|
} from './ImageCanvasGenerationModel';
|
||||||
|
import type {
|
||||||
|
GenerateDialogState,
|
||||||
|
SpecGenerationType,
|
||||||
|
UploadTarget,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||||
|
|
||||||
|
type ImageCanvasIconSpritesheetComposerViewProps = {
|
||||||
|
dialog: GenerateDialogState;
|
||||||
|
style: CSSProperties;
|
||||||
|
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
isIconSpecMenuOpen: boolean;
|
||||||
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||||
|
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||||
|
renderEditorPortal: (node: ReactNode) => ReactNode;
|
||||||
|
buildPortalMenuStyle: (
|
||||||
|
anchor: HTMLElement | null,
|
||||||
|
placement: 'above' | 'below',
|
||||||
|
) => CSSProperties;
|
||||||
|
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||||
|
onRequestUpload: (target: UploadTarget) => void;
|
||||||
|
onUpdateIconDescription: (index: number, value: string) => void;
|
||||||
|
onAddIconDescription: () => void;
|
||||||
|
onRememberImageModel: (model: string) => void;
|
||||||
|
onSubmit: (dialog: GenerateDialogState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageCanvasIconSpritesheetComposerView({
|
||||||
|
dialog,
|
||||||
|
style,
|
||||||
|
iconSpecButtonRef,
|
||||||
|
isIconSpecMenuOpen,
|
||||||
|
setGenerateDialog,
|
||||||
|
setIsIconSpecMenuOpen,
|
||||||
|
setIsPickingIconSpecFromCanvas,
|
||||||
|
renderEditorPortal,
|
||||||
|
buildPortalMenuStyle,
|
||||||
|
onOpenSpecDialog,
|
||||||
|
onRequestUpload,
|
||||||
|
onUpdateIconDescription,
|
||||||
|
onAddIconDescription,
|
||||||
|
onRememberImageModel,
|
||||||
|
onSubmit,
|
||||||
|
}: ImageCanvasIconSpritesheetComposerViewProps) {
|
||||||
|
const descriptions = dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS;
|
||||||
|
|
||||||
|
const pickIconSpecFromCanvas = () => {
|
||||||
|
setIsPickingIconSpecFromCanvas(true);
|
||||||
|
setIsIconSpecMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openIconSpecDialog = () => {
|
||||||
|
setIsIconSpecMenuOpen(false);
|
||||||
|
onOpenSpecDialog('icon');
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestIconSpecUpload = () => {
|
||||||
|
setIsIconSpecMenuOpen(false);
|
||||||
|
onRequestUpload('icon-spec');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="image-canvas-editor__icon-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__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="field"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
图标素材规范
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<div className="image-canvas-editor__icon-spec-row">
|
||||||
|
<span className="image-canvas-editor__character-spec-wrap">
|
||||||
|
<button
|
||||||
|
ref={iconSpecButtonRef}
|
||||||
|
type="button"
|
||||||
|
className="image-canvas-editor__icon-spec-card"
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
aria-label={dialog.iconSpecReference?.label ?? '图标素材规范'}
|
||||||
|
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__icon-spec-preview"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{dialog.iconSpecReference?.src ? (
|
||||||
|
<img src={dialog.iconSpecReference.src} alt="" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="image-canvas-editor__icon-spec-copy">
|
||||||
|
<span className="image-canvas-editor__icon-spec-eyebrow">
|
||||||
|
图标素材规范
|
||||||
|
</span>
|
||||||
|
<span className="image-canvas-editor__icon-spec-title">
|
||||||
|
{dialog.iconSpecReference?.label ?? '待选择'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="image-canvas-editor__icon-spec-state">
|
||||||
|
{dialog.iconSpecReference ? '已绑定' : '待绑定'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{isIconSpecMenuOpen
|
||||||
|
? renderEditorPortal(
|
||||||
|
<PlatformFloatingMenu
|
||||||
|
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||||
|
label="图标素材规范来源"
|
||||||
|
placement="top-start"
|
||||||
|
style={buildPortalMenuStyle(iconSpecButtonRef.current, 'above')}
|
||||||
|
>
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__context-menu-item"
|
||||||
|
onClick={pickIconSpecFromCanvas}
|
||||||
|
>
|
||||||
|
从画布中选择
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__context-menu-item"
|
||||||
|
onClick={openIconSpecDialog}
|
||||||
|
>
|
||||||
|
新建图标素材规范
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__context-menu-item"
|
||||||
|
onClick={requestIconSpecUpload}
|
||||||
|
>
|
||||||
|
上传图片
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
</PlatformFloatingMenu>,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__icon-spec-actions"
|
||||||
|
aria-label="图标素材规范操作"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
onClick={pickIconSpecFromCanvas}
|
||||||
|
>
|
||||||
|
画布
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
onClick={openIconSpecDialog}
|
||||||
|
>
|
||||||
|
新建
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
onClick={requestIconSpecUpload}
|
||||||
|
>
|
||||||
|
上传
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="image-canvas-editor__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="field"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
素材描述
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<div className="image-canvas-editor__icon-description-list">
|
||||||
|
{descriptions.map((description, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className="image-canvas-editor__icon-description-card"
|
||||||
|
>
|
||||||
|
<span className="image-canvas-editor__icon-description-index">
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="image-canvas-editor__icon-description-title">
|
||||||
|
素材描述 {index + 1}
|
||||||
|
</span>
|
||||||
|
<PlatformTextField
|
||||||
|
aria-label={`素材描述${index + 1}`}
|
||||||
|
value={description}
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
size="sm"
|
||||||
|
density="compact"
|
||||||
|
className="image-canvas-editor__icon-description-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateIconDescription(index, event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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__icon-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="image-canvas-editor__character-reference-add"
|
||||||
|
disabled={
|
||||||
|
dialog.status === 'generating' ||
|
||||||
|
descriptions.length >= ICON_DESCRIPTION_LIMIT
|
||||||
|
}
|
||||||
|
onClick={onAddIconDescription}
|
||||||
|
>
|
||||||
|
添加素材描述
|
||||||
|
</button>
|
||||||
|
<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'}
|
||||||
|
aria-label="生成"
|
||||||
|
>
|
||||||
|
{dialog.status === 'generating' ? '生成中' : '生成'}
|
||||||
|
</PlatformActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/image-editor/ImageCanvasLayerPanelView.tsx
Normal file
90
src/components/image-editor/ImageCanvasLayerPanelView.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
|
import { SidebarMediaItem } from './ImageCanvasEditorPrimitives';
|
||||||
|
import type {
|
||||||
|
CanvasContextMenuState,
|
||||||
|
CanvasLayer,
|
||||||
|
ImageContextMenuState,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
|
export type ImageCanvasLayerPanelViewProps = {
|
||||||
|
layers: CanvasLayer[];
|
||||||
|
selectedLayerId: string | null;
|
||||||
|
selectedLayerIds: string[];
|
||||||
|
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||||
|
setContextMenu: Dispatch<SetStateAction<CanvasContextMenuState | null>>;
|
||||||
|
selectSingleLayer: (layerId: string | null) => void;
|
||||||
|
resolveContextMenuPosition: (
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
menuKind: 'blank' | 'layer',
|
||||||
|
) => Omit<CanvasContextMenuState, 'kind' | 'layerId' | 'canvasPoint'>;
|
||||||
|
getCanvasPointFromClient: (
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
) => { x: number; y: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageCanvasLayerPanelView({
|
||||||
|
layers,
|
||||||
|
selectedLayerId,
|
||||||
|
selectedLayerIds,
|
||||||
|
setImageContextMenu,
|
||||||
|
setContextMenu,
|
||||||
|
selectSingleLayer,
|
||||||
|
resolveContextMenuPosition,
|
||||||
|
getCanvasPointFromClient,
|
||||||
|
}: ImageCanvasLayerPanelViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="image-canvas-editor__layers-list">
|
||||||
|
{layers
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.zIndex - left.zIndex)
|
||||||
|
.map((layer) => (
|
||||||
|
<SidebarMediaItem
|
||||||
|
key={layer.id}
|
||||||
|
title={layer.title}
|
||||||
|
detail={[
|
||||||
|
`${Math.round(layer.width)} x ${Math.round(layer.height)}`,
|
||||||
|
layer.groupId ? '已打组' : null,
|
||||||
|
layer.hidden ? '已隐藏' : null,
|
||||||
|
layer.locked ? '已锁定' : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
imageSrc={layer.src}
|
||||||
|
imageAlt={`图层缩略图:${layer.title}`}
|
||||||
|
selected={selectedLayerId === layer.id}
|
||||||
|
primaryLabel={`选择图层${layer.title}`}
|
||||||
|
onPrimaryClick={() => selectSingleLayer(layer.id)}
|
||||||
|
rowClassName="image-canvas-editor__layer-row"
|
||||||
|
primaryClassName="image-canvas-editor__layer-row-button"
|
||||||
|
thumbnailClassName="image-canvas-editor__layer-row-thumb"
|
||||||
|
metaClassName="image-canvas-editor__layer-row-meta"
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!selectedLayerIds.includes(layer.id)) {
|
||||||
|
selectSingleLayer(layer.id);
|
||||||
|
}
|
||||||
|
const position = resolveContextMenuPosition(
|
||||||
|
event.clientX,
|
||||||
|
event.clientY,
|
||||||
|
'layer',
|
||||||
|
);
|
||||||
|
setImageContextMenu(null);
|
||||||
|
setContextMenu({
|
||||||
|
kind: 'layer',
|
||||||
|
layerId: layer.id,
|
||||||
|
...position,
|
||||||
|
canvasPoint: getCanvasPointFromClient(
|
||||||
|
event.clientX,
|
||||||
|
event.clientY,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
|||||||
import { formatLayerImageType } from './ImageCanvasGenerationModel';
|
import { formatLayerImageType } from './ImageCanvasGenerationModel';
|
||||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
type ImageCanvasMetadataModalViewProps = {
|
export type ImageCanvasMetadataModalViewProps = {
|
||||||
layer: CanvasLayer | null;
|
layer: CanvasLayer | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
112
src/components/image-editor/ImageCanvasPanelDockView.test.tsx
Normal file
112
src/components/image-editor/ImageCanvasPanelDockView.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||||
|
import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView';
|
||||||
|
|
||||||
|
function renderPanelDock(
|
||||||
|
overrides: Partial<Parameters<typeof ImageCanvasPanelDockView>[0]> = {},
|
||||||
|
) {
|
||||||
|
const props: Parameters<typeof ImageCanvasPanelDockView>[0] = {
|
||||||
|
viewport: { x: 0, y: 0, scale: 1 },
|
||||||
|
canvasBackgroundColor: '#f8fafc',
|
||||||
|
canvasBackgroundHexValue: '#f8fafc',
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
isZoomMenuOpen: false,
|
||||||
|
isBackgroundSettingsOpen: false,
|
||||||
|
activeSidebarPanel: null,
|
||||||
|
isMinimapOpen: false,
|
||||||
|
minimapModel: null,
|
||||||
|
onFitLayers: vi.fn(),
|
||||||
|
onUndoCanvasChange: vi.fn(),
|
||||||
|
onRedoCanvasChange: vi.fn(),
|
||||||
|
onUpdateScaleFromCenter: vi.fn(),
|
||||||
|
onToggleZoomMenu: vi.fn(),
|
||||||
|
onCloseZoomMenu: vi.fn(),
|
||||||
|
onToggleBackgroundSettings: vi.fn(),
|
||||||
|
onApplyCanvasBackgroundColor: vi.fn(),
|
||||||
|
onCanvasBackgroundHexChange: vi.fn(),
|
||||||
|
onToggleSidebarPanel: vi.fn(),
|
||||||
|
onToggleMinimap: vi.fn(),
|
||||||
|
onMinimapPointerDown: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ImageCanvasPanelDockView {...props} />);
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageCanvasPanelDockView', () => {
|
||||||
|
it('renders panel dock actions and forwards common controls', () => {
|
||||||
|
const props = renderPanelDock({
|
||||||
|
activeSidebarPanel: 'assets',
|
||||||
|
isMinimapOpen: true,
|
||||||
|
minimapModel: {
|
||||||
|
bounds: { minX: 0, minY: 0, maxX: 100, maxY: 100 },
|
||||||
|
scale: 1,
|
||||||
|
layers: [],
|
||||||
|
viewport: { left: '10%', top: '12%', width: '20%', height: '24%' },
|
||||||
|
} satisfies StageMinimapModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(toolbar).getByRole('button', { name: '打开素材' }).getAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
),
|
||||||
|
).toBe('true');
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
within(toolbar).getByRole('button', { name: '重做' }) as HTMLButtonElement
|
||||||
|
).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
|
||||||
|
fireEvent.click(within(toolbar).getByRole('button', { name: '撤销' }));
|
||||||
|
fireEvent.click(within(toolbar).getByRole('button', { name: '打开图层' }));
|
||||||
|
fireEvent.click(within(toolbar).getByRole('button', { name: '切换小地图' }));
|
||||||
|
|
||||||
|
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onUndoCanvasChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onToggleSidebarPanel).toHaveBeenCalledWith('layers');
|
||||||
|
expect(props.onToggleMinimap).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders zoom and background settings with callback wiring', () => {
|
||||||
|
const props = renderPanelDock({
|
||||||
|
isZoomMenuOpen: true,
|
||||||
|
isBackgroundSettingsOpen: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' }));
|
||||||
|
|
||||||
|
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.5);
|
||||||
|
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole('button', { name: '暖灰' }));
|
||||||
|
fireEvent.change(within(panel).getByLabelText('自定义画布背景色'), {
|
||||||
|
target: { value: '#ffffff' },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(panel).getByLabelText('画布背景十六进制颜色'), {
|
||||||
|
target: { value: '#abc' },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(panel).getByRole('button', { name: '恢复默认' }));
|
||||||
|
fireEvent.click(within(panel).getByRole('button', { name: '关闭画布背景设置' }));
|
||||||
|
|
||||||
|
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea');
|
||||||
|
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
||||||
|
expect(props.onCanvasBackgroundHexChange).toHaveBeenCalledWith('#abc');
|
||||||
|
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f8fafc');
|
||||||
|
expect(props.onToggleBackgroundSettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
318
src/components/image-editor/ImageCanvasPanelDockView.tsx
Normal file
318
src/components/image-editor/ImageCanvasPanelDockView.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import {
|
||||||
|
ImagePlus,
|
||||||
|
Layers,
|
||||||
|
Map as MapIcon,
|
||||||
|
Redo2,
|
||||||
|
RotateCcw,
|
||||||
|
Undo2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
|
|
||||||
|
import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu';
|
||||||
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||||
|
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||||
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
|
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||||
|
import {
|
||||||
|
CANVAS_BACKGROUND_OPTIONS,
|
||||||
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||||
|
formatPercent,
|
||||||
|
} from './ImageCanvasEditorModel';
|
||||||
|
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
|
type ImageCanvasPanelDockViewProps = {
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
canvasBackgroundColor: string;
|
||||||
|
canvasBackgroundHexValue: string;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
isZoomMenuOpen: boolean;
|
||||||
|
isBackgroundSettingsOpen: boolean;
|
||||||
|
activeSidebarPanel: SidebarPanel | null;
|
||||||
|
isMinimapOpen: boolean;
|
||||||
|
minimapModel: StageMinimapModel | null;
|
||||||
|
onFitLayers: () => void;
|
||||||
|
onUndoCanvasChange: () => void;
|
||||||
|
onRedoCanvasChange: () => void;
|
||||||
|
onUpdateScaleFromCenter: (nextScale: number) => void;
|
||||||
|
onToggleZoomMenu: () => void;
|
||||||
|
onCloseZoomMenu: () => void;
|
||||||
|
onToggleBackgroundSettings: () => void;
|
||||||
|
onApplyCanvasBackgroundColor: (color: string) => void;
|
||||||
|
onCanvasBackgroundHexChange: (value: string) => void;
|
||||||
|
onToggleSidebarPanel: (panel: SidebarPanel) => void;
|
||||||
|
onToggleMinimap: () => void;
|
||||||
|
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageCanvasPanelDockView({
|
||||||
|
viewport,
|
||||||
|
canvasBackgroundColor,
|
||||||
|
canvasBackgroundHexValue,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
isZoomMenuOpen,
|
||||||
|
isBackgroundSettingsOpen,
|
||||||
|
activeSidebarPanel,
|
||||||
|
isMinimapOpen,
|
||||||
|
minimapModel,
|
||||||
|
onFitLayers,
|
||||||
|
onUndoCanvasChange,
|
||||||
|
onRedoCanvasChange,
|
||||||
|
onUpdateScaleFromCenter,
|
||||||
|
onToggleZoomMenu,
|
||||||
|
onCloseZoomMenu,
|
||||||
|
onToggleBackgroundSettings,
|
||||||
|
onApplyCanvasBackgroundColor,
|
||||||
|
onCanvasBackgroundHexChange,
|
||||||
|
onToggleSidebarPanel,
|
||||||
|
onToggleMinimap,
|
||||||
|
onMinimapPointerDown,
|
||||||
|
}: ImageCanvasPanelDockViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditorIconButton
|
||||||
|
className="image-canvas-editor__reset-button"
|
||||||
|
label="重置画布视图"
|
||||||
|
title="重置画布视图"
|
||||||
|
icon={RotateCcw}
|
||||||
|
onClick={() => onFitLayers()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__panel-dock"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="画布面板入口"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<EditorIconButton
|
||||||
|
label="撤销"
|
||||||
|
title="撤销"
|
||||||
|
icon={Undo2}
|
||||||
|
disabled={!canUndo}
|
||||||
|
onClick={onUndoCanvasChange}
|
||||||
|
/>
|
||||||
|
<EditorIconButton
|
||||||
|
label="重做"
|
||||||
|
title="重做"
|
||||||
|
icon={Redo2}
|
||||||
|
disabled={!canRedo}
|
||||||
|
onClick={onRedoCanvasChange}
|
||||||
|
/>
|
||||||
|
<div className="image-canvas-editor__zoom-menu-wrap">
|
||||||
|
<PlatformInlineOptionButton
|
||||||
|
className="image-canvas-editor__zoom-trigger"
|
||||||
|
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isZoomMenuOpen}
|
||||||
|
onClick={onToggleZoomMenu}
|
||||||
|
>
|
||||||
|
{formatPercent(viewport.scale)}
|
||||||
|
</PlatformInlineOptionButton>
|
||||||
|
{isZoomMenuOpen ? (
|
||||||
|
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateScaleFromCenter(viewport.scale * 1.16);
|
||||||
|
onCloseZoomMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
放大
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateScaleFromCenter(viewport.scale * 0.86);
|
||||||
|
onCloseZoomMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
缩小
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
onFitLayers();
|
||||||
|
onCloseZoomMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
显示画布所有元素
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
{[0.5, 1, 2].map((scale) => (
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
key={scale}
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateScaleFromCenter(scale);
|
||||||
|
onCloseZoomMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
缩放至{Math.round(scale * 100)}%
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
))}
|
||||||
|
</PlatformFloatingMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="image-canvas-editor__background-control">
|
||||||
|
<PlatformIconButton
|
||||||
|
label="画布背景色"
|
||||||
|
title="画布背景色"
|
||||||
|
aria-expanded={isBackgroundSettingsOpen}
|
||||||
|
onClick={onToggleBackgroundSettings}
|
||||||
|
icon={
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__background-swatch-current"
|
||||||
|
style={{ backgroundColor: canvasBackgroundColor }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isBackgroundSettingsOpen ? (
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__background-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="画布背景设置"
|
||||||
|
>
|
||||||
|
<div className="image-canvas-editor__background-panel-head">
|
||||||
|
<span>画布背景</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="image-canvas-editor__background-close"
|
||||||
|
aria-label="关闭画布背景设置"
|
||||||
|
onClick={onToggleBackgroundSettings}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="image-canvas-editor__background-current-row">
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__background-current-preview"
|
||||||
|
style={{ backgroundColor: canvasBackgroundColor }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{canvasBackgroundColor}</span>
|
||||||
|
</div>
|
||||||
|
<label className="image-canvas-editor__background-spectrum">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label="画布背景色相"
|
||||||
|
value={canvasBackgroundColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__background-spectrum-surface"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__background-spectrum-handle"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="image-canvas-editor__background-hue">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label="自定义画布背景色"
|
||||||
|
value={canvasBackgroundColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__background-presets"
|
||||||
|
aria-label="画布背景预设色"
|
||||||
|
>
|
||||||
|
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className="image-canvas-editor__background-preset"
|
||||||
|
aria-label={option.label}
|
||||||
|
aria-pressed={canvasBackgroundColor === option.value}
|
||||||
|
onClick={() => onApplyCanvasBackgroundColor(option.value)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__background-swatch"
|
||||||
|
style={{ backgroundColor: option.value }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="image-canvas-editor__background-footer">
|
||||||
|
<label className="image-canvas-editor__background-hex-field">
|
||||||
|
<span>HEX</span>
|
||||||
|
<input
|
||||||
|
aria-label="画布背景十六进制颜色"
|
||||||
|
value={canvasBackgroundHexValue}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) =>
|
||||||
|
onCanvasBackgroundHexChange(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="image-canvas-editor__background-reset"
|
||||||
|
onClick={() =>
|
||||||
|
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
恢复默认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<EditorIconButton
|
||||||
|
label="打开素材"
|
||||||
|
title="素材"
|
||||||
|
icon={ImagePlus}
|
||||||
|
pressed={activeSidebarPanel === 'assets'}
|
||||||
|
onClick={() => onToggleSidebarPanel('assets')}
|
||||||
|
/>
|
||||||
|
<EditorIconButton
|
||||||
|
label="打开图层"
|
||||||
|
title="图层"
|
||||||
|
icon={Layers}
|
||||||
|
pressed={activeSidebarPanel === 'layers'}
|
||||||
|
onClick={() => onToggleSidebarPanel('layers')}
|
||||||
|
/>
|
||||||
|
<EditorIconButton
|
||||||
|
label="切换小地图"
|
||||||
|
title="小地图"
|
||||||
|
icon={MapIcon}
|
||||||
|
pressed={isMinimapOpen}
|
||||||
|
onClick={onToggleMinimap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMinimapOpen && minimapModel ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="image-canvas-editor__minimap"
|
||||||
|
aria-label="画布小地图"
|
||||||
|
title="拖拽移动视图"
|
||||||
|
onPointerDown={onMinimapPointerDown}
|
||||||
|
>
|
||||||
|
<span className="image-canvas-editor__minimap-stage">
|
||||||
|
{minimapModel.layers.map((layer) => (
|
||||||
|
<span
|
||||||
|
key={layer.id}
|
||||||
|
className="image-canvas-editor__minimap-layer"
|
||||||
|
title={layer.title}
|
||||||
|
style={layer.rect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__minimap-viewport"
|
||||||
|
style={minimapModel.viewport}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanvasLayer,
|
||||||
|
QuickEditPanelState,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
|
||||||
|
|
||||||
|
function createLayer(): CanvasLayer {
|
||||||
|
return {
|
||||||
|
id: 'layer-a',
|
||||||
|
resourceId: 'resource-a',
|
||||||
|
title: '源图',
|
||||||
|
src: 'data:image/png;base64,c291cmNl',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
originalWidth: 320,
|
||||||
|
originalHeight: 180,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickEditPanelHarness({
|
||||||
|
initialPanel,
|
||||||
|
onSubmit = vi.fn(),
|
||||||
|
}: {
|
||||||
|
initialPanel: QuickEditPanelState;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
}) {
|
||||||
|
const [panel, setPanel] = useState<QuickEditPanelState | null>(initialPanel);
|
||||||
|
|
||||||
|
return panel ? (
|
||||||
|
<div>
|
||||||
|
<ImageCanvasQuickEditPanelView
|
||||||
|
panel={panel}
|
||||||
|
sourceLayer={createLayer()}
|
||||||
|
style={{ left: 10, top: 20 }}
|
||||||
|
sizeOptions={['1024x1024', '2048x1152']}
|
||||||
|
modelOptions={[
|
||||||
|
{ label: 'nanobanana2', value: 'nano' },
|
||||||
|
{ label: 'gpt-image-2', value: 'gpt' },
|
||||||
|
]}
|
||||||
|
setQuickEditPanel={setPanel}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
<output aria-label="当前提示词">{panel.prompt}</output>
|
||||||
|
<output aria-label="当前尺寸">{panel.size}</output>
|
||||||
|
<output aria-label="当前模型">{panel.model}</output>
|
||||||
|
<output aria-label="当前状态">{panel.status}</output>
|
||||||
|
<output aria-label="当前错误">{panel.errorMessage ?? '-'}</output>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<output aria-label="面板状态">closed</output>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageCanvasQuickEditPanelView', () => {
|
||||||
|
it('updates prompt, size, model and clears failed state', () => {
|
||||||
|
render(
|
||||||
|
<QuickEditPanelHarness
|
||||||
|
initialPanel={{
|
||||||
|
sourceLayerId: 'layer-a',
|
||||||
|
prompt: '旧提示',
|
||||||
|
size: '1024x1024',
|
||||||
|
model: 'nano',
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: '失败原因',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('快速编辑提示词'), {
|
||||||
|
target: { value: '新提示' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('快速编辑尺寸'), {
|
||||||
|
target: { value: '2048x1152' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('快速编辑模型'), {
|
||||||
|
target: { value: 'gpt' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('当前提示词').textContent).toBe('新提示');
|
||||||
|
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2048x1152');
|
||||||
|
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt');
|
||||||
|
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||||
|
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits and closes through its interface', () => {
|
||||||
|
const submitQuickEdit = vi.fn();
|
||||||
|
render(
|
||||||
|
<QuickEditPanelHarness
|
||||||
|
initialPanel={{
|
||||||
|
sourceLayerId: 'layer-a',
|
||||||
|
prompt: '提示',
|
||||||
|
size: '1024x1024',
|
||||||
|
model: 'nano',
|
||||||
|
status: 'idle',
|
||||||
|
}}
|
||||||
|
onSubmit={submitQuickEdit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭快速编辑图片' }));
|
||||||
|
|
||||||
|
expect(submitQuickEdit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByLabelText('面板状态').textContent).toBe('closed');
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/components/image-editor/ImageCanvasQuickEditPanelView.tsx
Normal file
139
src/components/image-editor/ImageCanvasQuickEditPanelView.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
|
import type {
|
||||||
|
CanvasLayer,
|
||||||
|
QuickEditPanelState,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
|
type ImageCanvasQuickEditPanelViewProps = {
|
||||||
|
panel: QuickEditPanelState;
|
||||||
|
sourceLayer: CanvasLayer;
|
||||||
|
style: CSSProperties;
|
||||||
|
sizeOptions: string[];
|
||||||
|
modelOptions: Array<{ label: string; value: string }>;
|
||||||
|
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
|
||||||
|
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 ImageCanvasQuickEditPanelView({
|
||||||
|
panel,
|
||||||
|
sourceLayer,
|
||||||
|
style,
|
||||||
|
sizeOptions,
|
||||||
|
modelOptions,
|
||||||
|
setQuickEditPanel,
|
||||||
|
onSubmit,
|
||||||
|
}: ImageCanvasQuickEditPanelViewProps) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="image-canvas-editor__quick-edit-panel"
|
||||||
|
style={style}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="快速编辑图片"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="image-canvas-editor__quick-edit-head">
|
||||||
|
<div className="image-canvas-editor__quick-edit-reference">
|
||||||
|
<img src={sourceLayer.src} alt={`${sourceLayer.title}参考图`} />
|
||||||
|
<span>{sourceLayer.title}</span>
|
||||||
|
</div>
|
||||||
|
<EditorIconButton
|
||||||
|
label="关闭快速编辑图片"
|
||||||
|
title="关闭"
|
||||||
|
icon={X}
|
||||||
|
onClick={() => setQuickEditPanel(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PlatformTextField
|
||||||
|
variant="textarea"
|
||||||
|
aria-label="快速编辑提示词"
|
||||||
|
value={panel.prompt}
|
||||||
|
size="sm"
|
||||||
|
density="compact"
|
||||||
|
className="image-canvas-editor__quick-edit-prompt"
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuickEditPanel((currentPanel) =>
|
||||||
|
currentPanel
|
||||||
|
? {
|
||||||
|
...resetFailedPanelStatus(currentPanel),
|
||||||
|
prompt: event.target.value,
|
||||||
|
}
|
||||||
|
: currentPanel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="image-canvas-editor__quick-edit-controls">
|
||||||
|
<PlatformSelectField
|
||||||
|
aria-label="快速编辑尺寸"
|
||||||
|
value={panel.size}
|
||||||
|
size="xs"
|
||||||
|
density="compact"
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuickEditPanel((currentPanel) =>
|
||||||
|
currentPanel
|
||||||
|
? { ...currentPanel, size: event.target.value }
|
||||||
|
: currentPanel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sizeOptions.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</PlatformSelectField>
|
||||||
|
<PlatformSelectField
|
||||||
|
aria-label="快速编辑模型"
|
||||||
|
value={panel.model}
|
||||||
|
size="xs"
|
||||||
|
density="compact"
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuickEditPanel((currentPanel) =>
|
||||||
|
currentPanel
|
||||||
|
? { ...currentPanel, model: event.target.value }
|
||||||
|
: currentPanel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{modelOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</PlatformSelectField>
|
||||||
|
</div>
|
||||||
|
{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__quick-edit-submit"
|
||||||
|
>
|
||||||
|
生成
|
||||||
|
</PlatformActionButton>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||||
|
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
|
||||||
|
|
||||||
|
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||||
|
return {
|
||||||
|
id: 'layer-1',
|
||||||
|
resourceId: 'resource-1',
|
||||||
|
title: '生成主图',
|
||||||
|
src: 'data:image/png;base64,layer',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
originalWidth: 320,
|
||||||
|
originalHeight: 240,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedToolbar(
|
||||||
|
overrides: Partial<Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]> = {},
|
||||||
|
) {
|
||||||
|
const props: Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0] = {
|
||||||
|
selectedLayer: createLayer(),
|
||||||
|
selectedToolbarStyle: { left: 12, top: 24 },
|
||||||
|
onDeleteSelectedLayer: vi.fn(),
|
||||||
|
onOpenQuickEditPanel: vi.fn(),
|
||||||
|
onOpenEditDialog: vi.fn(),
|
||||||
|
onOpenCharacterAnimationPanel: vi.fn(),
|
||||||
|
onOpenLayerMetadata: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ImageCanvasSelectedLayerToolbarView {...props} />);
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||||
|
it('renders common layer actions and forwards callbacks', () => {
|
||||||
|
const props = renderSelectedToolbar();
|
||||||
|
const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
|
||||||
|
|
||||||
|
fireEvent.click(within(toolbar).getByRole('button', { name: '删除图片' }));
|
||||||
|
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
|
||||||
|
|
||||||
|
expect(props.onDeleteSelectedLayer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(props.selectedLayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders generated and character actions only when applicable', () => {
|
||||||
|
const layer = createLayer({
|
||||||
|
sourceType: 'generated',
|
||||||
|
assetKind: 'character',
|
||||||
|
});
|
||||||
|
const props = renderSelectedToolbar({ selectedLayer: layer });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: `查看${layer.title}图片信息` }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(props.onOpenEditDialog).toHaveBeenCalledWith(layer);
|
||||||
|
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||||
|
expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing without a selected layer or toolbar position', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ImageCanvasSelectedLayerToolbarView
|
||||||
|
selectedLayer={null}
|
||||||
|
selectedToolbarStyle={{ left: 0, top: 0 }}
|
||||||
|
onDeleteSelectedLayer={vi.fn()}
|
||||||
|
onOpenQuickEditPanel={vi.fn()}
|
||||||
|
onOpenEditDialog={vi.fn()}
|
||||||
|
onOpenCharacterAnimationPanel={vi.fn()}
|
||||||
|
onOpenLayerMetadata={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ImageCanvasSelectedLayerToolbarView
|
||||||
|
selectedLayer={createLayer()}
|
||||||
|
selectedToolbarStyle={null}
|
||||||
|
onDeleteSelectedLayer={vi.fn()}
|
||||||
|
onOpenQuickEditPanel={vi.fn()}
|
||||||
|
onOpenEditDialog={vi.fn()}
|
||||||
|
onOpenCharacterAnimationPanel={vi.fn()}
|
||||||
|
onOpenLayerMetadata={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Crop,
|
||||||
|
Info,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
WandSparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
|
import { isGeneratedLayer } from './ImageCanvasEditorModel';
|
||||||
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
|
type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||||
|
selectedLayer: CanvasLayer | null;
|
||||||
|
selectedToolbarStyle: CSSProperties | null;
|
||||||
|
onDeleteSelectedLayer: () => void;
|
||||||
|
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
||||||
|
onOpenEditDialog: (layer: CanvasLayer) => void;
|
||||||
|
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||||
|
onOpenLayerMetadata: (layer: CanvasLayer) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const layerToolButtons = [
|
||||||
|
{ label: '裁剪', icon: Crop },
|
||||||
|
{ label: '重绘', icon: Sparkles },
|
||||||
|
{ label: '调整', icon: SlidersHorizontal },
|
||||||
|
{ label: '复制', icon: Copy },
|
||||||
|
];
|
||||||
|
|
||||||
|
function triggerPlaceholderAction(label: string) {
|
||||||
|
window.alert(`${label}功能建设中`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageCanvasSelectedLayerToolbarView({
|
||||||
|
selectedLayer,
|
||||||
|
selectedToolbarStyle,
|
||||||
|
onDeleteSelectedLayer,
|
||||||
|
onOpenQuickEditPanel,
|
||||||
|
onOpenEditDialog,
|
||||||
|
onOpenCharacterAnimationPanel,
|
||||||
|
onOpenLayerMetadata,
|
||||||
|
}: ImageCanvasSelectedLayerToolbarViewProps) {
|
||||||
|
if (!selectedLayer || !selectedToolbarStyle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__floating-toolbar"
|
||||||
|
style={selectedToolbarStyle}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="图片工具栏"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{layerToolButtons.map(({ label, icon: Icon }) => (
|
||||||
|
<EditorIconButton
|
||||||
|
key={label}
|
||||||
|
label={`${label}占位`}
|
||||||
|
title={`${label}占位`}
|
||||||
|
icon={Icon}
|
||||||
|
onClick={() => triggerPlaceholderAction(label)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<EditorIconButton
|
||||||
|
label="删除图片"
|
||||||
|
title="删除图片"
|
||||||
|
icon={Trash2}
|
||||||
|
onClick={onDeleteSelectedLayer}
|
||||||
|
/>
|
||||||
|
<EditorIconButton
|
||||||
|
label="快速编辑"
|
||||||
|
title="快速编辑"
|
||||||
|
icon={Sparkles}
|
||||||
|
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||||
|
/>
|
||||||
|
{isGeneratedLayer(selectedLayer) ? (
|
||||||
|
<>
|
||||||
|
<EditorIconButton
|
||||||
|
label={`查看${selectedLayer.title}图片信息`}
|
||||||
|
title={`查看${selectedLayer.title}图片信息`}
|
||||||
|
icon={Info}
|
||||||
|
onClick={() => onOpenLayerMetadata(selectedLayer)}
|
||||||
|
/>
|
||||||
|
<EditorIconButton
|
||||||
|
label="修改图片"
|
||||||
|
title="修改图片"
|
||||||
|
icon={WandSparkles}
|
||||||
|
onClick={() => onOpenEditDialog(selectedLayer)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{selectedLayer.assetKind === 'character' ? (
|
||||||
|
<EditorIconButton
|
||||||
|
label="生成动画"
|
||||||
|
title="生成动画"
|
||||||
|
icon={Sparkles}
|
||||||
|
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/components/image-editor/ImageCanvasSidebarView.test.tsx
Normal file
275
src/components/image-editor/ImageCanvasSidebarView.test.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { createRef } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView';
|
||||||
|
import {
|
||||||
|
ImageCanvasSidebarView,
|
||||||
|
type ImageCanvasSidebarViewProps,
|
||||||
|
} from './ImageCanvasSidebarView';
|
||||||
|
import type {
|
||||||
|
CanvasLayer,
|
||||||
|
EditorAsset,
|
||||||
|
EditorAssetFolder,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
||||||
|
|
||||||
|
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
|
||||||
|
return {
|
||||||
|
id: 'asset-1',
|
||||||
|
label: '账号素材A',
|
||||||
|
src: '/creation-type-references/puzzle.webp',
|
||||||
|
width: 640,
|
||||||
|
height: 640,
|
||||||
|
folderId: 'project',
|
||||||
|
sourceKind: 'uploaded',
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
persisted: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFolder(
|
||||||
|
overrides: Partial<EditorAssetFolder> = {},
|
||||||
|
): EditorAssetFolder {
|
||||||
|
return {
|
||||||
|
id: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
persisted: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||||
|
return {
|
||||||
|
id: 'layer-1',
|
||||||
|
resourceId: 'resource-1',
|
||||||
|
title: '图层A',
|
||||||
|
src: '/creation-type-references/puzzle.webp',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
originalWidth: 320,
|
||||||
|
originalHeight: 240,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSidebarProps(
|
||||||
|
overrides: Partial<ImageCanvasSidebarViewProps> = {},
|
||||||
|
): ImageCanvasSidebarViewProps {
|
||||||
|
const folder = createFolder();
|
||||||
|
const asset = createAsset();
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSidebarPanel: 'assets',
|
||||||
|
assetListRef: createRef<HTMLDivElement>(),
|
||||||
|
assetPointerDragRef: { current: null },
|
||||||
|
suppressAssetClickRef: { current: false },
|
||||||
|
assets: [asset],
|
||||||
|
groupedAssets: [{ ...folder, assets: [asset] }],
|
||||||
|
assetFolders: [folder],
|
||||||
|
layers: [createLayer()],
|
||||||
|
selectedLayerId: null,
|
||||||
|
selectedLayerIds: [],
|
||||||
|
isAssetSelectionMode: false,
|
||||||
|
selectedAssetIds: new Set(),
|
||||||
|
assetMoveDropFolderId: null,
|
||||||
|
pinnedAssetMoveFolderId: null,
|
||||||
|
creatingFolder: false,
|
||||||
|
newFolderName: '',
|
||||||
|
renamingFolder: null,
|
||||||
|
renamingAsset: null,
|
||||||
|
allSelectableAssetsSelected: false,
|
||||||
|
assetMarquee: null,
|
||||||
|
setIsAssetSelectionMode: vi.fn(),
|
||||||
|
setCreatingFolder: vi.fn(),
|
||||||
|
setNewFolderName: vi.fn(),
|
||||||
|
setRenamingFolder: vi.fn(),
|
||||||
|
setRenamingAsset: vi.fn(),
|
||||||
|
setActiveUploadFolderId: vi.fn(),
|
||||||
|
setUploadDropTarget: vi.fn(),
|
||||||
|
setAssetPointerDrag: vi.fn(),
|
||||||
|
setSelectedAssetIds: vi.fn(),
|
||||||
|
setImageContextMenu: vi.fn(),
|
||||||
|
setContextMenu: vi.fn(),
|
||||||
|
onAssetMarqueePointerDown: vi.fn(),
|
||||||
|
onAssetMarqueePointerMove: vi.fn(),
|
||||||
|
onAssetMarqueePointerUp: vi.fn(),
|
||||||
|
updateAssetMoveDropFolder: vi.fn(),
|
||||||
|
addUploadedFiles: vi.fn(),
|
||||||
|
requestUpload: vi.fn(),
|
||||||
|
moveAssetToFolder: vi.fn(),
|
||||||
|
commitNewAssetFolder: vi.fn(),
|
||||||
|
toggleAssetFolder: vi.fn(),
|
||||||
|
startRenamingFolder: vi.fn(),
|
||||||
|
commitFolderRename: vi.fn(),
|
||||||
|
deleteAssetFolder: vi.fn(),
|
||||||
|
startRenamingAsset: vi.fn(),
|
||||||
|
commitAssetRename: vi.fn(),
|
||||||
|
deleteUploadedAsset: vi.fn(),
|
||||||
|
toggleAssetSelected: vi.fn(),
|
||||||
|
addAssetLayer: vi.fn(),
|
||||||
|
toggleAllAssetsSelected: vi.fn(),
|
||||||
|
deleteSelectedAssets: vi.fn(),
|
||||||
|
closeAssetSelectionMode: vi.fn(),
|
||||||
|
groupSelectedLayers: vi.fn(),
|
||||||
|
selectSingleLayer: vi.fn(),
|
||||||
|
resolveContextMenuPosition: vi.fn(() => ({ x: 0, y: 0 })),
|
||||||
|
getCanvasPointFromClient: vi.fn(() => ({ x: 0, y: 0 })),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTabsHarness() {
|
||||||
|
const chrome = useImageCanvasEditorChrome({ openEditorLoginModal: vi.fn() });
|
||||||
|
const asset = createAsset();
|
||||||
|
const folder = createFolder();
|
||||||
|
const layer = createLayer({ title: '图层B', zIndex: 2 });
|
||||||
|
const sidebarProps = createSidebarProps({
|
||||||
|
activeSidebarPanel: chrome.activeSidebarPanel,
|
||||||
|
assets: [asset],
|
||||||
|
groupedAssets: [{ ...folder, assets: [asset] }],
|
||||||
|
assetFolders: [folder],
|
||||||
|
layers: [layer],
|
||||||
|
selectedLayerId: null,
|
||||||
|
selectedLayerIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ImageCanvasSidebarView {...sidebarProps} />
|
||||||
|
<ImageCanvasPanelDockView
|
||||||
|
viewport={{ x: 0, y: 0, scale: 1 }}
|
||||||
|
canvasBackgroundColor={chrome.canvasBackgroundColor}
|
||||||
|
canvasBackgroundHexValue={chrome.canvasBackgroundHexValue}
|
||||||
|
canUndo={false}
|
||||||
|
canRedo={false}
|
||||||
|
isZoomMenuOpen={false}
|
||||||
|
isBackgroundSettingsOpen={false}
|
||||||
|
activeSidebarPanel={chrome.activeSidebarPanel}
|
||||||
|
isMinimapOpen={false}
|
||||||
|
minimapModel={null}
|
||||||
|
onFitLayers={vi.fn()}
|
||||||
|
onUndoCanvasChange={vi.fn()}
|
||||||
|
onRedoCanvasChange={vi.fn()}
|
||||||
|
onUpdateScaleFromCenter={vi.fn()}
|
||||||
|
onToggleZoomMenu={vi.fn()}
|
||||||
|
onCloseZoomMenu={vi.fn()}
|
||||||
|
onToggleBackgroundSettings={vi.fn()}
|
||||||
|
onApplyCanvasBackgroundColor={vi.fn()}
|
||||||
|
onCanvasBackgroundHexChange={vi.fn()}
|
||||||
|
onToggleSidebarPanel={chrome.toggleSidebarPanel}
|
||||||
|
onToggleMinimap={vi.fn()}
|
||||||
|
onMinimapPointerDown={vi.fn()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageCanvasSidebarView', () => {
|
||||||
|
it('renders the asset library folders and asset actions', () => {
|
||||||
|
render(<ImageCanvasSidebarView {...createSidebarProps()} />);
|
||||||
|
|
||||||
|
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||||
|
const folder = within(sidebar).getByRole('region', { name: '项目素材' });
|
||||||
|
|
||||||
|
expect(within(sidebar).getByRole('heading', { name: '素材' })).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(folder).getByRole('button', { name: '添加账号素材A' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(folder).getByRole('button', { name: '上传到项目素材' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders asset editing inputs with sidebar text-field chrome', () => {
|
||||||
|
const folder = createFolder();
|
||||||
|
const asset = createAsset();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ImageCanvasSidebarView
|
||||||
|
{...createSidebarProps({
|
||||||
|
assets: [asset],
|
||||||
|
groupedAssets: [{ ...folder, assets: [asset] }],
|
||||||
|
assetFolders: [folder],
|
||||||
|
creatingFolder: true,
|
||||||
|
newFolderName: '角色上传',
|
||||||
|
renamingFolder: { folderId: folder.id, value: '角色参考' },
|
||||||
|
renamingAsset: { assetId: asset.id, value: '主视觉素材' },
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderNameInput = screen.getByLabelText('素材文件夹名称');
|
||||||
|
const folderRenameInput = screen.getByLabelText('重命名文件夹项目素材');
|
||||||
|
const assetRenameInput = screen.getByLabelText('重命名素材账号素材A');
|
||||||
|
|
||||||
|
expect(folderNameInput.className).toContain('platform-text-field');
|
||||||
|
expect(folderNameInput.className).toContain(
|
||||||
|
'image-canvas-editor__folder-create-input',
|
||||||
|
);
|
||||||
|
expect(folderRenameInput.className).toContain('platform-text-field');
|
||||||
|
expect(folderRenameInput.className).toContain(
|
||||||
|
'image-canvas-editor__folder-rename-input',
|
||||||
|
);
|
||||||
|
expect(assetRenameInput.className).toContain('platform-text-field');
|
||||||
|
expect(assetRenameInput.className).toContain(
|
||||||
|
'image-canvas-editor__asset-rename-input',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the layer panel with grouped and locked layer details', () => {
|
||||||
|
render(
|
||||||
|
<ImageCanvasSidebarView
|
||||||
|
{...createSidebarProps({
|
||||||
|
activeSidebarPanel: 'layers',
|
||||||
|
layers: [
|
||||||
|
createLayer({
|
||||||
|
title: '图层A',
|
||||||
|
groupId: 'group-1',
|
||||||
|
locked: true,
|
||||||
|
zIndex: 3,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
selectedLayerId: 'layer-1',
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||||
|
|
||||||
|
expect(within(sidebar).getByRole('heading', { name: '图层' })).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(sidebar).getByRole('button', { name: '选择图层图层A' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(sidebar).getByText('320 x 240 · 已打组 · 已锁定'),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sidebar tab switching and repeated-click closing behavior', () => {
|
||||||
|
render(<SidebarTabsHarness />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: '图层' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: '选择图层图层B' })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('complementary', { name: '图片资源栏' }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Check,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Folder,
|
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
ImagePlus,
|
|
||||||
Pencil,
|
|
||||||
PencilLine,
|
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
@@ -19,19 +10,12 @@ import type {
|
|||||||
SetStateAction,
|
SetStateAction,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
|
||||||
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
|
||||||
import { PlatformTextField } from '../common/PlatformTextField';
|
|
||||||
import {
|
import {
|
||||||
EditorIconButton,
|
ImageCanvasAssetLibraryPanelView,
|
||||||
SidebarMediaItem,
|
type GroupedEditorAssetFolder,
|
||||||
} from './ImageCanvasEditorPrimitives';
|
type UploadFilesOptions,
|
||||||
import {
|
} from './ImageCanvasAssetLibraryPanelView';
|
||||||
ASSET_DRAG_MIME_TYPE,
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
clamp,
|
|
||||||
getDraggedAssetId,
|
|
||||||
hasDataTransferType,
|
|
||||||
} from './ImageCanvasEditorModel';
|
|
||||||
import type {
|
import type {
|
||||||
AssetMarqueeState,
|
AssetMarqueeState,
|
||||||
AssetPointerDragState,
|
AssetPointerDragState,
|
||||||
@@ -43,18 +27,11 @@ import type {
|
|||||||
SidebarPanel,
|
SidebarPanel,
|
||||||
UploadTarget,
|
UploadTarget,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { ImageCanvasLayerPanelView } from './ImageCanvasLayerPanelView';
|
||||||
|
|
||||||
export type GroupedEditorAssetFolder = EditorAssetFolder & {
|
export type { GroupedEditorAssetFolder, UploadFilesOptions };
|
||||||
assets: EditorAsset[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type UploadFilesOptions = {
|
export type ImageCanvasSidebarViewProps = {
|
||||||
folderId?: string;
|
|
||||||
canvasPoint?: { x: number; y: number };
|
|
||||||
addToCanvas?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImageCanvasSidebarViewProps = {
|
|
||||||
activeSidebarPanel: SidebarPanel | null;
|
activeSidebarPanel: SidebarPanel | null;
|
||||||
assetListRef: RefObject<HTMLDivElement | null>;
|
assetListRef: RefObject<HTMLDivElement | null>;
|
||||||
assetPointerDragRef: { current: AssetPointerDragState | null };
|
assetPointerDragRef: { current: AssetPointerDragState | null };
|
||||||
@@ -234,591 +211,62 @@ export function ImageCanvasSidebarView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeSidebarPanel === 'assets' ? (
|
{activeSidebarPanel === 'assets' ? (
|
||||||
<div
|
<ImageCanvasAssetLibraryPanelView
|
||||||
ref={assetListRef}
|
assetListRef={assetListRef}
|
||||||
className="image-canvas-editor__asset-list"
|
assetPointerDragRef={assetPointerDragRef}
|
||||||
onPointerDown={onAssetMarqueePointerDown}
|
suppressAssetClickRef={suppressAssetClickRef}
|
||||||
onPointerMove={onAssetMarqueePointerMove}
|
groupedAssets={groupedAssets}
|
||||||
onPointerUp={onAssetMarqueePointerUp}
|
assetFolders={assetFolders}
|
||||||
onPointerCancel={onAssetMarqueePointerUp}
|
isAssetSelectionMode={isAssetSelectionMode}
|
||||||
>
|
selectedAssetIds={selectedAssetIds}
|
||||||
{pinnedAssetMoveFolderId ? (
|
assetMoveDropFolderId={assetMoveDropFolderId}
|
||||||
<div
|
pinnedAssetMoveFolderId={pinnedAssetMoveFolderId}
|
||||||
className="image-canvas-editor__asset-folder-sticky-target"
|
creatingFolder={creatingFolder}
|
||||||
aria-hidden="true"
|
newFolderName={newFolderName}
|
||||||
>
|
renamingFolder={renamingFolder}
|
||||||
<Folder className="h-4 w-4" />
|
renamingAsset={renamingAsset}
|
||||||
<span>
|
allSelectableAssetsSelected={allSelectableAssetsSelected}
|
||||||
{assetFolders.find(
|
assetMarquee={assetMarquee}
|
||||||
(folder) => folder.id === pinnedAssetMoveFolderId,
|
setCreatingFolder={setCreatingFolder}
|
||||||
)?.label ?? '目标文件夹'}
|
setNewFolderName={setNewFolderName}
|
||||||
</span>
|
setRenamingFolder={setRenamingFolder}
|
||||||
</div>
|
setRenamingAsset={setRenamingAsset}
|
||||||
) : null}
|
setActiveUploadFolderId={setActiveUploadFolderId}
|
||||||
{creatingFolder ? (
|
setUploadDropTarget={setUploadDropTarget}
|
||||||
<form
|
setAssetPointerDrag={setAssetPointerDrag}
|
||||||
className="image-canvas-editor__folder-create"
|
setSelectedAssetIds={setSelectedAssetIds}
|
||||||
onSubmit={(event) => {
|
onAssetMarqueePointerDown={onAssetMarqueePointerDown}
|
||||||
event.preventDefault();
|
onAssetMarqueePointerMove={onAssetMarqueePointerMove}
|
||||||
void commitNewAssetFolder();
|
onAssetMarqueePointerUp={onAssetMarqueePointerUp}
|
||||||
}}
|
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||||
>
|
addUploadedFiles={addUploadedFiles}
|
||||||
<PlatformTextField
|
requestUpload={requestUpload}
|
||||||
aria-label="素材文件夹名称"
|
moveAssetToFolder={moveAssetToFolder}
|
||||||
value={newFolderName}
|
commitNewAssetFolder={commitNewAssetFolder}
|
||||||
autoFocus
|
toggleAssetFolder={toggleAssetFolder}
|
||||||
size="xs"
|
startRenamingFolder={startRenamingFolder}
|
||||||
density="compact"
|
commitFolderRename={commitFolderRename}
|
||||||
className="image-canvas-editor__folder-create-input"
|
deleteAssetFolder={deleteAssetFolder}
|
||||||
onChange={(event) => setNewFolderName(event.target.value)}
|
startRenamingAsset={startRenamingAsset}
|
||||||
onKeyDown={(event) => {
|
commitAssetRename={commitAssetRename}
|
||||||
if (event.key === 'Escape') {
|
deleteUploadedAsset={deleteUploadedAsset}
|
||||||
event.preventDefault();
|
toggleAssetSelected={toggleAssetSelected}
|
||||||
setCreatingFolder(false);
|
addAssetLayer={addAssetLayer}
|
||||||
setNewFolderName('');
|
toggleAllAssetsSelected={toggleAllAssetsSelected}
|
||||||
}
|
deleteSelectedAssets={deleteSelectedAssets}
|
||||||
}}
|
closeAssetSelectionMode={closeAssetSelectionMode}
|
||||||
/>
|
/>
|
||||||
<EditorIconButton
|
|
||||||
type="submit"
|
|
||||||
label="保存素材文件夹"
|
|
||||||
icon={Check}
|
|
||||||
/>
|
|
||||||
<EditorIconButton
|
|
||||||
label="取消新建素材文件夹"
|
|
||||||
icon={X}
|
|
||||||
onClick={() => {
|
|
||||||
setCreatingFolder(false);
|
|
||||||
setNewFolderName('');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
{groupedAssets.map((folder) => (
|
|
||||||
<section
|
|
||||||
key={folder.id}
|
|
||||||
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) => {
|
|
||||||
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 key={asset.id} 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="image-canvas-editor__layers-list">
|
<ImageCanvasLayerPanelView
|
||||||
{layers
|
layers={layers}
|
||||||
.slice()
|
selectedLayerId={selectedLayerId}
|
||||||
.sort((left, right) => right.zIndex - left.zIndex)
|
selectedLayerIds={selectedLayerIds}
|
||||||
.map((layer) => (
|
setImageContextMenu={setImageContextMenu}
|
||||||
<SidebarMediaItem
|
setContextMenu={setContextMenu}
|
||||||
key={layer.id}
|
selectSingleLayer={selectSingleLayer}
|
||||||
title={layer.title}
|
resolveContextMenuPosition={resolveContextMenuPosition}
|
||||||
detail={[
|
getCanvasPointFromClient={getCanvasPointFromClient}
|
||||||
`${Math.round(layer.width)} x ${Math.round(layer.height)}`,
|
/>
|
||||||
layer.groupId ? '已打组' : null,
|
|
||||||
layer.hidden ? '已隐藏' : null,
|
|
||||||
layer.locked ? '已锁定' : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')}
|
|
||||||
imageSrc={layer.src}
|
|
||||||
imageAlt={`图层缩略图:${layer.title}`}
|
|
||||||
selected={selectedLayerId === layer.id}
|
|
||||||
primaryLabel={`选择图层${layer.title}`}
|
|
||||||
onPrimaryClick={() => selectSingleLayer(layer.id)}
|
|
||||||
rowClassName="image-canvas-editor__layer-row"
|
|
||||||
primaryClassName="image-canvas-editor__layer-row-button"
|
|
||||||
thumbnailClassName="image-canvas-editor__layer-row-thumb"
|
|
||||||
metaClassName="image-canvas-editor__layer-row-meta"
|
|
||||||
onContextMenu={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (!selectedLayerIds.includes(layer.id)) {
|
|
||||||
selectSingleLayer(layer.id);
|
|
||||||
}
|
|
||||||
const position = resolveContextMenuPosition(
|
|
||||||
event.clientX,
|
|
||||||
event.clientY,
|
|
||||||
'layer',
|
|
||||||
);
|
|
||||||
setImageContextMenu(null);
|
|
||||||
setContextMenu({
|
|
||||||
kind: 'layer',
|
|
||||||
layerId: layer.id,
|
|
||||||
...position,
|
|
||||||
canvasPoint: getCanvasPointFromClient(
|
|
||||||
event.clientX,
|
|
||||||
event.clientY,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GenerateDialogState,
|
||||||
|
SpecFormValues,
|
||||||
|
UploadTarget,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
|
||||||
|
|
||||||
|
function createSpecDialog(
|
||||||
|
patch: Partial<GenerateDialogState> = {},
|
||||||
|
): GenerateDialogState {
|
||||||
|
return {
|
||||||
|
mode: 'spec',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
specType: 'character',
|
||||||
|
specValues: {
|
||||||
|
playSetting: 'RPG玩法',
|
||||||
|
artStyle: '像素风',
|
||||||
|
bodyRatio: '3',
|
||||||
|
characterView: '右向斜侧身站姿',
|
||||||
|
customPrompt: '',
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel({
|
||||||
|
dialog,
|
||||||
|
onUpdateSpecFormValue = vi.fn(),
|
||||||
|
onRequestUpload = vi.fn(),
|
||||||
|
onSubmit = vi.fn(),
|
||||||
|
}: {
|
||||||
|
dialog: GenerateDialogState;
|
||||||
|
onUpdateSpecFormValue?: (key: keyof SpecFormValues, value: string) => void;
|
||||||
|
onRequestUpload?: (target: UploadTarget) => void;
|
||||||
|
onSubmit?: (dialog: GenerateDialogState) => void;
|
||||||
|
}) {
|
||||||
|
render(
|
||||||
|
<ImageCanvasSpecGenerationPanelView
|
||||||
|
dialog={dialog}
|
||||||
|
style={{ left: 10, top: 20 }}
|
||||||
|
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
||||||
|
onRequestUpload={onRequestUpload}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||||
|
it('renders character spec fields and forwards updates', () => {
|
||||||
|
const updateSpecFormValue = vi.fn();
|
||||||
|
renderPanel({
|
||||||
|
dialog: createSpecDialog(),
|
||||||
|
onUpdateSpecFormValue: updateSpecFormValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||||||
|
target: { value: '战棋玩法' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('美术风格'), {
|
||||||
|
target: { value: '水彩' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('头身比'), {
|
||||||
|
target: { value: '5' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('角色视角'), {
|
||||||
|
target: { value: '左向三分之二侧身站姿' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateSpecFormValue).toHaveBeenCalledWith('playSetting', '战棋玩法');
|
||||||
|
expect(updateSpecFormValue).toHaveBeenCalledWith('artStyle', '水彩');
|
||||||
|
expect(updateSpecFormValue).toHaveBeenCalledWith('bodyRatio', '5');
|
||||||
|
expect(updateSpecFormValue).toHaveBeenCalledWith(
|
||||||
|
'characterView',
|
||||||
|
'左向三分之二侧身站姿',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom prompt and hides reference upload for icon specs', () => {
|
||||||
|
const updateSpecFormValue = vi.fn();
|
||||||
|
renderPanel({
|
||||||
|
dialog: createSpecDialog({
|
||||||
|
specType: 'custom',
|
||||||
|
specValues: {
|
||||||
|
playSetting: '',
|
||||||
|
artStyle: '',
|
||||||
|
bodyRatio: '3',
|
||||||
|
characterView: '',
|
||||||
|
customPrompt: '自定义提示',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onUpdateSpecFormValue: updateSpecFormValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
|
||||||
|
target: { value: '新的规范提示' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateSpecFormValue).toHaveBeenCalledWith(
|
||||||
|
'customPrompt',
|
||||||
|
'新的规范提示',
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
renderPanel({ dialog: createSpecDialog({ specType: 'icon' }) });
|
||||||
|
|
||||||
|
expect(screen.queryByText('添加参考图')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests reference upload and submits while idle', () => {
|
||||||
|
const requestUpload = vi.fn();
|
||||||
|
const submitSpec = vi.fn();
|
||||||
|
const dialog = createSpecDialog();
|
||||||
|
renderPanel({
|
||||||
|
dialog,
|
||||||
|
onRequestUpload: requestUpload,
|
||||||
|
onSubmit: submitSpec,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
|
||||||
|
|
||||||
|
expect(requestUpload).toHaveBeenCalledWith('spec-reference');
|
||||||
|
expect(submitSpec).toHaveBeenCalledWith(dialog);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables controls while generating and renders failure state', () => {
|
||||||
|
const submitSpec = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<ImageCanvasSpecGenerationPanelView
|
||||||
|
dialog={createSpecDialog({ status: 'generating' })}
|
||||||
|
style={{ left: 10, top: 20 }}
|
||||||
|
onUpdateSpecFormValue={vi.fn()}
|
||||||
|
onRequestUpload={vi.fn()}
|
||||||
|
onSubmit={submitSpec}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(submitSpec).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ImageCanvasSpecGenerationPanelView
|
||||||
|
dialog={createSpecDialog({
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: '生成失败',
|
||||||
|
})}
|
||||||
|
style={{ left: 10, top: 20 }}
|
||||||
|
onUpdateSpecFormValue={vi.fn()}
|
||||||
|
onRequestUpload={vi.fn()}
|
||||||
|
onSubmit={submitSpec}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert').textContent).toContain('生成失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { type CSSProperties } from 'react';
|
||||||
|
import { ImagePlus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import {
|
||||||
|
PlatformSelectField,
|
||||||
|
PlatformTextField,
|
||||||
|
} from '../common/PlatformTextField';
|
||||||
|
import {
|
||||||
|
CHARACTER_SPEC_VIEW_OPTIONS,
|
||||||
|
SPEC_GENERATION_COST,
|
||||||
|
} from './ImageCanvasGenerationModel';
|
||||||
|
import type {
|
||||||
|
GenerateDialogState,
|
||||||
|
SpecFormValues,
|
||||||
|
UploadTarget,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
|
type ImageCanvasSpecGenerationPanelViewProps = {
|
||||||
|
dialog: GenerateDialogState;
|
||||||
|
style: CSSProperties;
|
||||||
|
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||||
|
onRequestUpload: (target: UploadTarget) => void;
|
||||||
|
onSubmit: (dialog: GenerateDialogState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageCanvasSpecGenerationPanelView({
|
||||||
|
dialog,
|
||||||
|
style,
|
||||||
|
onUpdateSpecFormValue,
|
||||||
|
onRequestUpload,
|
||||||
|
onSubmit,
|
||||||
|
}: ImageCanvasSpecGenerationPanelViewProps) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="image-canvas-editor__generation-composer image-canvas-editor__spec-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__spec-fields">
|
||||||
|
{dialog.specType === 'custom' ? (
|
||||||
|
<label className="image-canvas-editor__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="form"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
自定义规范提示词
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<PlatformTextField
|
||||||
|
variant="textarea"
|
||||||
|
aria-label="自定义规范提示词"
|
||||||
|
value={dialog.specValues?.customPrompt ?? ''}
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
size="sm"
|
||||||
|
density="compact"
|
||||||
|
className="image-canvas-editor__spec-textarea"
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateSpecFormValue('customPrompt', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="image-canvas-editor__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="form"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
玩法设定
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<PlatformTextField
|
||||||
|
aria-label="玩法设定"
|
||||||
|
value={dialog.specValues?.playSetting ?? ''}
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
size="sm"
|
||||||
|
density="compact"
|
||||||
|
className="image-canvas-editor__spec-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateSpecFormValue('playSetting', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="image-canvas-editor__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="form"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
美术风格
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<PlatformTextField
|
||||||
|
aria-label="美术风格"
|
||||||
|
value={dialog.specValues?.artStyle ?? ''}
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
size="sm"
|
||||||
|
density="compact"
|
||||||
|
className="image-canvas-editor__spec-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateSpecFormValue('artStyle', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{dialog.specType === 'character' ? (
|
||||||
|
<>
|
||||||
|
<label className="image-canvas-editor__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="form"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
头身比
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<PlatformSelectField
|
||||||
|
aria-label="头身比"
|
||||||
|
value={dialog.specValues?.bodyRatio ?? '3'}
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
size="sm"
|
||||||
|
density="compact"
|
||||||
|
className="image-canvas-editor__spec-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateSpecFormValue('bodyRatio', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{['2', '3', '4', '5', '6'].map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</PlatformSelectField>
|
||||||
|
</label>
|
||||||
|
<label className="image-canvas-editor__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="form"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
角色视角
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<PlatformSelectField
|
||||||
|
aria-label="角色视角"
|
||||||
|
value={dialog.specValues?.characterView ?? ''}
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
size="sm"
|
||||||
|
density="compact"
|
||||||
|
className="image-canvas-editor__spec-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateSpecFormValue('characterView', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{CHARACTER_SPEC_VIEW_OPTIONS.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</PlatformSelectField>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dialog.specType !== 'icon' ? (
|
||||||
|
<div className="image-canvas-editor__field-block">
|
||||||
|
<PlatformFieldLabel
|
||||||
|
variant="form"
|
||||||
|
className="image-canvas-editor__field-title"
|
||||||
|
>
|
||||||
|
参考图
|
||||||
|
</PlatformFieldLabel>
|
||||||
|
<button
|
||||||
|
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={() => onRequestUpload('spec-reference')}
|
||||||
|
>
|
||||||
|
<span className="image-canvas-editor__reference-tile-visual">
|
||||||
|
{dialog.specReference ? (
|
||||||
|
<img src={dialog.specReference.src} alt="" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="image-canvas-editor__reference-tile-copy">
|
||||||
|
{dialog.specReference?.label ?? '添加参考图'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{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__spec-footer">
|
||||||
|
<PlatformActionButton
|
||||||
|
type="submit"
|
||||||
|
tone="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="image-canvas-editor__spec-submit"
|
||||||
|
disabled={dialog.status === 'generating'}
|
||||||
|
aria-label="提交生成规范"
|
||||||
|
>
|
||||||
|
{dialog.status === 'generating'
|
||||||
|
? '生成中'
|
||||||
|
: `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
|
||||||
|
</PlatformActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,3 @@
|
|||||||
import {
|
|
||||||
Braces,
|
|
||||||
ChevronDown,
|
|
||||||
ClipboardList,
|
|
||||||
Copy,
|
|
||||||
Crop,
|
|
||||||
Download,
|
|
||||||
Hand,
|
|
||||||
ImageIcon,
|
|
||||||
ImagePlus,
|
|
||||||
Info,
|
|
||||||
Layers,
|
|
||||||
Map as MapIcon,
|
|
||||||
MousePointer2,
|
|
||||||
Redo2,
|
|
||||||
RotateCcw,
|
|
||||||
Shapes,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Sparkles,
|
|
||||||
Trash2,
|
|
||||||
Type,
|
|
||||||
Undo2,
|
|
||||||
WandSparkles,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type {
|
import type {
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
DragEvent as ReactDragEvent,
|
DragEvent as ReactDragEvent,
|
||||||
@@ -32,28 +7,12 @@ import type {
|
|||||||
RefObject,
|
RefObject,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import {
|
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
|
||||||
PlatformFloatingMenu,
|
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||||
PlatformFloatingMenuItem,
|
import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView';
|
||||||
} from '../common/PlatformFloatingMenu';
|
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
|
||||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
|
||||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
|
||||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
|
||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|
||||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
|
||||||
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||||
import {
|
|
||||||
CANVAS_BACKGROUND_OPTIONS,
|
|
||||||
CANVAS_WORLD_SIZE,
|
|
||||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
||||||
formatPercent,
|
|
||||||
isGeneratedLayer,
|
|
||||||
} from './ImageCanvasEditorModel';
|
|
||||||
import {
|
|
||||||
getGenerationFrameAriaLabel,
|
|
||||||
getGenerationFrameLabel,
|
|
||||||
getLayerKindLabel,
|
|
||||||
} from './ImageCanvasGenerationModel';
|
|
||||||
import type {
|
import type {
|
||||||
CanvasClipboard,
|
CanvasClipboard,
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
@@ -69,7 +28,7 @@ import type {
|
|||||||
SnapGuide,
|
SnapGuide,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
type ImageCanvasStageViewProps = {
|
export type ImageCanvasStageViewProps = {
|
||||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||||
isPanning: boolean;
|
isPanning: boolean;
|
||||||
@@ -163,34 +122,6 @@ type ImageCanvasStageViewProps = {
|
|||||||
onSwitchTool: (tool: CanvasTool) => void;
|
onSwitchTool: (tool: CanvasTool) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const layerToolButtons = [
|
|
||||||
{ label: '裁剪', icon: Crop },
|
|
||||||
{ label: '重绘', icon: Sparkles },
|
|
||||||
{ label: '调整', icon: SlidersHorizontal },
|
|
||||||
{ label: '复制', icon: Copy },
|
|
||||||
];
|
|
||||||
|
|
||||||
const canvasTools: Array<{
|
|
||||||
id: CanvasTool;
|
|
||||||
label: string;
|
|
||||||
icon: typeof MousePointer2;
|
|
||||||
}> = [
|
|
||||||
{ id: 'select', label: '选择工具', icon: MousePointer2 },
|
|
||||||
{ id: 'hand', label: '抓手工具', icon: Hand },
|
|
||||||
{ id: 'upload', label: '上传工具', icon: ImagePlus },
|
|
||||||
{ id: 'generate', label: '生成工具', icon: WandSparkles },
|
|
||||||
{ id: 'spec', label: '生成规范', icon: ClipboardList },
|
|
||||||
{ id: 'character', label: '生成角色形象', icon: Sparkles },
|
|
||||||
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
|
|
||||||
{ id: 'text', label: '文字工具', icon: Type },
|
|
||||||
{ id: 'shape', label: '形状标注工具', icon: Shapes },
|
|
||||||
{ id: 'export', label: '导出工具', icon: Download },
|
|
||||||
];
|
|
||||||
|
|
||||||
function triggerPlaceholderAction(label: string) {
|
|
||||||
window.alert(`${label}功能建设中`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImageCanvasStageView({
|
export function ImageCanvasStageView({
|
||||||
canvasViewportRef,
|
canvasViewportRef,
|
||||||
specToolWrapRef,
|
specToolWrapRef,
|
||||||
@@ -296,807 +227,96 @@ export function ImageCanvasStageView({
|
|||||||
<strong>松开即可添加</strong>
|
<strong>松开即可添加</strong>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<ImageCanvasWorldView
|
||||||
className="image-canvas-editor__world"
|
viewport={viewport}
|
||||||
style={{
|
snapGuide={snapGuide}
|
||||||
width: CANVAS_WORLD_SIZE,
|
layers={layers}
|
||||||
height: CANVAS_WORLD_SIZE,
|
selectedLayerIds={selectedLayerIds}
|
||||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
hoveredLayerId={hoveredLayerId}
|
||||||
}}
|
canvasMarquee={canvasMarquee}
|
||||||
>
|
canvasGenerationDialogs={canvasGenerationDialogs}
|
||||||
{snapGuide?.vertical !== undefined ? (
|
generateDialog={generateDialog}
|
||||||
<div
|
quickEditPanel={quickEditPanel}
|
||||||
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--vertical"
|
generationComposerStyle={generationComposerStyle}
|
||||||
data-testid="image-canvas-editor-snap-guide-vertical"
|
onLayerPointerDown={onLayerPointerDown}
|
||||||
style={{ left: snapGuide.vertical }}
|
onLayerClick={onLayerClick}
|
||||||
/>
|
onLayerContextMenu={onLayerContextMenu}
|
||||||
) : null}
|
onLayerMouseEnter={onLayerMouseEnter}
|
||||||
{snapGuide?.horizontal !== undefined ? (
|
onLayerMouseLeave={onLayerMouseLeave}
|
||||||
<div
|
onOpenLayerMetadata={onOpenLayerMetadata}
|
||||||
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--horizontal"
|
onGenerationFramePointerDown={onGenerationFramePointerDown}
|
||||||
data-testid="image-canvas-editor-snap-guide-horizontal"
|
onActivateGenerationDialog={onActivateGenerationDialog}
|
||||||
style={{ top: snapGuide.horizontal }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{layers
|
|
||||||
.slice()
|
|
||||||
.filter((layer) => !layer.hidden)
|
|
||||||
.sort((left, right) => left.zIndex - right.zIndex)
|
|
||||||
.map((layer) => {
|
|
||||||
const isSelected = selectedLayerIds.includes(layer.id);
|
|
||||||
const isHovered = hoveredLayerId === layer.id;
|
|
||||||
const kindLabel = getLayerKindLabel(layer);
|
|
||||||
const layerGeneratingLabel =
|
|
||||||
generateDialog?.mode === 'edit' &&
|
|
||||||
generateDialog.status === 'generating' &&
|
|
||||||
generateDialog.sourceLayerId === layer.id
|
|
||||||
? '修改中'
|
|
||||||
: quickEditPanel?.status === 'generating' &&
|
|
||||||
quickEditPanel.sourceLayerId === layer.id
|
|
||||||
? '生成中'
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={layer.id}
|
|
||||||
type="button"
|
|
||||||
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
|
|
||||||
style={{
|
|
||||||
left: layer.x,
|
|
||||||
top: layer.y,
|
|
||||||
width: layer.width,
|
|
||||||
height: layer.height,
|
|
||||||
zIndex: layer.zIndex,
|
|
||||||
display: layer.hidden ? 'none' : undefined,
|
|
||||||
}}
|
|
||||||
onPointerDown={(event) => onLayerPointerDown(event, layer)}
|
|
||||||
onClick={(event) => onLayerClick(event, layer)}
|
|
||||||
onContextMenu={(event) => onLayerContextMenu(event, layer)}
|
|
||||||
onMouseEnter={() => onLayerMouseEnter(layer.id)}
|
|
||||||
onMouseLeave={() => onLayerMouseLeave(layer.id)}
|
|
||||||
aria-label={`选择${layer.title}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={layer.src}
|
|
||||||
alt={`画布图片:${layer.title}`}
|
|
||||||
style={{
|
|
||||||
transform:
|
|
||||||
layer.flipX || layer.flipY
|
|
||||||
? `scale(${layer.flipX ? -1 : 1}, ${
|
|
||||||
layer.flipY ? -1 : 1
|
|
||||||
})`
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{kindLabel ? (
|
|
||||||
<span
|
|
||||||
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
|
|
||||||
>
|
|
||||||
{kindLabel}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<PlatformIconButton
|
|
||||||
asChild="spanButton"
|
|
||||||
variant="darkMini"
|
|
||||||
className={`image-canvas-editor__metadata-corner ${
|
|
||||||
kindLabel
|
|
||||||
? 'image-canvas-editor__metadata-corner--beside-kind'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
label={`查看${layer.title}图片信息`}
|
|
||||||
icon={<Braces className="h-3 w-3" />}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onOpenLayerMetadata(layer);
|
|
||||||
}}
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
/>
|
|
||||||
{isHovered ? (
|
|
||||||
<PlatformPillBadge
|
|
||||||
tone="lightOverlay"
|
|
||||||
size="xs"
|
|
||||||
className="image-canvas-editor__size-badge"
|
|
||||||
>
|
|
||||||
{Math.round(layer.originalWidth)} x{' '}
|
|
||||||
{Math.round(layer.originalHeight)} px
|
|
||||||
</PlatformPillBadge>
|
|
||||||
) : null}
|
|
||||||
{layerGeneratingLabel ? (
|
|
||||||
<span
|
|
||||||
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
{layerGeneratingLabel}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{canvasMarquee ? (
|
|
||||||
<div
|
|
||||||
className="image-canvas-editor__canvas-marquee"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
left:
|
|
||||||
(Math.min(canvasMarquee.startX, canvasMarquee.currentX) -
|
|
||||||
viewport.x) /
|
|
||||||
viewport.scale,
|
|
||||||
top:
|
|
||||||
(Math.min(canvasMarquee.startY, canvasMarquee.currentY) -
|
|
||||||
viewport.y) /
|
|
||||||
viewport.scale,
|
|
||||||
width:
|
|
||||||
Math.abs(canvasMarquee.currentX - canvasMarquee.startX) /
|
|
||||||
viewport.scale,
|
|
||||||
height:
|
|
||||||
Math.abs(canvasMarquee.currentY - canvasMarquee.startY) /
|
|
||||||
viewport.scale,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{canvasGenerationDialogs.map((dialog) =>
|
|
||||||
dialog.placeholder ? (
|
|
||||||
<div
|
|
||||||
key={dialog.id}
|
|
||||||
className={`image-canvas-editor__generation-frame ${
|
|
||||||
dialog.mode === 'icon'
|
|
||||||
? 'image-canvas-editor__generation-frame--icon'
|
|
||||||
: ''
|
|
||||||
} ${
|
|
||||||
dialog.status === 'generating'
|
|
||||||
? 'image-canvas-editor__generation-frame--generating'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
style={{
|
|
||||||
left: dialog.placeholder.x,
|
|
||||||
top: dialog.placeholder.y,
|
|
||||||
width: dialog.placeholder.width,
|
|
||||||
height: dialog.placeholder.height,
|
|
||||||
}}
|
|
||||||
aria-label={getGenerationFrameAriaLabel(dialog)}
|
|
||||||
onPointerDown={(event) =>
|
|
||||||
onGenerationFramePointerDown(event, dialog)
|
|
||||||
}
|
|
||||||
onDoubleClick={() => onActivateGenerationDialog(dialog)}
|
|
||||||
>
|
|
||||||
<span className="image-canvas-editor__generation-frame-label">
|
|
||||||
<ImageIcon className="h-4 w-4" />
|
|
||||||
{getGenerationFrameLabel(dialog)}
|
|
||||||
</span>
|
|
||||||
{dialog.mode === 'character' ? (
|
|
||||||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--character">
|
|
||||||
角色
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{dialog.mode === 'spec' ? (
|
|
||||||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--spec">
|
|
||||||
规范
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{dialog.mode === 'icon' ? (
|
|
||||||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--icon">
|
|
||||||
图标
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="image-canvas-editor__generation-frame-size">
|
|
||||||
{dialog.placeholder.originalWidth} x{' '}
|
|
||||||
{dialog.placeholder.originalHeight}
|
|
||||||
</span>
|
|
||||||
<span className="image-canvas-editor__generation-frame-icon">
|
|
||||||
<ImageIcon className="h-8 w-8" />
|
|
||||||
</span>
|
|
||||||
{dialog.status === 'generating' ? (
|
|
||||||
<span
|
|
||||||
className="image-canvas-editor__generation-frame-progress"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
生成中
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
{(generateDialog?.mode === 'generate' ||
|
|
||||||
generateDialog?.mode === 'spec' ||
|
|
||||||
generateDialog?.mode === 'character' ||
|
|
||||||
generateDialog?.mode === 'icon') &&
|
|
||||||
generateDialog.status === 'generating' &&
|
|
||||||
generationComposerStyle ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="info"
|
|
||||||
surface="platform"
|
|
||||||
size="xs"
|
|
||||||
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--floating"
|
|
||||||
role="status"
|
|
||||||
style={generationComposerStyle}
|
|
||||||
>
|
|
||||||
生成中
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedLayer && selectedToolbarStyle ? (
|
|
||||||
<div
|
|
||||||
className="image-canvas-editor__floating-toolbar"
|
|
||||||
style={selectedToolbarStyle}
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="图片工具栏"
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
{layerToolButtons.map(({ label, icon: Icon }) => (
|
|
||||||
<EditorIconButton
|
|
||||||
key={label}
|
|
||||||
label={`${label}占位`}
|
|
||||||
title={`${label}占位`}
|
|
||||||
icon={Icon}
|
|
||||||
onClick={() => triggerPlaceholderAction(label)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<EditorIconButton
|
|
||||||
label="删除图片"
|
|
||||||
title="删除图片"
|
|
||||||
icon={Trash2}
|
|
||||||
onClick={onDeleteSelectedLayer}
|
|
||||||
/>
|
|
||||||
<EditorIconButton
|
|
||||||
label="快速编辑"
|
|
||||||
title="快速编辑"
|
|
||||||
icon={Sparkles}
|
|
||||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
|
||||||
/>
|
|
||||||
{isGeneratedLayer(selectedLayer) ? (
|
|
||||||
<>
|
|
||||||
<EditorIconButton
|
|
||||||
label={`查看${selectedLayer.title}图片信息`}
|
|
||||||
title={`查看${selectedLayer.title}图片信息`}
|
|
||||||
icon={Info}
|
|
||||||
onClick={() => onOpenLayerMetadata(selectedLayer)}
|
|
||||||
/>
|
|
||||||
<EditorIconButton
|
|
||||||
label="修改图片"
|
|
||||||
title="修改图片"
|
|
||||||
icon={WandSparkles}
|
|
||||||
onClick={() => onOpenEditDialog(selectedLayer)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{selectedLayer.assetKind === 'character' ? (
|
|
||||||
<EditorIconButton
|
|
||||||
label="生成动画"
|
|
||||||
title="生成动画"
|
|
||||||
icon={Sparkles}
|
|
||||||
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{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}
|
|
||||||
|
|
||||||
<EditorIconButton
|
|
||||||
className="image-canvas-editor__reset-button"
|
|
||||||
label="重置画布视图"
|
|
||||||
title="重置画布视图"
|
|
||||||
icon={RotateCcw}
|
|
||||||
onClick={() => onFitLayers()}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<ImageCanvasSelectedLayerToolbarView
|
||||||
className="image-canvas-editor__panel-dock"
|
selectedLayer={selectedLayer}
|
||||||
role="toolbar"
|
selectedToolbarStyle={selectedToolbarStyle}
|
||||||
aria-label="画布面板入口"
|
onDeleteSelectedLayer={onDeleteSelectedLayer}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onOpenQuickEditPanel={onOpenQuickEditPanel}
|
||||||
>
|
onOpenEditDialog={onOpenEditDialog}
|
||||||
<EditorIconButton
|
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
|
||||||
label="撤销"
|
onOpenLayerMetadata={onOpenLayerMetadata}
|
||||||
title="撤销"
|
/>
|
||||||
icon={Undo2}
|
|
||||||
disabled={!canUndo}
|
|
||||||
onClick={onUndoCanvasChange}
|
|
||||||
/>
|
|
||||||
<EditorIconButton
|
|
||||||
label="重做"
|
|
||||||
title="重做"
|
|
||||||
icon={Redo2}
|
|
||||||
disabled={!canRedo}
|
|
||||||
onClick={onRedoCanvasChange}
|
|
||||||
/>
|
|
||||||
<div className="image-canvas-editor__zoom-menu-wrap">
|
|
||||||
<PlatformInlineOptionButton
|
|
||||||
className="image-canvas-editor__zoom-trigger"
|
|
||||||
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={isZoomMenuOpen}
|
|
||||||
onClick={onToggleZoomMenu}
|
|
||||||
>
|
|
||||||
{formatPercent(viewport.scale)}
|
|
||||||
</PlatformInlineOptionButton>
|
|
||||||
{isZoomMenuOpen ? (
|
|
||||||
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
onUpdateScaleFromCenter(viewport.scale * 1.16);
|
|
||||||
onCloseZoomMenu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
放大
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
onUpdateScaleFromCenter(viewport.scale * 0.86);
|
|
||||||
onCloseZoomMenu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
缩小
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
onFitLayers();
|
|
||||||
onCloseZoomMenu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
显示画布所有元素
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
{[0.5, 1, 2].map((scale) => (
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
key={scale}
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
onUpdateScaleFromCenter(scale);
|
|
||||||
onCloseZoomMenu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
缩放至{Math.round(scale * 100)}%
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
))}
|
|
||||||
</PlatformFloatingMenu>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="image-canvas-editor__background-control">
|
|
||||||
<PlatformIconButton
|
|
||||||
label="画布背景色"
|
|
||||||
title="画布背景色"
|
|
||||||
aria-expanded={isBackgroundSettingsOpen}
|
|
||||||
onClick={onToggleBackgroundSettings}
|
|
||||||
icon={
|
|
||||||
<span
|
|
||||||
className="image-canvas-editor__background-swatch-current"
|
|
||||||
style={{ backgroundColor: canvasBackgroundColor }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{isBackgroundSettingsOpen ? (
|
|
||||||
<div
|
|
||||||
className="image-canvas-editor__background-panel"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="画布背景设置"
|
|
||||||
>
|
|
||||||
<div className="image-canvas-editor__background-panel-head">
|
|
||||||
<span>画布背景</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="image-canvas-editor__background-close"
|
|
||||||
aria-label="关闭画布背景设置"
|
|
||||||
onClick={onToggleBackgroundSettings}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="image-canvas-editor__background-current-row">
|
|
||||||
<span
|
|
||||||
className="image-canvas-editor__background-current-preview"
|
|
||||||
style={{ backgroundColor: canvasBackgroundColor }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>{canvasBackgroundColor}</span>
|
|
||||||
</div>
|
|
||||||
<label className="image-canvas-editor__background-spectrum">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
aria-label="画布背景色相"
|
|
||||||
value={canvasBackgroundColor}
|
|
||||||
onChange={(event) =>
|
|
||||||
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="image-canvas-editor__background-spectrum-surface"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="image-canvas-editor__background-spectrum-handle"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="image-canvas-editor__background-hue">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
aria-label="自定义画布背景色"
|
|
||||||
value={canvasBackgroundColor}
|
|
||||||
onChange={(event) =>
|
|
||||||
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="image-canvas-editor__background-presets"
|
|
||||||
aria-label="画布背景预设色"
|
|
||||||
>
|
|
||||||
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className="image-canvas-editor__background-preset"
|
|
||||||
aria-label={option.label}
|
|
||||||
aria-pressed={canvasBackgroundColor === option.value}
|
|
||||||
onClick={() => onApplyCanvasBackgroundColor(option.value)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="image-canvas-editor__background-swatch"
|
|
||||||
style={{ backgroundColor: option.value }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="image-canvas-editor__background-footer">
|
|
||||||
<label className="image-canvas-editor__background-hex-field">
|
|
||||||
<span>HEX</span>
|
|
||||||
<input
|
|
||||||
aria-label="画布背景十六进制颜色"
|
|
||||||
value={canvasBackgroundHexValue}
|
|
||||||
spellCheck={false}
|
|
||||||
onChange={(event) =>
|
|
||||||
onCanvasBackgroundHexChange(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="image-canvas-editor__background-reset"
|
|
||||||
onClick={() =>
|
|
||||||
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
恢复默认
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<EditorIconButton
|
|
||||||
label="打开素材"
|
|
||||||
title="素材"
|
|
||||||
icon={ImagePlus}
|
|
||||||
pressed={activeSidebarPanel === 'assets'}
|
|
||||||
onClick={() => onToggleSidebarPanel('assets')}
|
|
||||||
/>
|
|
||||||
<EditorIconButton
|
|
||||||
label="打开图层"
|
|
||||||
title="图层"
|
|
||||||
icon={Layers}
|
|
||||||
pressed={activeSidebarPanel === 'layers'}
|
|
||||||
onClick={() => onToggleSidebarPanel('layers')}
|
|
||||||
/>
|
|
||||||
<EditorIconButton
|
|
||||||
label="切换小地图"
|
|
||||||
title="小地图"
|
|
||||||
icon={MapIcon}
|
|
||||||
pressed={isMinimapOpen}
|
|
||||||
onClick={onToggleMinimap}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isMinimapOpen && minimapModel ? (
|
<ImageCanvasContextMenusView
|
||||||
<button
|
viewport={viewport}
|
||||||
type="button"
|
contextMenu={contextMenu}
|
||||||
className="image-canvas-editor__minimap"
|
canvasClipboard={canvasClipboard}
|
||||||
aria-label="画布小地图"
|
imageContextMenu={imageContextMenu}
|
||||||
title="拖拽移动视图"
|
imageContextMenuLayer={imageContextMenuLayer}
|
||||||
onPointerDown={onMinimapPointerDown}
|
contextShouldShowLayer={contextShouldShowLayer}
|
||||||
>
|
contextShouldUnlockLayer={contextShouldUnlockLayer}
|
||||||
<span className="image-canvas-editor__minimap-stage">
|
onPasteCanvasClipboard={onPasteCanvasClipboard}
|
||||||
{minimapModel.layers.map((layer) => (
|
onCopyContextLayers={onCopyContextLayers}
|
||||||
<span
|
onDuplicateContextLayers={onDuplicateContextLayers}
|
||||||
key={layer.id}
|
onMoveContextLayers={onMoveContextLayers}
|
||||||
className="image-canvas-editor__minimap-layer"
|
onGroupContextLayers={onGroupContextLayers}
|
||||||
title={layer.title}
|
onUngroupContextLayers={onUngroupContextLayers}
|
||||||
style={layer.rect}
|
onToggleContextLayerVisibility={onToggleContextLayerVisibility}
|
||||||
/>
|
onToggleContextLayerLock={onToggleContextLayerLock}
|
||||||
))}
|
onFlipContextLayers={onFlipContextLayers}
|
||||||
<span
|
onExportContextLayer={onExportContextLayer}
|
||||||
className="image-canvas-editor__minimap-viewport"
|
onDeleteContextLayers={onDeleteContextLayers}
|
||||||
style={minimapModel.viewport}
|
onDeleteLayerById={onDeleteLayerById}
|
||||||
/>
|
onCloseContextMenu={onCloseContextMenu}
|
||||||
</span>
|
onCloseImageContextMenu={onCloseImageContextMenu}
|
||||||
</button>
|
onUpdateScaleFromCenter={onUpdateScaleFromCenter}
|
||||||
) : null}
|
onFitLayers={onFitLayers}
|
||||||
|
onOpenQuickEditPanel={onOpenQuickEditPanel}
|
||||||
|
onOpenLayerMetadata={onOpenLayerMetadata}
|
||||||
|
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<ImageCanvasPanelDockView
|
||||||
className="image-canvas-editor__bottom-toolbar"
|
viewport={viewport}
|
||||||
role="toolbar"
|
canvasBackgroundColor={canvasBackgroundColor}
|
||||||
aria-label="AI画布工具栏"
|
canvasBackgroundHexValue={canvasBackgroundHexValue}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
canUndo={canUndo}
|
||||||
>
|
canRedo={canRedo}
|
||||||
{canvasTools.map(({ id, label, icon: Icon }) =>
|
isZoomMenuOpen={isZoomMenuOpen}
|
||||||
id === 'spec' ? (
|
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
|
||||||
<span
|
activeSidebarPanel={activeSidebarPanel}
|
||||||
key={id}
|
isMinimapOpen={isMinimapOpen}
|
||||||
ref={specToolWrapRef}
|
minimapModel={minimapModel}
|
||||||
className="image-canvas-editor__spec-tool-wrap"
|
onFitLayers={onFitLayers}
|
||||||
>
|
onUndoCanvasChange={onUndoCanvasChange}
|
||||||
<EditorIconButton
|
onRedoCanvasChange={onRedoCanvasChange}
|
||||||
label={label}
|
onUpdateScaleFromCenter={onUpdateScaleFromCenter}
|
||||||
title={label}
|
onToggleZoomMenu={onToggleZoomMenu}
|
||||||
icon={Icon}
|
onCloseZoomMenu={onCloseZoomMenu}
|
||||||
pressed={effectiveTool === id}
|
onToggleBackgroundSettings={onToggleBackgroundSettings}
|
||||||
onClick={() => onSwitchTool(id)}
|
onApplyCanvasBackgroundColor={onApplyCanvasBackgroundColor}
|
||||||
/>
|
onCanvasBackgroundHexChange={onCanvasBackgroundHexChange}
|
||||||
</span>
|
onToggleSidebarPanel={onToggleSidebarPanel}
|
||||||
) : (
|
onToggleMinimap={onToggleMinimap}
|
||||||
<EditorIconButton
|
onMinimapPointerDown={onMinimapPointerDown}
|
||||||
key={id}
|
/>
|
||||||
label={label}
|
|
||||||
title={label}
|
|
||||||
icon={Icon}
|
|
||||||
pressed={effectiveTool === id}
|
|
||||||
onClick={() => onSwitchTool(id)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
|
<ImageCanvasBottomToolbarView
|
||||||
<div
|
specToolWrapRef={specToolWrapRef}
|
||||||
className="image-canvas-editor__context-menu"
|
effectiveTool={effectiveTool}
|
||||||
style={{
|
onSwitchTool={onSwitchTool}
|
||||||
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}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
|||||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||||
import type { AssetExportStatus } from './useImageCanvasAssetExportWorkflow';
|
import type { AssetExportStatus } from './useImageCanvasAssetExportWorkflow';
|
||||||
|
|
||||||
type ImageCanvasTopbarViewProps = {
|
export type ImageCanvasTopbarViewProps = {
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
projectTitle: string;
|
projectTitle: string;
|
||||||
projectRenameValue: string;
|
projectRenameValue: string;
|
||||||
|
|||||||
221
src/components/image-editor/ImageCanvasWorldView.test.tsx
Normal file
221
src/components/image-editor/ImageCanvasWorldView.test.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
|
||||||
|
|
||||||
|
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||||
|
return {
|
||||||
|
id: 'layer-1',
|
||||||
|
resourceId: 'resource-1',
|
||||||
|
title: '角色主图',
|
||||||
|
src: 'data:image/png;base64,layer',
|
||||||
|
x: 120,
|
||||||
|
y: 160,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
originalWidth: 640,
|
||||||
|
originalHeight: 480,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGenerationDialog(
|
||||||
|
overrides: Partial<CanvasGenerationDialogState> = {},
|
||||||
|
): CanvasGenerationDialogState {
|
||||||
|
return {
|
||||||
|
id: 'dialog-1',
|
||||||
|
mode: 'generate',
|
||||||
|
prompt: '生成一张图',
|
||||||
|
status: 'idle',
|
||||||
|
placeholder: {
|
||||||
|
x: 480,
|
||||||
|
y: 320,
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
originalWidth: 1024,
|
||||||
|
originalHeight: 768,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorldView(
|
||||||
|
overrides: Partial<Parameters<typeof ImageCanvasWorldView>[0]> = {},
|
||||||
|
) {
|
||||||
|
const props: Parameters<typeof ImageCanvasWorldView>[0] = {
|
||||||
|
viewport: { x: 10, y: 20, scale: 1.5 },
|
||||||
|
snapGuide: null,
|
||||||
|
layers: [createLayer()],
|
||||||
|
selectedLayerIds: [],
|
||||||
|
hoveredLayerId: null,
|
||||||
|
canvasMarquee: null,
|
||||||
|
canvasGenerationDialogs: [],
|
||||||
|
generateDialog: null,
|
||||||
|
quickEditPanel: null,
|
||||||
|
generationComposerStyle: null,
|
||||||
|
onLayerPointerDown: vi.fn(),
|
||||||
|
onLayerClick: vi.fn(),
|
||||||
|
onLayerContextMenu: vi.fn(),
|
||||||
|
onLayerMouseEnter: vi.fn(),
|
||||||
|
onLayerMouseLeave: vi.fn(),
|
||||||
|
onOpenLayerMetadata: vi.fn(),
|
||||||
|
onGenerationFramePointerDown: vi.fn(),
|
||||||
|
onActivateGenerationDialog: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = render(<ImageCanvasWorldView {...props} />);
|
||||||
|
|
||||||
|
return { props, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageCanvasWorldView', () => {
|
||||||
|
it('renders visible layers and filters hidden layers', () => {
|
||||||
|
const visibleLayer = createLayer({ title: '可见图层', zIndex: 2 });
|
||||||
|
const hiddenLayer = createLayer({
|
||||||
|
id: 'layer-hidden',
|
||||||
|
resourceId: 'resource-hidden',
|
||||||
|
title: '隐藏图层',
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWorldView({
|
||||||
|
layers: [hiddenLayer, visibleLayer],
|
||||||
|
selectedLayerIds: [visibleLayer.id],
|
||||||
|
hoveredLayerId: visibleLayer.id,
|
||||||
|
quickEditPanel: {
|
||||||
|
sourceLayerId: visibleLayer.id,
|
||||||
|
prompt: '快速编辑',
|
||||||
|
size: '1024x1024',
|
||||||
|
model: 'gpt-image-2',
|
||||||
|
status: 'generating',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleButton = screen.getByRole('button', { name: '选择可见图层' });
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '选择隐藏图层' })).toBeNull();
|
||||||
|
expect(visibleButton.className).toContain(
|
||||||
|
'image-canvas-editor__layer--selected',
|
||||||
|
);
|
||||||
|
expect(visibleButton.className).toContain(
|
||||||
|
'image-canvas-editor__layer--hovered',
|
||||||
|
);
|
||||||
|
expect(visibleButton.className).toContain(
|
||||||
|
'image-canvas-editor__layer--generating',
|
||||||
|
);
|
||||||
|
expect(visibleButton.style.left).toBe('120px');
|
||||||
|
expect(visibleButton.style.top).toBe('160px');
|
||||||
|
expect(screen.getByText('640 x 480 px')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('status', { name: '' }).textContent).toBe('生成中');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards layer pointer, hover, context menu and metadata actions', () => {
|
||||||
|
const layer = createLayer({ assetKind: 'character' });
|
||||||
|
const { props } = renderWorldView({ layers: [layer] });
|
||||||
|
|
||||||
|
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
|
||||||
|
const metadataButton = within(layerButton).getByRole('button', {
|
||||||
|
name: '查看角色主图图片信息',
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(layerButton);
|
||||||
|
fireEvent.click(layerButton);
|
||||||
|
fireEvent.contextMenu(layerButton);
|
||||||
|
fireEvent.mouseEnter(layerButton);
|
||||||
|
fireEvent.mouseLeave(layerButton);
|
||||||
|
fireEvent.click(metadataButton);
|
||||||
|
|
||||||
|
expect(props.onLayerPointerDown).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
layer,
|
||||||
|
);
|
||||||
|
expect(props.onLayerClick).toHaveBeenCalledWith(expect.any(Object), layer);
|
||||||
|
expect(props.onLayerContextMenu).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
layer,
|
||||||
|
);
|
||||||
|
expect(props.onLayerMouseEnter).toHaveBeenCalledWith(layer.id);
|
||||||
|
expect(props.onLayerMouseLeave).toHaveBeenCalledWith(layer.id);
|
||||||
|
expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer);
|
||||||
|
expect(screen.getByText('角色')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders snap guides, marquee and floating generation status', () => {
|
||||||
|
renderWorldView({
|
||||||
|
snapGuide: { vertical: 288, horizontal: 344 },
|
||||||
|
canvasMarquee: {
|
||||||
|
pointerId: 1,
|
||||||
|
startX: 40,
|
||||||
|
startY: 50,
|
||||||
|
currentX: 190,
|
||||||
|
currentY: 230,
|
||||||
|
},
|
||||||
|
generateDialog: {
|
||||||
|
id: 'dialog-active',
|
||||||
|
mode: 'generate',
|
||||||
|
prompt: '生成中',
|
||||||
|
status: 'generating',
|
||||||
|
},
|
||||||
|
generationComposerStyle: { left: 12, top: 24 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('image-canvas-editor-snap-guide-vertical').style.left,
|
||||||
|
).toBe('288px');
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('image-canvas-editor-snap-guide-horizontal').style.top,
|
||||||
|
).toBe('344px');
|
||||||
|
expect(screen.getByText('生成中')).toBeTruthy();
|
||||||
|
|
||||||
|
const world = document.querySelector('.image-canvas-editor__world');
|
||||||
|
const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee');
|
||||||
|
|
||||||
|
expect(world?.getAttribute('style')).toContain(
|
||||||
|
'transform: translate(10px, 20px) scale(1.5)',
|
||||||
|
);
|
||||||
|
expect((marquee as HTMLElement | null)?.style.width).toBe('100px');
|
||||||
|
expect((marquee as HTMLElement | null)?.style.height).toBe('120px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders generation placeholders and forwards frame actions', () => {
|
||||||
|
const dialog = createGenerationDialog({
|
||||||
|
mode: 'icon',
|
||||||
|
status: 'generating',
|
||||||
|
});
|
||||||
|
const { props } = renderWorldView({
|
||||||
|
canvasGenerationDialogs: [
|
||||||
|
dialog,
|
||||||
|
createGenerationDialog({
|
||||||
|
id: 'dialog-without-placeholder',
|
||||||
|
placeholder: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame = screen.getByRole('button', { name: '图标素材生成占位图' });
|
||||||
|
|
||||||
|
expect(within(frame).getByText('Icon Generator')).toBeTruthy();
|
||||||
|
expect(within(frame).getByText('图标')).toBeTruthy();
|
||||||
|
expect(within(frame).getByText('1024 x 768')).toBeTruthy();
|
||||||
|
expect(within(frame).getByRole('status').textContent).toBe('生成中');
|
||||||
|
|
||||||
|
fireEvent.pointerDown(frame);
|
||||||
|
fireEvent.doubleClick(frame);
|
||||||
|
|
||||||
|
expect(props.onGenerationFramePointerDown).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
dialog,
|
||||||
|
);
|
||||||
|
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
|
||||||
|
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
300
src/components/image-editor/ImageCanvasWorldView.tsx
Normal file
300
src/components/image-editor/ImageCanvasWorldView.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { Braces, ImageIcon } from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
CSSProperties,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||||
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { CANVAS_WORLD_SIZE } from './ImageCanvasEditorModel';
|
||||||
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
CanvasMarqueeState,
|
||||||
|
CanvasViewport,
|
||||||
|
GenerateDialogState,
|
||||||
|
QuickEditPanelState,
|
||||||
|
SnapGuide,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import {
|
||||||
|
getGenerationFrameAriaLabel,
|
||||||
|
getGenerationFrameLabel,
|
||||||
|
getLayerKindLabel,
|
||||||
|
} from './ImageCanvasGenerationModel';
|
||||||
|
|
||||||
|
export type ImageCanvasWorldViewProps = {
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
snapGuide: SnapGuide | null;
|
||||||
|
layers: CanvasLayer[];
|
||||||
|
selectedLayerIds: string[];
|
||||||
|
hoveredLayerId: string | null;
|
||||||
|
canvasMarquee: CanvasMarqueeState | null;
|
||||||
|
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||||
|
generateDialog: GenerateDialogState | null;
|
||||||
|
quickEditPanel: QuickEditPanelState | null;
|
||||||
|
generationComposerStyle: CSSProperties | null;
|
||||||
|
onLayerPointerDown: (
|
||||||
|
event: ReactPointerEvent<HTMLButtonElement>,
|
||||||
|
layer: CanvasLayer,
|
||||||
|
) => void;
|
||||||
|
onLayerClick: (
|
||||||
|
event: ReactMouseEvent<HTMLButtonElement>,
|
||||||
|
layer: CanvasLayer,
|
||||||
|
) => void;
|
||||||
|
onLayerContextMenu: (
|
||||||
|
event: ReactMouseEvent<HTMLButtonElement>,
|
||||||
|
layer: CanvasLayer,
|
||||||
|
) => void;
|
||||||
|
onLayerMouseEnter: (layerId: string) => void;
|
||||||
|
onLayerMouseLeave: (layerId: string) => void;
|
||||||
|
onOpenLayerMetadata: (layer: CanvasLayer) => void;
|
||||||
|
onGenerationFramePointerDown: (
|
||||||
|
event: ReactPointerEvent<HTMLDivElement>,
|
||||||
|
dialog: CanvasGenerationDialogState,
|
||||||
|
) => void;
|
||||||
|
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageCanvasWorldView({
|
||||||
|
viewport,
|
||||||
|
snapGuide,
|
||||||
|
layers,
|
||||||
|
selectedLayerIds,
|
||||||
|
hoveredLayerId,
|
||||||
|
canvasMarquee,
|
||||||
|
canvasGenerationDialogs,
|
||||||
|
generateDialog,
|
||||||
|
quickEditPanel,
|
||||||
|
generationComposerStyle,
|
||||||
|
onLayerPointerDown,
|
||||||
|
onLayerClick,
|
||||||
|
onLayerContextMenu,
|
||||||
|
onLayerMouseEnter,
|
||||||
|
onLayerMouseLeave,
|
||||||
|
onOpenLayerMetadata,
|
||||||
|
onGenerationFramePointerDown,
|
||||||
|
onActivateGenerationDialog,
|
||||||
|
}: ImageCanvasWorldViewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__world"
|
||||||
|
style={{
|
||||||
|
width: CANVAS_WORLD_SIZE,
|
||||||
|
height: CANVAS_WORLD_SIZE,
|
||||||
|
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snapGuide?.vertical !== undefined ? (
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--vertical"
|
||||||
|
data-testid="image-canvas-editor-snap-guide-vertical"
|
||||||
|
style={{ left: snapGuide.vertical }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{snapGuide?.horizontal !== undefined ? (
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--horizontal"
|
||||||
|
data-testid="image-canvas-editor-snap-guide-horizontal"
|
||||||
|
style={{ top: snapGuide.horizontal }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{layers
|
||||||
|
.slice()
|
||||||
|
.filter((layer) => !layer.hidden)
|
||||||
|
.sort((left, right) => left.zIndex - right.zIndex)
|
||||||
|
.map((layer) => {
|
||||||
|
const isSelected = selectedLayerIds.includes(layer.id);
|
||||||
|
const isHovered = hoveredLayerId === layer.id;
|
||||||
|
const kindLabel = getLayerKindLabel(layer);
|
||||||
|
const layerGeneratingLabel =
|
||||||
|
generateDialog?.mode === 'edit' &&
|
||||||
|
generateDialog.status === 'generating' &&
|
||||||
|
generateDialog.sourceLayerId === layer.id
|
||||||
|
? '修改中'
|
||||||
|
: quickEditPanel?.status === 'generating' &&
|
||||||
|
quickEditPanel.sourceLayerId === layer.id
|
||||||
|
? '生成中'
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={layer.id}
|
||||||
|
type="button"
|
||||||
|
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
|
||||||
|
style={{
|
||||||
|
left: layer.x,
|
||||||
|
top: layer.y,
|
||||||
|
width: layer.width,
|
||||||
|
height: layer.height,
|
||||||
|
zIndex: layer.zIndex,
|
||||||
|
display: layer.hidden ? 'none' : undefined,
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => onLayerPointerDown(event, layer)}
|
||||||
|
onClick={(event) => onLayerClick(event, layer)}
|
||||||
|
onContextMenu={(event) => onLayerContextMenu(event, layer)}
|
||||||
|
onMouseEnter={() => onLayerMouseEnter(layer.id)}
|
||||||
|
onMouseLeave={() => onLayerMouseLeave(layer.id)}
|
||||||
|
aria-label={`选择${layer.title}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={layer.src}
|
||||||
|
alt={`画布图片:${layer.title}`}
|
||||||
|
style={{
|
||||||
|
transform:
|
||||||
|
layer.flipX || layer.flipY
|
||||||
|
? `scale(${layer.flipX ? -1 : 1}, ${
|
||||||
|
layer.flipY ? -1 : 1
|
||||||
|
})`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{kindLabel ? (
|
||||||
|
<span
|
||||||
|
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
|
||||||
|
>
|
||||||
|
{kindLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<PlatformIconButton
|
||||||
|
asChild="spanButton"
|
||||||
|
variant="darkMini"
|
||||||
|
className={`image-canvas-editor__metadata-corner ${
|
||||||
|
kindLabel
|
||||||
|
? 'image-canvas-editor__metadata-corner--beside-kind'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
label={`查看${layer.title}图片信息`}
|
||||||
|
icon={<Braces className="h-3 w-3" />}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenLayerMetadata(layer);
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{isHovered ? (
|
||||||
|
<PlatformPillBadge
|
||||||
|
tone="lightOverlay"
|
||||||
|
size="xs"
|
||||||
|
className="image-canvas-editor__size-badge"
|
||||||
|
>
|
||||||
|
{Math.round(layer.originalWidth)} x{' '}
|
||||||
|
{Math.round(layer.originalHeight)} px
|
||||||
|
</PlatformPillBadge>
|
||||||
|
) : null}
|
||||||
|
{layerGeneratingLabel ? (
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{layerGeneratingLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{canvasMarquee ? (
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__canvas-marquee"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
left:
|
||||||
|
(Math.min(canvasMarquee.startX, canvasMarquee.currentX) -
|
||||||
|
viewport.x) /
|
||||||
|
viewport.scale,
|
||||||
|
top:
|
||||||
|
(Math.min(canvasMarquee.startY, canvasMarquee.currentY) -
|
||||||
|
viewport.y) /
|
||||||
|
viewport.scale,
|
||||||
|
width:
|
||||||
|
Math.abs(canvasMarquee.currentX - canvasMarquee.startX) /
|
||||||
|
viewport.scale,
|
||||||
|
height:
|
||||||
|
Math.abs(canvasMarquee.currentY - canvasMarquee.startY) /
|
||||||
|
viewport.scale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canvasGenerationDialogs.map((dialog) =>
|
||||||
|
dialog.placeholder ? (
|
||||||
|
<div
|
||||||
|
key={dialog.id}
|
||||||
|
className={`image-canvas-editor__generation-frame ${
|
||||||
|
dialog.mode === 'icon'
|
||||||
|
? 'image-canvas-editor__generation-frame--icon'
|
||||||
|
: ''
|
||||||
|
} ${
|
||||||
|
dialog.status === 'generating'
|
||||||
|
? 'image-canvas-editor__generation-frame--generating'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{
|
||||||
|
left: dialog.placeholder.x,
|
||||||
|
top: dialog.placeholder.y,
|
||||||
|
width: dialog.placeholder.width,
|
||||||
|
height: dialog.placeholder.height,
|
||||||
|
}}
|
||||||
|
aria-label={getGenerationFrameAriaLabel(dialog)}
|
||||||
|
onPointerDown={(event) => onGenerationFramePointerDown(event, dialog)}
|
||||||
|
onDoubleClick={() => onActivateGenerationDialog(dialog)}
|
||||||
|
>
|
||||||
|
<span className="image-canvas-editor__generation-frame-label">
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
{getGenerationFrameLabel(dialog)}
|
||||||
|
</span>
|
||||||
|
{dialog.mode === 'character' ? (
|
||||||
|
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--character">
|
||||||
|
角色
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{dialog.mode === 'spec' ? (
|
||||||
|
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--spec">
|
||||||
|
规范
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{dialog.mode === 'icon' ? (
|
||||||
|
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--icon">
|
||||||
|
图标
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="image-canvas-editor__generation-frame-size">
|
||||||
|
{dialog.placeholder.originalWidth} x{' '}
|
||||||
|
{dialog.placeholder.originalHeight}
|
||||||
|
</span>
|
||||||
|
<span className="image-canvas-editor__generation-frame-icon">
|
||||||
|
<ImageIcon className="h-8 w-8" />
|
||||||
|
</span>
|
||||||
|
{dialog.status === 'generating' ? (
|
||||||
|
<span
|
||||||
|
className="image-canvas-editor__generation-frame-progress"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
生成中
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
{(generateDialog?.mode === 'generate' ||
|
||||||
|
generateDialog?.mode === 'spec' ||
|
||||||
|
generateDialog?.mode === 'character' ||
|
||||||
|
generateDialog?.mode === 'icon') &&
|
||||||
|
generateDialog.status === 'generating' &&
|
||||||
|
generationComposerStyle ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="info"
|
||||||
|
surface="platform"
|
||||||
|
size="xs"
|
||||||
|
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--floating"
|
||||||
|
role="status"
|
||||||
|
style={generationComposerStyle}
|
||||||
|
>
|
||||||
|
生成中
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user