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