Unify canvas generation placement and UI changes
Add unified placement and UI updates for canvas generation: docs require ImageCanvasGenerationPlacementModel to compute placeholder positions (avoid overlaps, center viewport on placement) and limit frontend video model entries. Refactor UI components and tests: expose div props on PlatformFloatingMenu, make PlatformInlineOptionButton forwardRef, replace inline placeholder actions with ImageCanvasGenerationImageOptionsView in basic generation composer, enhance character animation panel with parameter menu portal and option choices, add reference chip UI, and update many tests to match new interactions and labels. These changes align generation workflows, accessibility labels, and positioning behavior across editor panels.
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-17 图片画布生成占位统一避让落点
|
||||
|
||||
- 背景:图片画布的普通图片、规范、角色、图标、视频和 UI 设计图生成入口都会先在画布中新建“即将生成”的占位图;若各入口直接使用当前视口中心,容易压住已有图片或已有生成占位,Lovart 式连续创作体验不稳定。
|
||||
- 决策:所有会新建画布生成占位的入口统一经过 `ImageCanvasGenerationPlacementModel` 计算落点。模型以当前视口世界中心为目标,避让所有未隐藏画布图层和 active / inactive generation dialog placeholder,按 32px 画布世界坐标间距外扩阻挡矩形,选择距离当前屏幕中心对应画板位置最近且不重叠的位置。选定后立即调用 `centerViewportOnPlacement(...)`,保持当前缩放比例不变,只平移画布 viewport,让屏幕中心移动到新占位中心。
|
||||
- 影响范围:`/editor/canvas` 图片画布生成入口、`useImageCanvasGenerationWorkflow`、`ImageCanvasGenerationPlacementModel`、图片画布技术方案和 Lovart 生成类面板文档。
|
||||
- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
|
||||
|
||||
- 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。
|
||||
@@ -2322,7 +2330,7 @@
|
||||
## 2026-06-17 图片画布底部生成视频接入 Lovart 面板
|
||||
|
||||
- 背景:编辑器画板底部工具栏需要新增 `生成视频`,并和现有 Lovart 式生成类面板、泥点展示、占位图和画布结果图层保持一致。
|
||||
- 决策:`生成视频` 点击后创建独立视频生成占位和极简面板,提交 `POST /api/editor/videos/generations`;首期支持 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`、`veo3.1`、`veo3.1-fast`,固定文字转视频、`16:9`、标准模式和静音。后端复用 Ark / VectorEngine content generation task 轮询链路,下载视频后持久化到 OSS。生成结果在画布中写入 `mediaType="video"` 与 `assetKind="video"`,图片信息弹窗按视频显示为 `视频信息` / `视频类型`。生成类泥点价格统一走 `editor_generation_config`,视频和角色动画均为 480p 每秒 10 泥点、720p 每秒 20 泥点。
|
||||
- 决策:`生成视频` 点击后创建独立视频生成占位和极简面板,提交 `POST /api/editor/videos/generations`;首期前端仅开放 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`,不展示 Veo 模型入口;固定文字转视频、`16:9`、标准模式和静音。后端复用 Ark / VectorEngine content generation task 轮询链路,下载视频后持久化到 OSS。生成结果在画布中写入 `mediaType="video"` 与 `assetKind="video"`,图片信息弹窗按视频显示为 `视频信息` / `视频类型`。生成类泥点价格统一走 `editor_generation_config`,视频和角色动画均为 480p 每秒 10 泥点、720p 每秒 20 泥点。
|
||||
- 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "opens the bottom generate video panel"`、`npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx`、`npm run test -- src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。
|
||||
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范 / 常规参考图 / 图标素材规范 / 修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。
|
||||
- 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。
|
||||
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和快速编辑保留既有 `gpt-image-2` 路径;`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。`生成视频` 走 `/api/editor/videos/generations`,支持 Seedance 2.0 / Seedance 2.0 Fast / Kling 3.0 / Kling 3.0 Omni / Veo 3.1 / Veo 3.1 Fast,首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。
|
||||
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和快速编辑保留既有 `gpt-image-2` 路径;`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 / Seedance 2.0 Fast / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。
|
||||
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
|
||||
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频` 和 `生成UI设计图`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。
|
||||
|
||||
## 交互规则
|
||||
|
||||
@@ -65,7 +66,7 @@
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标素材规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`,后端把 UI 尺寸归一为文档支持的 `1024x1024`、`1024x1536`、`1536x1024`、`2048x2048`、`2048x1152` 等 `size` 字符串。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。请求模型支持 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`、`veo3.1`、`veo3.1-fast`;后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。前端可选模型为 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`;后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
|
||||
所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
- `生成图标素材`
|
||||
- `生成UI设计图`
|
||||
- `生成视频`
|
||||
- `角色动画生成面板`
|
||||
|
||||
`角色动画生成面板` 同步纳入本次生成类面板交互统一:参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
|
||||
|
||||
## 统一布局
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
- 规范参考图:紫色剪贴板图标。
|
||||
- 图标素材规范:绿色图标图标。
|
||||
- UI 图标规范:琥珀色图标图标。
|
||||
- 视频参考图 / 角色视频源图:深色视频或角色图标。
|
||||
- 视频参考图:深色视频图标。
|
||||
3. 参考图卡片尽量少文字;必要文字写在图标块内或短标签内,不写规则说明。
|
||||
4. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。
|
||||
5. 单文本输入面板不显示文本框标题,用问题式 placeholder:
|
||||
@@ -30,7 +31,6 @@
|
||||
- 生成角色:`你希望角色如何设计?`
|
||||
- 生成 UI:`你希望这个 UI 长什么样?`
|
||||
- 生成视频:`你希望生成什么视频?`
|
||||
- 角色动画:`你希望角色做什么动作?`
|
||||
6. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范、图标素材多描述。
|
||||
7. 生成规范下的角色形象规范、UI素材规范、图标素材规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
|
||||
|
||||
@@ -39,15 +39,15 @@
|
||||
画面比例、大小尺寸、模型、分辨率、时长等生成参数统一为 Lovart 式底部胶囊按钮:
|
||||
|
||||
```text
|
||||
左下:[比例 · 尺寸 ˄] 右下:[模型 ˄] [生成 · 图标 12]
|
||||
左下:[比例 · 尺寸 ˄] 右下:[模型 ˄] [生成12泥点]
|
||||
```
|
||||
|
||||
- 面板内不显示 `画面比例`、`大小尺寸`、`模型` 等字段标题,只显示当前选择值。
|
||||
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频 / 角色动画把比例、时长、清晰度合并成一个左下角选项框。
|
||||
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框。
|
||||
- 模型选项框和生成按钮位于右下角。
|
||||
- 点击后在父级生成面板内向上弹出子面板;父级面板隐藏或销毁时,子面板同步销毁。
|
||||
- 子面板展示时,点击父级面板中除当前子面板和选项触发器以外的任意区域即可收起,不额外显示右上角关闭按钮。
|
||||
- 弹出面板内可分组展示字段,点击某个选项只更新当前字段和选中样式,不关闭弹出面板,方便连续修改。
|
||||
- 点击后以页面级浮层向上弹出独立选项面板;父级面板隐藏或销毁时,选项面板同步销毁。
|
||||
- 点击选项只更新字段和选中样式,不收起选项面板;再次点击当前参数按钮可收起。
|
||||
- 弹出面板内可分组展示字段,点击某个选项只更新当前字段和选中样式,方便连续修改。
|
||||
- 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。
|
||||
- 比例选项卡片内展示对应比例的线框。
|
||||
- 父级面板、底部选项框、弹出子面板字号保持一致。
|
||||
@@ -59,9 +59,25 @@
|
||||
## 泥点显示
|
||||
|
||||
- 本次消耗泥点必须显示在生成按钮内部。
|
||||
- `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`。
|
||||
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。
|
||||
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。
|
||||
|
||||
## 画布占位落点
|
||||
|
||||
- 任何会创建“即将生成”画布占位的 Lovart 面板入口,都必须先生成对应 draft,再通过统一 placement 模型改写 placeholder,不能直接使用 draft 初始中心点。
|
||||
- 统一入口范围:生成图片、生成规范、生成角色形象、生成图标素材、生成视频、生成 UI 设计图。
|
||||
- 避让对象:所有 `hidden !== true` 的画布图层,以及当前 active / inactive generation dialogs 中已有的 placeholder。
|
||||
- 避让间距:每个避让矩形向外扩 32px,保证新占位和已有图层 / 占位之间保留可见空隙。
|
||||
- 距离目标:以当前视口世界中心为目标点,在候选落点中选择距离最近且不重叠的位置;中心被占用时按相邻边、四角和外圈候选继续寻找。
|
||||
- 居中行为:调用 `openCanvasGenerationDialog(...)` 时写入避让后的 placeholder;随后调用 `centerViewportOnPlacement(...)`,让屏幕中心移动到新占位中心,并保持原 viewport scale。
|
||||
- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。
|
||||
|
||||
## 画布悬浮信息
|
||||
|
||||
- 待生成占位图只有聚焦或生成中才显示高亮边框、左上角生成器名称和右上角原始尺寸;失焦后保留灰色占位块本体,不显示这些辅助信息。
|
||||
- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。
|
||||
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
|
||||
|
||||
## 第一版计费配置
|
||||
|
||||
```text
|
||||
@@ -83,9 +99,7 @@
|
||||
- `seedance2.0-fast`
|
||||
- `kling3.0`
|
||||
- `kling3.0-omni`
|
||||
- 不展示 Veo 模型入口。
|
||||
- `veo3.1`
|
||||
- `veo3.1-fast`
|
||||
- 前端不展示 Veo 模型入口。
|
||||
- 后端复用现有 Ark / VectorEngine content generation task 轮询链路,并把生成视频持久化到 OSS;缺少 `ARK_CHARACTER_VIDEO_BASE_URL` 或 `ARK_CHARACTER_VIDEO_API_KEY` 时 fail-closed 返回配置错误。
|
||||
|
||||
## 验收
|
||||
@@ -94,8 +108,7 @@
|
||||
- 比例 / 尺寸 / 模型不再平铺全部选项;比例与尺寸合并为左下角当前值按钮,模型与生成按钮位于右下角。
|
||||
- 单文本输入面板不显示字段标题,placeholder 是问题式文案。
|
||||
- 多文本输入面板字段标题和边界仍清晰。
|
||||
- 生成按钮内能看到图标化泥点消耗数值。
|
||||
- 弹出选项面板点击选项后保持打开,可连续修改多个字段。
|
||||
- 生成按钮内能看到 `N泥点` 文案。
|
||||
- 弹出选项面板通过 `role=menu` 暴露,点击选项后保持打开,可连续修改多个字段。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。
|
||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
|
||||
import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type PlatformFloatingMenuProps = {
|
||||
type PlatformFloatingMenuProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
@@ -26,9 +26,11 @@ export function PlatformFloatingMenu({
|
||||
label,
|
||||
placement = 'top-end',
|
||||
style,
|
||||
...divProps
|
||||
}: PlatformFloatingMenuProps) {
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={[
|
||||
'platform-floating-menu',
|
||||
`platform-floating-menu--${placement}`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
type PlatformInlineOptionButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -12,13 +12,13 @@ type PlatformInlineOptionButtonProps = Omit<
|
||||
* 平台内联选项按钮。
|
||||
* 统一承接工具面板里“当前选项 + 下拉箭头”这类轻量 pill 动作。
|
||||
*/
|
||||
export function PlatformInlineOptionButton({
|
||||
children,
|
||||
trailingIcon,
|
||||
className,
|
||||
type = 'button',
|
||||
...buttonProps
|
||||
}: PlatformInlineOptionButtonProps) {
|
||||
export const PlatformInlineOptionButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
PlatformInlineOptionButtonProps
|
||||
>(function PlatformInlineOptionButton(
|
||||
{ children, trailingIcon, className, type = 'button', ...buttonProps },
|
||||
ref,
|
||||
) {
|
||||
const actionClassName = [
|
||||
'platform-inline-option-button inline-flex items-center justify-center border-0 bg-transparent text-sm font-black text-slate-700 transition-colors hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-55',
|
||||
className,
|
||||
@@ -27,7 +27,7 @@ export function PlatformInlineOptionButton({
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button {...buttonProps} type={type} className={actionClassName}>
|
||||
<button {...buttonProps} ref={ref} type={type} className={actionClassName}>
|
||||
<span className="platform-inline-option-button__label">{children}</span>
|
||||
{trailingIcon ? (
|
||||
<span className="platform-inline-option-button__trailing" aria-hidden="true">
|
||||
@@ -36,4 +36,4 @@ export function PlatformInlineOptionButton({
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -17,6 +17,10 @@ function createDialog(
|
||||
mode: 'generate',
|
||||
prompt: '初始提示',
|
||||
status: 'idle',
|
||||
generationReferences: [],
|
||||
imageModel: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +39,9 @@ function BasicGenerationHarness({
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false);
|
||||
const [isPickingReference, setIsPickingReference] = useState(false);
|
||||
const generationReferenceButtonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
return dialog ? (
|
||||
<div>
|
||||
@@ -42,13 +49,24 @@ function BasicGenerationHarness({
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
setGenerateDialog={setDialog}
|
||||
generationReferenceButtonRef={generationReferenceButtonRef}
|
||||
isGenerationReferenceMenuOpen={isReferenceMenuOpen}
|
||||
setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen}
|
||||
setIsPickingGenerationReferenceFromCanvas={setIsPickingReference}
|
||||
renderEditorPortal={(node) => node}
|
||||
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<output aria-label="当前提示词">{dialog.prompt}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="选择参考图">{String(isPickingReference)}</output>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -71,31 +89,89 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '新的提示' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { 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).not.toHaveBeenCalled();
|
||||
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
|
||||
expect(submitGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs placeholder actions and closes through its interface', () => {
|
||||
it('keeps references on the first row and appends a new add-reference entry', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'ref-a',
|
||||
label: '参考图A',
|
||||
src: 'data:image/png;base64,cmVmQQ==',
|
||||
},
|
||||
{
|
||||
id: 'ref-b',
|
||||
label: '参考图B',
|
||||
src: 'data:image/png;base64,cmVmQg==',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
const strip = panel.querySelector('.image-canvas-editor__reference-strip');
|
||||
|
||||
expect(strip).toBeTruthy();
|
||||
expect(within(strip as HTMLElement).getByText('参考图A')).toBeTruthy();
|
||||
expect(within(strip as HTMLElement).getByText('参考图B')).toBeTruthy();
|
||||
expect(
|
||||
within(strip as HTMLElement).getByRole('button', { name: '添加参考图' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows only current image options and opens upward option panels', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片尺寸 16:9 · 1K',
|
||||
}),
|
||||
);
|
||||
const dimensionPanel = screen.getByRole('menu', {
|
||||
name: '生成图片尺寸选项',
|
||||
});
|
||||
expect(within(dimensionPanel).getByText('比例')).toBeTruthy();
|
||||
fireEvent.click(within(dimensionPanel).getByRole('button', { name: '2K' }));
|
||||
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
);
|
||||
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', { name: /gpt-image-2/ }),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
const submitButton = within(panel).getByRole('button', { name: '生成' });
|
||||
expect(submitButton.textContent).toBe('生成12泥点');
|
||||
});
|
||||
|
||||
it('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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ImageIcon, X } from 'lucide-react';
|
||||
import { ImageIcon, X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
@@ -7,17 +7,19 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
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 {
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
@@ -36,14 +38,10 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: 'generation-reference') => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function triggerPlaceholderAction(label: string) {
|
||||
window.alert(`${label}功能建设中`);
|
||||
}
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
...dialog,
|
||||
@@ -52,6 +50,28 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceChip({
|
||||
reference,
|
||||
index,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="image-canvas-editor__reference-chip"
|
||||
title={reference.label}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{reference.label || `参考图${index + 1}`}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasBasicGenerationComposerView({
|
||||
dialog,
|
||||
style,
|
||||
@@ -64,9 +84,11 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
onRequestUpload,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ImageCanvasBasicGenerationComposerViewProps) {
|
||||
const references = dialog.generationReferences ?? [];
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
@@ -82,23 +104,36 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlatformIconButton
|
||||
ref={generationReferenceButtonRef}
|
||||
variant="surfaceFloating"
|
||||
className="image-canvas-editor__generation-ref"
|
||||
label="添加参考图"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => onToggleReferenceMenu?.()}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
>
|
||||
<span>参考图</span>
|
||||
</PlatformIconButton>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference, index) => (
|
||||
<ReferenceChip
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
ref={generationReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="添加参考图"
|
||||
onClick={() => onToggleReferenceMenu?.()}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<ImageIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
参考图
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="今天我们要创作什么"
|
||||
placeholder="今天想生成什么画面?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
@@ -114,35 +149,18 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.image}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
</div>
|
||||
{dialog.status === 'generating' ? (
|
||||
<PlatformStatusMessage
|
||||
|
||||
@@ -4,9 +4,27 @@ 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 type { CanvasLayer, CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
|
||||
|
||||
|
||||
function createSourceLayer(): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-a',
|
||||
resourceId: 'resource-a',
|
||||
title: '角色源图',
|
||||
src: 'data:image/png;base64,Y2hhcmFjdGVy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
originalWidth: 128,
|
||||
originalHeight: 128,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
};
|
||||
}
|
||||
|
||||
function createPanel(
|
||||
patch: Partial<CharacterAnimationPanelState> = {},
|
||||
): CharacterAnimationPanelState {
|
||||
@@ -64,6 +82,7 @@ function CharacterAnimationPanelHarness({
|
||||
<div>
|
||||
<ImageCanvasCharacterAnimationPanelView
|
||||
panel={panel}
|
||||
sourceLayer={createSourceLayer()}
|
||||
style={{ left: 12, top: 24 }}
|
||||
price={18}
|
||||
setCharacterAnimationPanel={setPanel}
|
||||
@@ -84,7 +103,7 @@ function CharacterAnimationPanelHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
it('updates prompt, resolution and ratio while clearing failed state', () => {
|
||||
it('keeps source reference first and updates prompt, resolution and ratio from a menu while clearing failed state', () => {
|
||||
render(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel({
|
||||
@@ -97,16 +116,18 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
fireEvent.change(screen.getByLabelText('动画描述'), {
|
||||
target: { value: `${'a'.repeat(4001)}` },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('分辨率'), {
|
||||
target: { value: '720p' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面比例'), {
|
||||
target: { value: '16:9' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
|
||||
);
|
||||
const menu = screen.getByRole('menu', { name: '动画参数选项' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '清晰度 720p' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(4000);
|
||||
expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p');
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
|
||||
expect(menu).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
});
|
||||
@@ -124,9 +145,10 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '行走' }));
|
||||
fireEvent.change(screen.getByLabelText('时长'), {
|
||||
target: { value: '48' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '时长 48帧·6秒' }));
|
||||
|
||||
expect(screen.getByLabelText('当前动画描述').textContent).toBe(
|
||||
'循环行走动作,步伐稳定。',
|
||||
@@ -145,7 +167,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成18泥点' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭角色动画生成面板' }),
|
||||
);
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import {
|
||||
CHARACTER_ANIMATION_ACTION_PROMPTS,
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
CHARACTER_ANIMATION_RATIO_OPTIONS,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
CharacterAnimationPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type ImageCanvasCharacterAnimationPanelViewProps = {
|
||||
panel: CharacterAnimationPanelState;
|
||||
sourceLayer: CanvasLayer;
|
||||
style: CSSProperties;
|
||||
price: number;
|
||||
setCharacterAnimationPanel: Dispatch<
|
||||
@@ -33,144 +51,198 @@ function resetFailedPanelStatus<T extends { status: string; errorMessage?: strin
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalMenuStyle(anchor: HTMLElement | null): CSSProperties {
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
zIndex: 70,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: Math.round(rect.left),
|
||||
top: Math.round(rect.top),
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
zIndex: 70,
|
||||
transform: 'translateY(calc(-100% - 0.45rem))',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanelPortal(node: ReactNode) {
|
||||
if (typeof document === 'undefined') {
|
||||
return node;
|
||||
}
|
||||
// 中文注释:参数菜单挂到页面级,避免被角色动画面板滚动边界裁切。
|
||||
return document.body ? createPortal(node, document.body) : node;
|
||||
}
|
||||
|
||||
function getRatioLabel(value: EditorCharacterAnimationRatio) {
|
||||
return value === 'same' ? '同图尺寸' : value;
|
||||
}
|
||||
|
||||
function AnimationOptionChoice({
|
||||
children,
|
||||
selected,
|
||||
className,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['image-canvas-editor__option-popover-choice', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={selected}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasCharacterAnimationPanelView({
|
||||
panel,
|
||||
sourceLayer,
|
||||
style,
|
||||
price,
|
||||
setCharacterAnimationPanel,
|
||||
onUpdateDuration,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterAnimationPanelViewProps) {
|
||||
const [isParameterMenuOpen, setIsParameterMenuOpen] = useState(false);
|
||||
const parameterButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isGenerating = panel.status === 'generating';
|
||||
const ratioLabel = getRatioLabel(panel.ratio);
|
||||
|
||||
const updatePanel = (patch: Partial<CharacterAnimationPanelState>) => {
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedPanelStatus(currentPanel),
|
||||
...patch,
|
||||
}
|
||||
: currentPanel,
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
<>
|
||||
<form
|
||||
className="image-canvas-editor__character-animation-panel"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="角色动画生成面板"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isGenerating) {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
<span
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--character"
|
||||
title={sourceLayer.title}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<img src={sourceLayer.src} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{sourceLayer.title}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__generation-close"
|
||||
label="关闭角色动画生成面板"
|
||||
title="关闭"
|
||||
icon={X}
|
||||
variant="surfaceFloating"
|
||||
disabled={isGenerating}
|
||||
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,
|
||||
)
|
||||
}
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="动画描述"
|
||||
value={panel.promptText}
|
||||
maxLength={4000}
|
||||
disabled={isGenerating}
|
||||
placeholder="你希望角色做什么动作?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless image-canvas-editor__character-animation-textarea"
|
||||
onChange={(event) =>
|
||||
updatePanel({ promptText: event.target.value.slice(0, 4000) })
|
||||
}
|
||||
/>
|
||||
<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={isGenerating}
|
||||
onClick={() =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...currentPanel,
|
||||
promptText: preset.text,
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="image-canvas-editor__character-animation-footer">
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
|
||||
<PlatformInlineOptionButton
|
||||
ref={parameterButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
|
||||
aria-label={`动画参数 ${ratioLabel} · ${panel.durationSeconds}秒 · ${panel.resolution}`}
|
||||
aria-expanded={isParameterMenuOpen}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => setIsParameterMenuOpen((open) => !open)}
|
||||
>
|
||||
{ratioLabel} · {panel.durationSeconds}秒 · {panel.resolution}
|
||||
</PlatformInlineOptionButton>
|
||||
</div>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__character-animation-submit"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{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">
|
||||
{isGenerating ? '生成中' : `生成${price}泥点`}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
<span
|
||||
className="image-canvas-editor__character-animation-summary-text"
|
||||
title={panel.promptText.trim() || undefined}
|
||||
@@ -178,37 +250,103 @@ export function ImageCanvasCharacterAnimationPanelView({
|
||||
>
|
||||
{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>
|
||||
{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}
|
||||
</form>
|
||||
{isParameterMenuOpen
|
||||
? renderPanelPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
|
||||
label="动画参数选项"
|
||||
placement="top-start"
|
||||
style={buildLocalMenuStyle(parameterButtonRef.current)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-sections">
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
比例
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
{CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => (
|
||||
<AnimationOptionChoice
|
||||
key={option.value}
|
||||
selected={panel.ratio === option.value}
|
||||
disabled={isGenerating}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
ariaLabel={`比例 ${getRatioLabel(option.value)}`}
|
||||
onClick={() => updatePanel({ ratio: option.value })}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__ratio-wireframe"
|
||||
data-ratio={option.value}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{getRatioLabel(option.value)}</span>
|
||||
</AnimationOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
时长
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
|
||||
<AnimationOptionChoice
|
||||
key={option.frameCount}
|
||||
selected={panel.frameCount === option.frameCount}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`时长 ${option.label}`}
|
||||
onClick={() => onUpdateDuration(String(option.frameCount))}
|
||||
>
|
||||
{option.label}
|
||||
</AnimationOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
清晰度
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{(['480p', '720p'] as EditorCharacterAnimationResolution[]).map(
|
||||
(resolution) => (
|
||||
<AnimationOptionChoice
|
||||
key={resolution}
|
||||
selected={panel.resolution === resolution}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`清晰度 ${resolution}`}
|
||||
onClick={() => updatePanel({ resolution })}
|
||||
>
|
||||
{resolution}
|
||||
</AnimationOptionChoice>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,23 @@ function CharacterGenerationHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
it('keeps the prompt as a borderless single text input with a question placeholder', () => {
|
||||
render(<CharacterGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||||
const prompt = screen.getByRole('textbox', { name: '角色设定' });
|
||||
|
||||
expect(
|
||||
Array.from(panel.querySelectorAll('.image-canvas-editor__field-title'))
|
||||
.map((node) => node.textContent)
|
||||
.join(''),
|
||||
).not.toContain('角色设定');
|
||||
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
|
||||
expect(prompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
});
|
||||
|
||||
it('updates character prompt, clears failed state and submits', () => {
|
||||
const submitCharacter = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
@@ -16,10 +14,12 @@ import {
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { EDITOR_GENERATION_MUD_POINT_CONFIG } from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
|
||||
type ImageCanvasCharacterGenerationComposerViewProps = {
|
||||
@@ -53,6 +53,28 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceChip({
|
||||
reference,
|
||||
index,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--character"
|
||||
title={reference.label}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{reference.label || `参考图${index + 1}`}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasCharacterGenerationComposerView({
|
||||
dialog,
|
||||
style,
|
||||
@@ -86,39 +108,32 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={characterSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--spec image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label={dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
角色形象规范
|
||||
</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>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
{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-chip-label">
|
||||
{dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
{isCharacterSpecMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
@@ -160,90 +175,71 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
</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"
|
||||
{(dialog.characterReferences ?? []).map((reference, index) => (
|
||||
<ReferenceChip
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
ref={characterReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="上传常规参考图"
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
>
|
||||
角色设定
|
||||
</PlatformFieldLabel>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
参考图
|
||||
</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>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="角色设定"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="你希望角色如何设计?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
@@ -273,17 +269,13 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.character}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -80,6 +80,57 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
saveEditorProjectLayoutMock,
|
||||
});
|
||||
|
||||
const openGenerationDimensionsPanel = (
|
||||
panel: HTMLElement,
|
||||
labelPrefix = '生成图片',
|
||||
) => {
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: new RegExp(`^${labelPrefix}尺寸 `, 'u'),
|
||||
}),
|
||||
);
|
||||
return screen.getByRole('menu', { name: `${labelPrefix}尺寸选项` });
|
||||
};
|
||||
|
||||
const openGenerationModelPanel = (
|
||||
panel: HTMLElement,
|
||||
labelPrefix = '生成图片',
|
||||
) => {
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: new RegExp(`^${labelPrefix}模型 `, 'u'),
|
||||
}),
|
||||
);
|
||||
return screen.getByRole('menu', { name: `${labelPrefix}模型选项` });
|
||||
};
|
||||
|
||||
const selectGenerationModel = (panel: HTMLElement, modelLabel: string) => {
|
||||
const modelPanel = openGenerationModelPanel(panel);
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', {
|
||||
name: new RegExp(`^${modelLabel}(?: ✓)?$`, 'u'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const selectGenerationDimensions = (
|
||||
panel: HTMLElement,
|
||||
aspectRatio: string,
|
||||
imageSize: string,
|
||||
) => {
|
||||
const dimensionsPanel = openGenerationDimensionsPanel(panel);
|
||||
fireEvent.click(
|
||||
within(dimensionsPanel).getByRole('button', {
|
||||
name: new RegExp(`^(?:比例 )?${aspectRatio}$`, 'u'),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dimensionsPanel).getByRole('button', {
|
||||
name: new RegExp(`^(?:尺寸 )?${imageSize}$`, 'u'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
@@ -108,11 +159,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||||
.className,
|
||||
).toContain('bg-white/94');
|
||||
).toContain('image-canvas-editor__reference-chip');
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||||
.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
).toContain('image-canvas-editor__reference-chip');
|
||||
const generatePrompt = screen.getByLabelText('生成提示词');
|
||||
expect(generatePrompt.className).toContain('platform-text-field');
|
||||
expect(generatePrompt.className).toContain(
|
||||
@@ -120,12 +171,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', {
|
||||
name: '生成比例 1:1 2k 1张',
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
}).className,
|
||||
).toContain('platform-inline-option-button');
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', {
|
||||
name: '生成模型 GPT Image',
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}).className,
|
||||
).toContain('platform-inline-option-button');
|
||||
expect(
|
||||
@@ -146,6 +197,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张明亮的拼图主视觉',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -654,7 +708,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(specDialog).getByRole('button', { name: '提交生成规范' })
|
||||
.textContent,
|
||||
).toContain('消耗5泥点');
|
||||
).toContain('生成5泥点');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||||
target: { value: '平台跳跃玩法' },
|
||||
@@ -745,7 +799,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy();
|
||||
expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy();
|
||||
expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy();
|
||||
expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(iconSpritesheetPanel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(iconSpritesheetPanel).getByRole('button', {
|
||||
@@ -768,26 +826,44 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterPanel = screen.getByRole('dialog', {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
expect(within(characterPanel).getByText('画面比例')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: '1:1' }),
|
||||
within(characterPanel).getByRole('button', {
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: '1K' }),
|
||||
within(characterPanel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
const characterDimensionsPanel =
|
||||
openGenerationDimensionsPanel(characterPanel);
|
||||
expect(within(characterDimensionsPanel).getByText(/^(?:比例|Aspect ratio)$/u)).toBeTruthy();
|
||||
expect(within(characterDimensionsPanel).getByText(/^(?:尺寸|Size)$/u)).toBeTruthy();
|
||||
expect(
|
||||
within(characterDimensionsPanel).getByRole('button', { name: /^(?:比例 )?1:1$/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: 'nanobanana2' }),
|
||||
within(characterDimensionsPanel).getByRole('button', { name: /^(?:尺寸 )?1K$/u }),
|
||||
).toBeTruthy();
|
||||
const characterModelPanel = openGenerationModelPanel(characterPanel);
|
||||
expect(
|
||||
within(characterModelPanel).getByRole('button', {
|
||||
name: /nanobanana2/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(within(iconPanel).getByText('画面比例')).toBeTruthy();
|
||||
expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy();
|
||||
expect(within(iconPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', { name: 'nanobanana2' }),
|
||||
within(iconPanel).getByRole('button', {
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -867,13 +943,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterPanel = screen.getByRole('dialog', {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '2:3' }),
|
||||
);
|
||||
fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' }));
|
||||
selectGenerationModel(characterPanel, 'gpt-image-2');
|
||||
selectGenerationDimensions(characterPanel, '2:3', '2K');
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||||
target: { value: '蓝衣剑士' },
|
||||
});
|
||||
@@ -896,7 +967,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
within(iconPanel).getByRole('button', {
|
||||
name: '生成图片模型 gpt-image-2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
|
||||
@@ -1091,11 +1164,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
|
||||
);
|
||||
const referenceRow = characterPanel.querySelector(
|
||||
'.image-canvas-editor__character-reference-row',
|
||||
'.image-canvas-editor__reference-strip',
|
||||
);
|
||||
const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' });
|
||||
|
||||
expect(referenceRow?.contains(sourceMenu)).toBe(false);
|
||||
if (!referenceRow) {
|
||||
throw new Error('角色生成面板应包含参考图区域');
|
||||
}
|
||||
expect(referenceRow.contains(sourceMenu)).toBe(false);
|
||||
expect(sourceMenu.className).toContain('platform-floating-menu--top-start');
|
||||
|
||||
fireEvent.click(
|
||||
@@ -1104,7 +1180,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const regularReferenceMenu = screen.getByRole('menu', {
|
||||
name: '常规参考图来源',
|
||||
});
|
||||
expect(referenceRow?.contains(regularReferenceMenu)).toBe(false);
|
||||
expect(referenceRow.contains(regularReferenceMenu)).toBe(false);
|
||||
expect(regularReferenceMenu.className).toContain(
|
||||
'platform-floating-menu--top-start',
|
||||
);
|
||||
@@ -1122,15 +1198,15 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
name: '上传常规参考图',
|
||||
});
|
||||
|
||||
expect(specTile.className).toContain('image-canvas-editor__reference-tile');
|
||||
expect(specTile.className).toContain('image-canvas-editor__reference-chip');
|
||||
expect(uploadTile.className).toContain(
|
||||
'image-canvas-editor__reference-tile',
|
||||
'image-canvas-editor__reference-chip',
|
||||
);
|
||||
expect(
|
||||
specTile.querySelector('.image-canvas-editor__reference-tile-visual'),
|
||||
specTile.querySelector('.image-canvas-editor__reference-chip-icon'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'),
|
||||
uploadTile.querySelector('.image-canvas-editor__reference-chip-icon'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1406,7 +1482,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(canvasReferenceLayer.className).not.toContain(
|
||||
'image-canvas-editor__layer--selected',
|
||||
);
|
||||
expect(within(characterPanel).getByText('1')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('大鱼素材')).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
|
||||
@@ -1417,7 +1493,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
new File(['reference'], '常规参考.png', { type: 'image/png' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(within(characterPanel).getByText('2')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('常规参考.png')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||||
@@ -1807,16 +1883,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||||
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
|
||||
expect(within(panel).getByText('40泥点')).toBeTruthy();
|
||||
expect(
|
||||
(within(panel).getByLabelText('分辨率') as HTMLSelectElement).value,
|
||||
).toBe('480p');
|
||||
within(panel).getByRole('button', {
|
||||
name: '动画参数 同图尺寸 · 4秒 · 480p',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(within(panel).getByLabelText('画面比例') as HTMLSelectElement).value,
|
||||
).toBe('same');
|
||||
expect(
|
||||
(within(panel).getByLabelText('时长') as HTMLSelectElement).value,
|
||||
).toBe('32');
|
||||
within(panel).getByRole('button', { name: '生成40泥点' }),
|
||||
).toBeTruthy();
|
||||
for (const actionLabel of [
|
||||
'待机',
|
||||
'行走',
|
||||
@@ -1849,17 +1923,30 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
|
||||
).toBeTruthy();
|
||||
fireEvent.change(within(panel).getByLabelText('分辨率'), {
|
||||
target: { value: '720p' },
|
||||
});
|
||||
fireEvent.change(within(panel).getByLabelText('画面比例'), {
|
||||
target: { value: '16:9' },
|
||||
});
|
||||
fireEvent.change(within(panel).getByLabelText('时长'), {
|
||||
target: { value: '48' },
|
||||
});
|
||||
expect(within(panel).getByText('120泥点')).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '动画参数 同图尺寸 · 4秒 · 480p',
|
||||
}),
|
||||
);
|
||||
const animationMenu = screen.getByRole('menu', { name: '动画参数选项' });
|
||||
fireEvent.click(
|
||||
within(animationMenu).getByRole('button', { name: '清晰度 720p' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(animationMenu).getByRole('button', { name: '比例 16:9' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(animationMenu).getByRole('button', { name: '时长 48帧·6秒' }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '动画参数选项' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '动画参数 16:9 · 6秒 · 720p',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' }));
|
||||
|
||||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* @vitest-environment jsdom */
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
createRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
@@ -16,13 +17,14 @@ function mockStateSetter<T>() {
|
||||
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
|
||||
}
|
||||
|
||||
function renderComposer(
|
||||
|
||||
function createComposerProps(
|
||||
generateDialog: GenerateDialogState,
|
||||
overrides: Partial<
|
||||
ComponentProps<typeof ImageCanvasGenerationComposerView>
|
||||
> = {},
|
||||
) {
|
||||
const props: ComponentProps<typeof ImageCanvasGenerationComposerView> = {
|
||||
): ComponentProps<typeof ImageCanvasGenerationComposerView> {
|
||||
return {
|
||||
specToolWrapRef: createRef(),
|
||||
characterSpecButtonRef: createRef(),
|
||||
characterReferenceButtonRef: createRef(),
|
||||
@@ -88,6 +90,15 @@ function renderComposer(
|
||||
onRememberImageModel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderComposer(
|
||||
generateDialog: GenerateDialogState,
|
||||
overrides: Partial<
|
||||
ComponentProps<typeof ImageCanvasGenerationComposerView>
|
||||
> = {},
|
||||
) {
|
||||
const props = createComposerProps(generateDialog, overrides);
|
||||
|
||||
return render(<ImageCanvasGenerationComposerView {...props} />);
|
||||
}
|
||||
@@ -113,6 +124,11 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer',
|
||||
);
|
||||
@@ -120,9 +136,8 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
'image-canvas-editor__generation-composer--image',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: 'UI设计图标素材规范' })
|
||||
.parentElement?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
within(panel).getByRole('button', { name: 'UI设计图标素材规范' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: 'UI设计要求' }).className,
|
||||
).toContain('image-canvas-editor__generation-prompt');
|
||||
@@ -157,16 +172,18 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer',
|
||||
);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer--image',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '参考图' }).parentElement
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
@@ -194,9 +211,11 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '参考图' }).parentElement
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
|
||||
});
|
||||
it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
@@ -272,11 +291,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('生成视频面板可切换时长、清晰度和模型并更新泥点', () => {
|
||||
const setGenerateDialog = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
it('生成视频面板用子面板修改参数和模型,不展示 Veo,并在按钮内显示泥点文案', () => {
|
||||
function VideoHarness() {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState>({
|
||||
mode: 'video',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
@@ -288,32 +305,60 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
videoResolution: '480p',
|
||||
videoMode: 'std',
|
||||
videoSound: 'off',
|
||||
},
|
||||
{
|
||||
setGenerateDialog:
|
||||
setGenerateDialog as unknown as Dispatch<
|
||||
SetStateAction<GenerateDialogState | null>
|
||||
>,
|
||||
},
|
||||
);
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<ImageCanvasGenerationComposerView
|
||||
{...createComposerProps(dialog)}
|
||||
setGenerateDialog={setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>}
|
||||
/>
|
||||
<output aria-label="当前视频模型">{dialog.videoModel}</output>
|
||||
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
|
||||
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<VideoHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成视频' });
|
||||
expect(within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }))
|
||||
.toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '模型 Seedance 2.0' }))
|
||||
.toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '清晰度 480p' }))
|
||||
.toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成视频' }).textContent)
|
||||
.toBe('40');
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '模型 Seedance 2.0' }),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成40泥点',
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '模型 Seedance 2.0' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '清晰度 480p' }));
|
||||
const paramsPanel = screen.getByRole('menu', {
|
||||
name: '视频参数选项',
|
||||
});
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '时长 5秒' }));
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' }));
|
||||
|
||||
expect(setGenerateDialog).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '视频参数选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
|
||||
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
|
||||
expect(screen.getByRole('button', { name: '视频参数 16:9 · 5秒 · 720p' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成100泥点',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0' }));
|
||||
const modelPanel = screen.getByRole('menu', { name: '视频模型选项' });
|
||||
expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull();
|
||||
fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0');
|
||||
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
|
||||
});
|
||||
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,11 +77,15 @@ export function createGenerateDialogDraft({
|
||||
const placeholderWidth = 420;
|
||||
const placeholderHeight = 420;
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const dimensionDefaults = resolveImageDimensionDefaults(DEFAULT_IMAGE_MODEL);
|
||||
return {
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
imageModel: DEFAULT_IMAGE_MODEL,
|
||||
aspectRatio: dimensionDefaults.aspectRatio,
|
||||
imageSize: dimensionDefaults.imageSize,
|
||||
placeholder: {
|
||||
x: worldCenter.x - placeholderWidth / 2,
|
||||
y: worldCenter.y - placeholderHeight / 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -29,6 +29,9 @@ function ImageOptionsHarness({
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={12}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成角色形象"
|
||||
/>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
@@ -40,7 +43,7 @@ function ImageOptionsHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
it('updates dimensions and resets failed dialog state', () => {
|
||||
it('updates dimensions from a menu, keeps the menu open and marks the selection', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
@@ -55,16 +58,35 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '16:9' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '2K' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片尺寸选项' });
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '16:9' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '2K' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('button', { name: '16:9' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('button', { name: '2K' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 16:9 · 2K' }),
|
||||
).toBeTruthy();
|
||||
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', () => {
|
||||
it('renders model choices as one model per row and uses a check mark for selection', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
@@ -80,13 +102,48 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
|
||||
expect(within(panel).getByRole('button', { name: 'nanobanana2' })).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy();
|
||||
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');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders option menus and shows mud point text in the submit button', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeTruthy();
|
||||
const submit = screen.getByRole('button', { name: '生成角色形象' });
|
||||
expect(submit.textContent).toBe('生成12泥点');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import {
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
@@ -14,8 +23,19 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
includeDimensions: boolean;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
optionLabelPrefix?: string;
|
||||
cost?: number;
|
||||
submitLabel?: string;
|
||||
submitAriaLabel?: string;
|
||||
renderEditorPortal?: (node: ReactNode) => ReactNode;
|
||||
buildPortalMenuStyle?: (
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
) => CSSProperties;
|
||||
};
|
||||
|
||||
type OpenPanel = 'dimensions' | 'model' | null;
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
...dialog,
|
||||
@@ -24,7 +44,7 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function getImageDimensionOptions(model: string | null | undefined) {
|
||||
export function getImageDimensionOptions(model: string | null | undefined) {
|
||||
return (
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
(model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
@@ -32,7 +52,7 @@ function getImageDimensionOptions(model: string | null | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
export function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
const model = dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2;
|
||||
const options = getImageDimensionOptions(model);
|
||||
const aspectRatios = options.aspectRatios as readonly string[];
|
||||
@@ -52,13 +72,58 @@ function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function getImageModelLabel(value: string) {
|
||||
return (
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.find((option) => option.value === value)
|
||||
?.label ?? value
|
||||
);
|
||||
}
|
||||
|
||||
function OptionChoice({
|
||||
children,
|
||||
selected,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['image-canvas-editor__option-popover-choice', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={selected}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasGenerationImageOptionsView({
|
||||
dialog,
|
||||
setGenerateDialog,
|
||||
includeDimensions,
|
||||
onRememberImageModel,
|
||||
optionLabelPrefix,
|
||||
cost,
|
||||
submitLabel = '生成',
|
||||
submitAriaLabel = '生成',
|
||||
renderEditorPortal = (node) => node,
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
}: ImageCanvasGenerationImageOptionsViewProps) {
|
||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selection = normalizeImageDialogSelection(dialog);
|
||||
const selectedModelLabel = getImageModelLabel(selection.model);
|
||||
const isGenerating = dialog.status === 'generating';
|
||||
const labelPrefix = optionLabelPrefix ?? '生成图片';
|
||||
|
||||
const updateDialog = (patch: Partial<GenerateDialogState>) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog && currentDialog.mode === dialog.mode
|
||||
@@ -70,96 +135,187 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
);
|
||||
};
|
||||
|
||||
const updateImageModel = (model: string) => {
|
||||
onRememberImageModel(model);
|
||||
setGenerateDialog((currentDialog) => {
|
||||
if (!currentDialog || currentDialog.mode !== dialog.mode) {
|
||||
return currentDialog;
|
||||
}
|
||||
const nextOptions = getImageDimensionOptions(model);
|
||||
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
|
||||
const nextImageSizes = nextOptions.imageSizes as readonly string[];
|
||||
return {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
imageModel: model,
|
||||
aspectRatio:
|
||||
currentDialog.aspectRatio &&
|
||||
nextAspectRatios.includes(currentDialog.aspectRatio)
|
||||
? currentDialog.aspectRatio
|
||||
: nextOptions.aspectRatios[0],
|
||||
imageSize:
|
||||
currentDialog.imageSize &&
|
||||
nextImageSizes.includes(currentDialog.imageSize)
|
||||
? currentDialog.imageSize
|
||||
: (nextOptions.imageSizes.find((size) => size === '1K') ??
|
||||
nextOptions.imageSizes[0]),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
|
||||
setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
|
||||
};
|
||||
|
||||
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 })}
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
|
||||
<PlatformInlineOptionButton
|
||||
ref={dimensionsButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
|
||||
aria-label={`${labelPrefix}尺寸 ${selection.aspectRatio} · ${selection.imageSize}`}
|
||||
aria-expanded={openPanel === 'dimensions'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => togglePanel('dimensions')}
|
||||
>
|
||||
{selection.aspectRatio} · {selection.imageSize}
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'dimensions'
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
|
||||
label={`${labelPrefix}尺寸选项`}
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
dimensionsButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{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 className="image-canvas-editor__option-popover-sections">
|
||||
<div className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
比例
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
{selection.options.aspectRatios.map((aspectRatio) => (
|
||||
<OptionChoice
|
||||
key={aspectRatio}
|
||||
selected={selection.aspectRatio === aspectRatio}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
onClick={() => updateDialog({ aspectRatio })}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__ratio-wireframe"
|
||||
data-ratio={aspectRatio}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{aspectRatio}</span>
|
||||
</OptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
尺寸
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{selection.options.imageSizes.map((imageSize) => (
|
||||
<OptionChoice
|
||||
key={imageSize}
|
||||
selected={selection.imageSize === imageSize}
|
||||
onClick={() => updateDialog({ imageSize })}
|
||||
>
|
||||
{imageSize}
|
||||
</OptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
|
||||
<PlatformInlineOptionButton
|
||||
ref={modelButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
|
||||
aria-label={`${labelPrefix}模型 ${selectedModelLabel}`}
|
||||
aria-expanded={openPanel === 'model'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => togglePanel('model')}
|
||||
>
|
||||
<span className="image-canvas-editor__model-trigger-label">
|
||||
<span className="image-canvas-editor__model-icon" aria-hidden="true">
|
||||
<Cpu />
|
||||
</span>
|
||||
<span>{selectedModelLabel}</span>
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'model'
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
|
||||
label={`${labelPrefix}模型选项`}
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(modelButtonRef.current, 'above')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
|
||||
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
|
||||
const selected = selection.model === option.value;
|
||||
return (
|
||||
<OptionChoice
|
||||
key={option.value}
|
||||
selected={selected}
|
||||
className="image-canvas-editor__option-popover-choice--model"
|
||||
onClick={() => updateImageModel(option.value)}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__model-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Cpu />
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
<Check
|
||||
className="image-canvas-editor__option-selected-check"
|
||||
data-visible={selected}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</OptionChoice>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{typeof cost === 'number' ? (
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={isGenerating}
|
||||
aria-label={submitAriaLabel}
|
||||
>
|
||||
{isGenerating ? (
|
||||
'生成中'
|
||||
) : (
|
||||
<>
|
||||
<span>{submitLabel}</span>
|
||||
<span className="image-canvas-editor__mud-point-inline">
|
||||
{cost}泥点
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
chooseGenerationPlacement,
|
||||
centerViewportOnPlacement,
|
||||
} from './ImageCanvasGenerationPlacementModel';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
const canvasSize = { width: 900, height: 640 };
|
||||
const viewport: CanvasViewport = { x: 10, y: 20, scale: 2 };
|
||||
@@ -35,6 +39,26 @@ function layer(overrides: Partial<CanvasLayer>): CanvasLayer {
|
||||
};
|
||||
}
|
||||
|
||||
function generationDialog(
|
||||
overrides: Partial<CanvasGenerationDialogState>,
|
||||
): CanvasGenerationDialogState {
|
||||
return {
|
||||
id: 'dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationPlacementModel', () => {
|
||||
it('places an empty-canvas generation frame at the current viewport center', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
@@ -94,10 +118,86 @@ describe('ImageCanvasGenerationPlacementModel', () => {
|
||||
expect(placement.y).toBe(-22);
|
||||
});
|
||||
|
||||
it('centers the viewport on the chosen placement without changing zoom', () => {
|
||||
const nextViewport = centerViewportOnPlacement({
|
||||
it('does not treat hidden layers as generation placement blockers', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame: { ...frame, width: 100, height: 100 },
|
||||
layers: [
|
||||
layer({
|
||||
id: 'hidden-center',
|
||||
hidden: true,
|
||||
x: 170,
|
||||
y: 110,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
],
|
||||
generationDialogs: [],
|
||||
});
|
||||
|
||||
expect(placement.x).toBe(170);
|
||||
expect(placement.y).toBe(100);
|
||||
});
|
||||
|
||||
it('chooses the nearest empty slot when multiple blockers surround the viewport center', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize: { width: 1000, height: 1000 },
|
||||
viewport: { x: 500, y: 500, scale: 1 },
|
||||
frame: { ...frame, width: 100, height: 100 },
|
||||
layers: [
|
||||
layer({
|
||||
id: 'left-blocker',
|
||||
x: -148,
|
||||
y: -68,
|
||||
width: 96,
|
||||
height: 136,
|
||||
}),
|
||||
layer({
|
||||
id: 'top-blocker',
|
||||
x: -68,
|
||||
y: -148,
|
||||
width: 136,
|
||||
height: 96,
|
||||
}),
|
||||
],
|
||||
generationDialogs: [],
|
||||
});
|
||||
|
||||
expect(placement.x).toBe(-20);
|
||||
expect(placement.y).toBe(-20);
|
||||
});
|
||||
|
||||
it('avoids existing generation placeholders when choosing a new placement', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame: { ...frame, width: 100, height: 100 },
|
||||
layers: [],
|
||||
generationDialogs: [
|
||||
generationDialog({
|
||||
id: 'existing-placeholder',
|
||||
placeholder: {
|
||||
x: 170,
|
||||
y: 110,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(placement.x).toBe(170);
|
||||
expect(placement.y).toBe(-22);
|
||||
});
|
||||
|
||||
it('centers the viewport on the chosen placement without changing zoom', () => {
|
||||
const zoomedViewport = { ...viewport, scale: 1.75 };
|
||||
const nextViewport = centerViewportOnPlacement({
|
||||
canvasSize,
|
||||
viewport: zoomedViewport,
|
||||
placement: {
|
||||
x: 170,
|
||||
y: 262,
|
||||
@@ -108,6 +208,7 @@ describe('ImageCanvasGenerationPlacementModel', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextViewport).toEqual({ x: 10, y: -304, scale: 2 });
|
||||
expect(nextViewport.scale).toBe(zoomedViewport.scale);
|
||||
expect(nextViewport).toEqual({ x: 65, y: -226, scale: 1.75 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +158,26 @@ function buildPlacementCandidates({
|
||||
);
|
||||
});
|
||||
|
||||
// 组合不同阻挡物的水平 / 垂直边界,覆盖中心被多块图层夹住时的最近空位。
|
||||
const edgeXs = new Set<number>([baseX]);
|
||||
const edgeYs = new Set<number>([baseY]);
|
||||
blockingRects.forEach((rect) => {
|
||||
edgeXs.add(rect.x + rect.width);
|
||||
edgeXs.add(rect.x - frame.width);
|
||||
edgeYs.add(rect.y + rect.height);
|
||||
edgeYs.add(rect.y - frame.height);
|
||||
});
|
||||
edgeXs.forEach((x) => {
|
||||
edgeYs.forEach((y) => {
|
||||
pushUniqueCandidate(candidates, {
|
||||
x,
|
||||
y,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
for (let ring = 1; ring <= MAX_PLACEMENT_RING; ring += 1) {
|
||||
const offsets = [
|
||||
{ x: ring, y: 0 },
|
||||
|
||||
@@ -227,11 +227,15 @@ export function buildImageGenerationSubmissionPlan({
|
||||
};
|
||||
}
|
||||
|
||||
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
|
||||
return {
|
||||
kind: 'image',
|
||||
normalizedPrompt,
|
||||
input: {
|
||||
prompt: normalizedPrompt,
|
||||
model: imageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
...(dialog.generationReferences?.length
|
||||
? {
|
||||
referenceImageSrcs: dialog.generationReferences.map(
|
||||
@@ -246,6 +250,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
dialog.generationReferences,
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -101,10 +101,31 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' }));
|
||||
expect(openSpecDialog).toHaveBeenCalledWith('icon');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '图标素材规范' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
expect(requestUpload).toHaveBeenCalledWith('icon-spec');
|
||||
});
|
||||
|
||||
it('keeps the spec reference as a single first-row card without extra source buttons', () => {
|
||||
render(<IconComposerHarness initialDialog={createIconDialog()} />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
const firstRow = panel.querySelector('.image-canvas-editor__reference-strip');
|
||||
|
||||
expect(firstRow).toBeTruthy();
|
||||
expect(
|
||||
within(firstRow as HTMLElement).getByRole('button', {
|
||||
name: '图标素材规范',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__icon-spec-actions'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '画布' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '新建' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '上传' })).toBeNull();
|
||||
});
|
||||
|
||||
it('updates descriptions, adds more descriptions and submits', () => {
|
||||
const updateIconDescription = vi.fn();
|
||||
const addIconDescription = vi.fn();
|
||||
@@ -142,7 +163,13 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
);
|
||||
|
||||
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from 'react';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
@@ -17,6 +16,7 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import {
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
@@ -95,46 +95,40 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={iconSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__icon-spec-card image-canvas-editor__reference-chip image-canvas-editor__reference-chip--icon image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label={dialog.iconSpecReference?.label ?? '图标素材规范'}
|
||||
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon image-canvas-editor__icon-spec-preview">
|
||||
{dialog.iconSpecReference?.src ? (
|
||||
<img
|
||||
src={dialog.iconSpecReference.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</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-copy">
|
||||
<span className="image-canvas-editor__icon-spec-eyebrow">
|
||||
图标素材规范
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-title">
|
||||
{dialog.iconSpecReference?.label ?? '待选择'}
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-title">
|
||||
{dialog.iconSpecReference?.label ?? '待选择'}
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-state">
|
||||
{dialog.iconSpecReference ? '已绑定' : '待绑定'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-state">
|
||||
{dialog.iconSpecReference ? '已绑定' : '待绑定'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
{isIconSpecMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
@@ -164,33 +158,6 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
</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
|
||||
@@ -249,23 +216,20 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
>
|
||||
添加素材描述
|
||||
</button>
|
||||
</div>
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.icon}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -51,7 +52,80 @@ function renderPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function UiDesignHarness({
|
||||
initialDialog,
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
const referenceButtonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
return dialog ? (
|
||||
<ImageCanvasSpecGenerationPanelView
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
generationReferenceButtonRef={referenceButtonRef}
|
||||
setGenerateDialog={setDialog}
|
||||
renderEditorPortal={(node) => node}
|
||||
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
||||
onUpdateSpecFormValue={vi.fn()}
|
||||
onRequestUpload={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
it('keeps the reference row above fields for spec panels and shows mud point text', () => {
|
||||
renderPanel({ dialog: createSpecDialog() });
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('renders UI design as a single borderless prompt below the first reference row', () => {
|
||||
render(
|
||||
<UiDesignHarness
|
||||
initialDialog={{
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
uiDesignSpecReference: null,
|
||||
imageModel: 'nanobanana2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
|
||||
const prompt = screen.getByRole('textbox', { name: 'UI设计要求' });
|
||||
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(panel.textContent).not.toContain('UI设计要求');
|
||||
expect(prompt.getAttribute('placeholder')).toBe(
|
||||
'你希望这个 UI 长什么样?',
|
||||
);
|
||||
expect(prompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
const submitButton = screen.getByRole('button', { name: '生成UI设计图' });
|
||||
expect(submitButton.textContent).toBe('生成12泥点');
|
||||
});
|
||||
|
||||
it('renders character spec fields and forwards updates', () => {
|
||||
const updateSpecFormValue = vi.fn();
|
||||
renderPanel({
|
||||
@@ -129,6 +203,14 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
expect(submitSpec).toHaveBeenCalledWith(dialog);
|
||||
});
|
||||
|
||||
it('renders compact spec submit cost with mud point text', () => {
|
||||
renderPanel({ dialog: createSpecDialog() });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
|
||||
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('disables controls while generating and renders failure state', () => {
|
||||
const submitSpec = vi.fn();
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { ClipboardList, ImagePlus } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../common/PlatformTextField';
|
||||
import {
|
||||
CHARACTER_SPEC_VIEW_OPTIONS,
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG,
|
||||
SPEC_GENERATION_COST,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
|
||||
type ImageCanvasSpecGenerationPanelViewProps = {
|
||||
dialog: GenerateDialogState;
|
||||
@@ -45,6 +47,7 @@ type ImageCanvasSpecGenerationPanelViewProps = {
|
||||
onOpenSpecDialog?: (specType: SpecGenerationType) => void;
|
||||
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -61,6 +64,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
onOpenSpecDialog,
|
||||
onUpdateSpecFormValue,
|
||||
onRequestUpload,
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
}: ImageCanvasSpecGenerationPanelViewProps) {
|
||||
const isUiDesignDialog = dialog.mode === 'ui-design';
|
||||
@@ -94,24 +98,49 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{(isUiDesignDialog || dialog.specType) ? (
|
||||
<button
|
||||
ref={generationReferenceButtonRef}
|
||||
type="button"
|
||||
className={[
|
||||
'image-canvas-editor__reference-chip',
|
||||
isUiDesignDialog
|
||||
? 'image-canvas-editor__reference-chip--ui'
|
||||
: 'image-canvas-editor__reference-chip--spec',
|
||||
'image-canvas-editor__reference-chip--upload',
|
||||
].join(' ')}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={openReferenceMenu}
|
||||
aria-label={referenceLabel}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
{reference ? (
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
) : isUiDesignDialog ? (
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ClipboardList className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{reference?.label ?? referenceLabel}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="image-canvas-editor__spec-fields">
|
||||
{isUiDesignDialog ? (
|
||||
<label className="image-canvas-editor__field-block">
|
||||
<PlatformFieldLabel
|
||||
variant="form"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
UI设计要求
|
||||
</PlatformFieldLabel>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="UI设计要求"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="描述要生成的UI界面"
|
||||
placeholder="你希望这个 UI 长什么样?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
onChange={(event) => {
|
||||
const nextPrompt = event.target.value;
|
||||
if (setGenerateDialog) {
|
||||
@@ -254,35 +283,6 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{isUiDesignDialog || dialog.specType ? (
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__generation-ref">
|
||||
<PlatformFieldLabel
|
||||
variant="form"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
{referenceLabel}
|
||||
</PlatformFieldLabel>
|
||||
<button
|
||||
ref={generationReferenceButtonRef}
|
||||
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={openReferenceMenu}
|
||||
aria-label={referenceLabel}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
{reference ? (
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
) : (
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
{reference?.label ?? '添加参考图'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
@@ -296,18 +296,44 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__spec-footer">
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label={isUiDesignDialog ? '生成UI设计图' : '提交生成规范'}
|
||||
>
|
||||
{dialog.status === 'generating'
|
||||
? '生成中'
|
||||
: `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
|
||||
</PlatformActionButton>
|
||||
{isUiDesignDialog ? (
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.uiDesign}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成UI设计图"
|
||||
optionLabelPrefix="生成图片"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden="true" />
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="提交生成规范"
|
||||
>
|
||||
{dialog.status === 'generating' ? (
|
||||
'生成中'
|
||||
) : (
|
||||
<>
|
||||
<span>生成</span>
|
||||
<span className="image-canvas-editor__mud-point-inline">
|
||||
{SPEC_GENERATION_COST}泥点
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
|
||||
|
||||
@@ -199,10 +199,14 @@ describe('ImageCanvasWorldView', () => {
|
||||
placeholder: undefined,
|
||||
}),
|
||||
],
|
||||
generateDialog: dialog,
|
||||
});
|
||||
|
||||
const frame = screen.getByRole('button', { name: '图标素材生成占位图' });
|
||||
|
||||
expect(frame.className).toContain(
|
||||
'image-canvas-editor__generation-frame--focused',
|
||||
);
|
||||
expect(within(frame).getByText('Icon Generator')).toBeTruthy();
|
||||
expect(within(frame).getByText('图标')).toBeTruthy();
|
||||
expect(within(frame).getByText('1024 x 768')).toBeTruthy();
|
||||
@@ -218,4 +222,90 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
|
||||
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides idle placeholder chrome after blur and restores it when focused', () => {
|
||||
const dialog = createGenerationDialog({
|
||||
id: 'dialog-idle',
|
||||
mode: 'generate',
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
const { rerender, props } = renderWorldView({
|
||||
canvasGenerationDialogs: [dialog],
|
||||
generateDialog: null,
|
||||
});
|
||||
|
||||
const blurredFrame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
expect(blurredFrame.className).not.toContain(
|
||||
'image-canvas-editor__generation-frame--focused',
|
||||
);
|
||||
expect(within(blurredFrame).queryByText('Image Generator')).toBeNull();
|
||||
expect(within(blurredFrame).queryByText('1024 x 768')).toBeNull();
|
||||
|
||||
rerender(<ImageCanvasWorldView {...props} generateDialog={dialog} />);
|
||||
|
||||
const focusedFrame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
expect(focusedFrame.className).toContain(
|
||||
'image-canvas-editor__generation-frame--focused',
|
||||
);
|
||||
expect(within(focusedFrame).getByText('Image Generator')).toBeTruthy();
|
||||
expect(within(focusedFrame).getByText('1024 x 768')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps layer and generation chrome readable when the canvas is zoomed out', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const dialog = createGenerationDialog({ id: 'dialog-focused' });
|
||||
|
||||
renderWorldView({
|
||||
viewport: { x: 0, y: 0, scale: 0.25 },
|
||||
layers: [layer],
|
||||
hoveredLayerId: layer.id,
|
||||
canvasGenerationDialogs: [dialog],
|
||||
generateDialog: dialog,
|
||||
});
|
||||
|
||||
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
|
||||
const frame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
const inverseScale = '4';
|
||||
|
||||
expect(
|
||||
(within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
within(layerButton)
|
||||
.getByRole('button', { name: '查看角色主图图片信息' })
|
||||
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
});
|
||||
|
||||
it('uses a plain information icon for metadata corner actions', () => {
|
||||
const layer = createLayer();
|
||||
renderWorldView({ layers: [layer] });
|
||||
|
||||
const metadataButton = screen.getByRole('button', {
|
||||
name: '查看角色主图图片信息',
|
||||
});
|
||||
|
||||
expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain(
|
||||
'lucide-info',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user