拆分编辑器前端画布视图

抽出素材栏、生成器、舞台工具栏和画布世界视图

补充各拆分视图的聚焦测试

更新 TRACKING.md 记录第三十四阶段验证
This commit is contained in:
2026-06-17 17:48:12 +08:00
parent 7a77ab4df7
commit d8b935317d
42 changed files with 6527 additions and 2992 deletions

View File

@@ -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。

View File

@@ -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>
);
}

View 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>
);
}

View 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');
});
});

View 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>
);
}

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -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');
});
});

View 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>
);
}

View File

@@ -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('生成失败');
});
});

View File

@@ -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>
);
}

View File

@@ -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');
});
});

View File

@@ -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>
);
}

View 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);
});
});

View 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}
</>
);
}

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View 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');
});
});

View 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>
);
}

View File

@@ -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(

View File

@@ -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>
); );
} }

View File

@@ -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');
});
});

View File

@@ -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>
</>
);
}

View File

@@ -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('生成失败');
});
});

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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;
}; };

View 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);
});
});

View 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}
</>
);
}

View File

@@ -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');
});
});

View 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>
);
}

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View 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();
});
});

View File

@@ -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>
); );

View File

@@ -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('生成失败');
});
});

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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;

View 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();
});
});

View 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>
);
}