拆分图片画布生成表面编排
新增 useImageCanvasGenerationSurface 收口生成浮层编排。 主视图移除生成 Composer 大段 props 胶水。 舞台控制模型移除重复生成锚点派生。 补充生成表面 hook 单测并更新拆分文档与跟踪记录。
This commit is contained in:
@@ -142,3 +142,4 @@
|
|||||||
- 2026-06-17 前端拆分第二十四阶段:新增 `useImageCanvasAssetCanvasBridge`,把删除素材清理画布图层、素材加入画布、素材 pointer 拖入画布 / 文件夹、画布 drag/drop 分流和文件拖入画布参数组装从主视图抽成素材到画布桥接 hook;主视图继续保留素材库事实、上传读取、工程资源持久化和历史触发。新增 hook 单测覆盖素材建层、pointer drop 入画布和删除素材清理关联图层 / 选中态;主视图从 1133 行降至 1086 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 是完整设置面板,包含当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,HEX 输入 `#ffffff` 后变为白色,按 Escape 关闭面板;登录后上传图片请求 `/api/editor/assets` 返回 200,素材库出现上传素材,`AI画布工具栏` 保持可见。
|
- 2026-06-17 前端拆分第二十四阶段:新增 `useImageCanvasAssetCanvasBridge`,把删除素材清理画布图层、素材加入画布、素材 pointer 拖入画布 / 文件夹、画布 drag/drop 分流和文件拖入画布参数组装从主视图抽成素材到画布桥接 hook;主视图继续保留素材库事实、上传读取、工程资源持久化和历史触发。新增 hook 单测覆盖素材建层、pointer drop 入画布和删除素材清理关联图层 / 选中态;主视图从 1133 行降至 1086 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 是完整设置面板,包含当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,HEX 输入 `#ffffff` 后变为白色,按 Escape 关闭面板;登录后上传图片请求 `/api/editor/assets` 返回 200,素材库出现上传素材,`AI画布工具栏` 保持可见。
|
||||||
- 2026-06-17 前端拆分第二十五阶段:新增 `ImageCanvasStageControllerModel` 和 `useImageCanvasStageController`,把舞台派生状态、生成 / 选中浮层位置、右键菜单目标、空白画布右键、图层右键和清空画布焦点从主视图抽出;主视图继续保留工具切换、上传 / 生成入口、图层命令、项目持久化和舞台 pointer 状态机。新增模型和 hook 单测覆盖选中 / 生成锚定、右键菜单位置、显示 / 解锁判断、清空焦点和空白 / 图层右键菜单;主视图从 1086 行降至 993 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/routing/appPageRoutes.test.ts`、`npm run typecheck`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`,关闭登录后 `画布背景色` 打开完整 `画布背景设置` dialog,包含色相、自定义颜色、预设、HEX 和恢复默认;点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,`AI画布工具栏` 保持可见,截图留存于 `output/playwright/editor-stage-controller-smoke-20260617.png`。
|
- 2026-06-17 前端拆分第二十五阶段:新增 `ImageCanvasStageControllerModel` 和 `useImageCanvasStageController`,把舞台派生状态、生成 / 选中浮层位置、右键菜单目标、空白画布右键、图层右键和清空画布焦点从主视图抽出;主视图继续保留工具切换、上传 / 生成入口、图层命令、项目持久化和舞台 pointer 状态机。新增模型和 hook 单测覆盖选中 / 生成锚定、右键菜单位置、显示 / 解锁判断、清空焦点和空白 / 图层右键菜单;主视图从 1086 行降至 993 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/routing/appPageRoutes.test.ts`、`npm run typecheck`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`,关闭登录后 `画布背景色` 打开完整 `画布背景设置` dialog,包含色相、自定义颜色、预设、HEX 和恢复默认;点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,`AI画布工具栏` 保持可见,截图留存于 `output/playwright/editor-stage-controller-smoke-20260617.png`。
|
||||||
- 2026-06-17 前端拆分第二十六阶段:新增 `ImageCanvasTopbarView`,把返回项目入口、项目标题展示 / 重命名表单、下载画布素材按钮和导出状态提示从主视图抽出;主视图继续保留 chrome hook、项目持久化、导出工作流和实际导出副作用。新增组件单测覆盖返回入口、标题编辑入口、重命名提交 / 取消、导出按钮禁用 / 启用和导出状态提示;主视图从 993 行降至 905 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasTopbarView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录直接显示 `账号入口`,顶栏返回项目入口、项目名、`画布` 标签和下载按钮均可见;关闭登录后打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,`AI画布工具栏` 保持可见,截图留存于 `output/playwright/editor-topbar-smoke-20260617.png`。
|
- 2026-06-17 前端拆分第二十六阶段:新增 `ImageCanvasTopbarView`,把返回项目入口、项目标题展示 / 重命名表单、下载画布素材按钮和导出状态提示从主视图抽出;主视图继续保留 chrome hook、项目持久化、导出工作流和实际导出副作用。新增组件单测覆盖返回入口、标题编辑入口、重命名提交 / 取消、导出按钮禁用 / 启用和导出状态提示;主视图从 993 行降至 905 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasTopbarView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录直接显示 `账号入口`,顶栏返回项目入口、项目名、`画布` 标签和下载按钮均可见;关闭登录后打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,`AI画布工具栏` 保持可见,截图留存于 `output/playwright/editor-topbar-smoke-20260617.png`。
|
||||||
|
- 2026-06-17 前端拆分第二十七阶段:新增 `useImageCanvasGenerationSurface`,把生成 Composer JSX、生成工具切换分流、普通生图 / 图标生成 / 快速编辑 / 角色动画浮层定位从主视图抽出;`useImageCanvasGenerationWorkflow` 继续负责生成状态机和真实 API 提交。同步移除 `ImageCanvasStageControllerModel` 中重复的生成锚点 / Composer 位置派生,避免舞台控制器和生成表面重复持有生成浮层职责;主视图从 905 行降至 793 行。rebase 到远端 `支持规范参考图输入` 后,生成表面继续透传角色形象规范和常规参考图入口,并把左下 dock / 底部工具栏层级提到 Composer 之上,避免生成输入框盖住常用工具和背景面板。验证命令:`npm run test -- src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录直接显示 `账号入口`,关闭登录后 `画布背景设置` 保持完整面板,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,再点击 `生成角色形象` 能打开包含 `角色形象规范` 和 `常规参考图` 的对话框,控制台仅有未登录 refresh 401。
|
||||||
|
|||||||
@@ -210,9 +210,9 @@
|
|||||||
## 第二十五阶段模块
|
## 第二十五阶段模块
|
||||||
|
|
||||||
- `ImageCanvasStageControllerModel.ts`
|
- `ImageCanvasStageControllerModel.ts`
|
||||||
- 承载舞台派生状态和右键菜单模型:选中图层、生成对象锚点、生成输入框位置、选中浮动工具栏位置、图片菜单图层、右键菜单目标图层,以及显示 / 解锁菜单文案判断。
|
- 承载舞台派生状态和右键菜单模型:选中图层、选中浮动工具栏位置、图片菜单图层、右键菜单目标图层,以及显示 / 解锁菜单文案判断。
|
||||||
- 该模型复用既有图层命令模型与浮层定位模型,不重新实现坐标公式,避免拆分后出现第二套右键目标和浮层定位规则。
|
- 该模型复用既有图层命令模型与浮层定位模型,不重新实现右键目标和选中工具栏坐标规则;生成 Composer 锚点不属于舞台控制器,后续由生成表面编排统一负责。
|
||||||
- 新增单测覆盖生成锚定、选中工具栏位置、右键目标集合、显示 / 解锁判断和菜单位置限制。
|
- 新增单测覆盖选中工具栏位置、右键目标集合、显示 / 解锁判断和菜单位置限制。
|
||||||
|
|
||||||
- `useImageCanvasStageController.ts`
|
- `useImageCanvasStageController.ts`
|
||||||
- 承载舞台控制胶水:清空画布焦点、空白画布右键菜单和图层右键菜单处理。
|
- 承载舞台控制胶水:清空画布焦点、空白画布右键菜单和图层右键菜单处理。
|
||||||
@@ -227,6 +227,14 @@
|
|||||||
- 新增组件单测覆盖返回入口、标题编辑入口、重命名提交 / 取消、导出按钮禁用 / 启用和导出状态提示。
|
- 新增组件单测覆盖返回入口、标题编辑入口、重命名提交 / 取消、导出按钮禁用 / 启用和导出状态提示。
|
||||||
- 本阶段主视图从 993 行降至 905 行;后续可继续评估隐藏上传 input / 侧栏拖拽预览这类根级浮层是否值得抽为编辑器 shell 视图。
|
- 本阶段主视图从 993 行降至 905 行;后续可继续评估隐藏上传 input / 侧栏拖拽预览这类根级浮层是否值得抽为编辑器 shell 视图。
|
||||||
|
|
||||||
|
## 第二十七阶段模块
|
||||||
|
|
||||||
|
- `useImageCanvasGenerationSurface.tsx`
|
||||||
|
- 承载 Lovart 式生成表面编排:组合 `useImageCanvasGenerationWorkflow`,统一计算普通生图、图标生成、快速编辑和角色动画的浮层位置,并构建 `ImageCanvasGenerationComposerView` 节点。
|
||||||
|
- `useImageCanvasGenerationWorkflow` 继续作为生成状态机和真实 API 提交入口;生成表面 hook 只收口 Composer JSX、工具切换分流和浮层定位,避免主视图继续维护大段生成 props 胶水。
|
||||||
|
- 本阶段同步把生成 Composer 锚点从 `ImageCanvasStageControllerModel` 移出,避免舞台控制器和生成表面各自计算一套生成浮层状态。
|
||||||
|
- 新增 hook 单测覆盖生成工具切换、Composer 位置、关闭生成输入框和规范菜单;主视图从 905 行降至 793 行。
|
||||||
|
|
||||||
## 后续阶段
|
## 后续阶段
|
||||||
|
|
||||||
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
||||||
@@ -234,7 +242,7 @@
|
|||||||
|
|
||||||
## 验证计划
|
## 验证计划
|
||||||
|
|
||||||
- `npm run test -- src/components/image-editor/ImageCanvasTopbarView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`
|
- `npm run test -- src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
||||||
- `npm run typecheck`
|
- `npm run typecheck`
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
|
||||||
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
|
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
|
||||||
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
||||||
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
||||||
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
|
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
|
||||||
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||||
import {
|
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||||
isCanvasGenerationComposerVisible,
|
|
||||||
resolveCharacterAnimationPanelStyle,
|
|
||||||
resolveIconComposerStyle,
|
|
||||||
resolveQuickEditPanelStyle,
|
|
||||||
} from './ImageCanvasOverlayModel';
|
|
||||||
import type {
|
import type {
|
||||||
AssetPointerDragState,
|
AssetPointerDragState,
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
@@ -30,7 +24,7 @@ import {
|
|||||||
} from './useImageCanvasAssetCanvasBridge';
|
} from './useImageCanvasAssetCanvasBridge';
|
||||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||||
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
||||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
|
||||||
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
|
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
|
||||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||||
@@ -330,13 +324,44 @@ export function ImageCanvasEditorView() {
|
|||||||
projectId,
|
projectId,
|
||||||
projectTitle,
|
projectTitle,
|
||||||
});
|
});
|
||||||
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
const {
|
||||||
|
uploadInputRef,
|
||||||
|
setUploadTarget,
|
||||||
|
requestUpload,
|
||||||
|
handleUploadInputChange,
|
||||||
|
addUploadedFiles,
|
||||||
|
} = useImageCanvasUploadWorkflow({
|
||||||
|
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||||
|
openEditorLoginModal,
|
||||||
|
assetFolders,
|
||||||
|
activeUploadFolderId,
|
||||||
|
canvasSize,
|
||||||
|
viewport,
|
||||||
|
activeTool,
|
||||||
|
allocateUploadIndex: () => {
|
||||||
|
layerCounterRef.current += 1;
|
||||||
|
return layerCounterRef.current;
|
||||||
|
},
|
||||||
|
setAssetFolders,
|
||||||
|
setAssets,
|
||||||
|
setLayers,
|
||||||
|
setGenerateDialog,
|
||||||
|
setActiveSidebarPanel,
|
||||||
|
appendCanvasLayersWithResources,
|
||||||
|
selectSingleLayer,
|
||||||
|
});
|
||||||
|
const generationSurface = useImageCanvasGenerationSurface({
|
||||||
layers,
|
layers,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
viewport,
|
viewport,
|
||||||
layerCounterRef,
|
layerCounterRef,
|
||||||
|
specToolWrapRef,
|
||||||
|
characterSpecButtonRef,
|
||||||
|
characterReferenceButtonRef,
|
||||||
|
iconSpecButtonRef,
|
||||||
generateDialog,
|
generateDialog,
|
||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
|
activeCanvasGenerationDialog,
|
||||||
openCanvasGenerationDialog,
|
openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
removeCanvasGenerationDialogById,
|
||||||
@@ -349,58 +374,35 @@ export function ImageCanvasEditorView() {
|
|||||||
setActiveSidebarPanel,
|
setActiveSidebarPanel,
|
||||||
setMetadataLayer,
|
setMetadataLayer,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
|
requestUpload,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
quickEditPanel,
|
quickEditPanel,
|
||||||
setQuickEditPanel,
|
setQuickEditPanel,
|
||||||
quickEditSourceLayer,
|
|
||||||
quickEditSizeOptions,
|
|
||||||
quickEditModelOptions,
|
|
||||||
characterAnimationPanel,
|
|
||||||
setCharacterAnimationPanel,
|
|
||||||
characterAnimationSourceLayer,
|
|
||||||
characterAnimationPrice,
|
|
||||||
iconDescriptionValues,
|
|
||||||
isSpecMenuOpen,
|
|
||||||
setIsSpecMenuOpen,
|
setIsSpecMenuOpen,
|
||||||
isCharacterSpecMenuOpen,
|
|
||||||
setIsCharacterSpecMenuOpen,
|
setIsCharacterSpecMenuOpen,
|
||||||
isCharacterReferenceMenuOpen,
|
|
||||||
setIsCharacterReferenceMenuOpen,
|
setIsCharacterReferenceMenuOpen,
|
||||||
isPickingCharacterSpecFromCanvas,
|
isPickingCharacterSpecFromCanvas,
|
||||||
setIsPickingCharacterSpecFromCanvas,
|
setIsPickingCharacterSpecFromCanvas,
|
||||||
isPickingCharacterReferenceFromCanvas,
|
isPickingCharacterReferenceFromCanvas,
|
||||||
setIsPickingCharacterReferenceFromCanvas,
|
setIsPickingCharacterReferenceFromCanvas,
|
||||||
isIconSpecMenuOpen,
|
|
||||||
setIsIconSpecMenuOpen,
|
setIsIconSpecMenuOpen,
|
||||||
isPickingIconSpecFromCanvas,
|
isPickingIconSpecFromCanvas,
|
||||||
setIsPickingIconSpecFromCanvas,
|
setIsPickingIconSpecFromCanvas,
|
||||||
openGenerateDialog,
|
|
||||||
openSpecDialog,
|
|
||||||
openCharacterAnimationPanel,
|
openCharacterAnimationPanel,
|
||||||
openCharacterGenerationDialog,
|
|
||||||
openIconGenerationDialog,
|
|
||||||
openEditDialog,
|
openEditDialog,
|
||||||
openQuickEditPanel,
|
openQuickEditPanel,
|
||||||
pickCharacterSpecFromLayer,
|
pickCharacterSpecFromLayer,
|
||||||
pickCharacterReferenceFromLayer,
|
pickCharacterReferenceFromLayer,
|
||||||
pickIconSpecFromLayer,
|
pickIconSpecFromLayer,
|
||||||
submitIconSpritesheetGeneration,
|
|
||||||
submitQuickEdit,
|
|
||||||
submitImageGeneration,
|
|
||||||
updateSpecFormValue,
|
|
||||||
updateIconDescription,
|
|
||||||
addIconDescription,
|
|
||||||
updateCharacterAnimationDuration,
|
|
||||||
rememberImageModel,
|
|
||||||
submitCharacterAnimation,
|
|
||||||
hideGeneratedLayerPanelAfterBlur,
|
hideGeneratedLayerPanelAfterBlur,
|
||||||
closeGenerateComposer,
|
|
||||||
clearDeletedLayerGenerationState,
|
clearDeletedLayerGenerationState,
|
||||||
} = generationWorkflow;
|
generationComposerStyle,
|
||||||
|
generationComposerNode,
|
||||||
|
switchGenerationTool,
|
||||||
|
} = generationSurface;
|
||||||
const {
|
const {
|
||||||
selectedLayer,
|
selectedLayer,
|
||||||
generationComposerStyle,
|
|
||||||
selectedToolbarStyle,
|
selectedToolbarStyle,
|
||||||
imageContextMenuLayer,
|
imageContextMenuLayer,
|
||||||
contextShouldShowLayer,
|
contextShouldShowLayer,
|
||||||
@@ -412,7 +414,6 @@ export function ImageCanvasEditorView() {
|
|||||||
layers,
|
layers,
|
||||||
selectedLayerId,
|
selectedLayerId,
|
||||||
selectedLayerIds,
|
selectedLayerIds,
|
||||||
activeCanvasGenerationDialog,
|
|
||||||
imageContextMenu,
|
imageContextMenu,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
@@ -423,23 +424,6 @@ export function ImageCanvasEditorView() {
|
|||||||
hideGeneratedLayerPanelAfterBlur,
|
hideGeneratedLayerPanelAfterBlur,
|
||||||
getCanvasPointFromClient,
|
getCanvasPointFromClient,
|
||||||
});
|
});
|
||||||
const iconComposerStyle = resolveIconComposerStyle({
|
|
||||||
dialog: activeCanvasGenerationDialog,
|
|
||||||
composerStyle: generationComposerStyle,
|
|
||||||
iconDescriptionCount: iconDescriptionValues.length,
|
|
||||||
});
|
|
||||||
const quickEditPanelStyle = resolveQuickEditPanelStyle({
|
|
||||||
panel: quickEditPanel,
|
|
||||||
sourceLayer: quickEditSourceLayer,
|
|
||||||
viewport,
|
|
||||||
canvasSize,
|
|
||||||
});
|
|
||||||
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
|
|
||||||
panel: characterAnimationPanel,
|
|
||||||
sourceLayer: characterAnimationSourceLayer,
|
|
||||||
viewport,
|
|
||||||
canvasSize,
|
|
||||||
});
|
|
||||||
const {
|
const {
|
||||||
canvasClipboard,
|
canvasClipboard,
|
||||||
pasteCanvasClipboard,
|
pasteCanvasClipboard,
|
||||||
@@ -474,32 +458,6 @@ export function ImageCanvasEditorView() {
|
|||||||
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
||||||
exportLayerImage,
|
exportLayerImage,
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
uploadInputRef,
|
|
||||||
setUploadTarget,
|
|
||||||
requestUpload,
|
|
||||||
handleUploadInputChange,
|
|
||||||
addUploadedFiles,
|
|
||||||
} = useImageCanvasUploadWorkflow({
|
|
||||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
|
||||||
openEditorLoginModal,
|
|
||||||
assetFolders,
|
|
||||||
activeUploadFolderId,
|
|
||||||
canvasSize,
|
|
||||||
viewport,
|
|
||||||
activeTool,
|
|
||||||
allocateUploadIndex: () => {
|
|
||||||
layerCounterRef.current += 1;
|
|
||||||
return layerCounterRef.current;
|
|
||||||
},
|
|
||||||
setAssetFolders,
|
|
||||||
setAssets,
|
|
||||||
setLayers,
|
|
||||||
setGenerateDialog,
|
|
||||||
setActiveSidebarPanel,
|
|
||||||
appendCanvasLayersWithResources,
|
|
||||||
selectSingleLayer,
|
|
||||||
});
|
|
||||||
const {
|
const {
|
||||||
canvasMarquee,
|
canvasMarquee,
|
||||||
isPanning,
|
isPanning,
|
||||||
@@ -627,21 +585,7 @@ export function ImageCanvasEditorView() {
|
|||||||
requestUpload('asset');
|
requestUpload('asset');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tool === 'generate') {
|
if (switchGenerationTool(tool)) {
|
||||||
openGenerateDialog();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tool === 'spec') {
|
|
||||||
setIsSpecMenuOpen((open) => !open);
|
|
||||||
setActiveTool('spec');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tool === 'character') {
|
|
||||||
openCharacterGenerationDialog();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tool === 'icon') {
|
|
||||||
openIconGenerationDialog();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveTool(tool);
|
setActiveTool(tool);
|
||||||
@@ -844,74 +788,7 @@ export function ImageCanvasEditorView() {
|
|||||||
onMinimapPointerDown={handleMinimapPointerDown}
|
onMinimapPointerDown={handleMinimapPointerDown}
|
||||||
onSwitchTool={switchTool}
|
onSwitchTool={switchTool}
|
||||||
>
|
>
|
||||||
<ImageCanvasGenerationComposerView
|
{generationComposerNode}
|
||||||
specToolWrapRef={specToolWrapRef}
|
|
||||||
characterSpecButtonRef={characterSpecButtonRef}
|
|
||||||
characterReferenceButtonRef={characterReferenceButtonRef}
|
|
||||||
iconSpecButtonRef={iconSpecButtonRef}
|
|
||||||
isSpecMenuOpen={isSpecMenuOpen}
|
|
||||||
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
|
|
||||||
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
|
|
||||||
isIconSpecMenuOpen={isIconSpecMenuOpen}
|
|
||||||
isPickingCharacterSpecFromCanvas={isPickingCharacterSpecFromCanvas}
|
|
||||||
isPickingCharacterReferenceFromCanvas={
|
|
||||||
isPickingCharacterReferenceFromCanvas
|
|
||||||
}
|
|
||||||
isPickingIconSpecFromCanvas={isPickingIconSpecFromCanvas}
|
|
||||||
generateDialog={generateDialog}
|
|
||||||
generationComposerStyle={generationComposerStyle}
|
|
||||||
iconComposerStyle={iconComposerStyle}
|
|
||||||
quickEditPanel={quickEditPanel}
|
|
||||||
quickEditSourceLayer={quickEditSourceLayer}
|
|
||||||
quickEditPanelStyle={quickEditPanelStyle}
|
|
||||||
quickEditSizeOptions={quickEditSizeOptions}
|
|
||||||
quickEditModelOptions={quickEditModelOptions}
|
|
||||||
characterAnimationPanel={characterAnimationPanel}
|
|
||||||
characterAnimationSourceLayer={characterAnimationSourceLayer}
|
|
||||||
characterAnimationPanelStyle={characterAnimationPanelStyle}
|
|
||||||
characterAnimationPrice={characterAnimationPrice}
|
|
||||||
setGenerateDialog={setGenerateDialog}
|
|
||||||
setQuickEditPanel={setQuickEditPanel}
|
|
||||||
setCharacterAnimationPanel={setCharacterAnimationPanel}
|
|
||||||
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
|
|
||||||
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
|
|
||||||
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
|
|
||||||
setIsPickingCharacterSpecFromCanvas={
|
|
||||||
setIsPickingCharacterSpecFromCanvas
|
|
||||||
}
|
|
||||||
setIsPickingCharacterReferenceFromCanvas={
|
|
||||||
setIsPickingCharacterReferenceFromCanvas
|
|
||||||
}
|
|
||||||
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
|
|
||||||
onOpenSpecDialog={openSpecDialog}
|
|
||||||
onRequestUpload={requestUpload}
|
|
||||||
onSubmitImageGeneration={(dialog) =>
|
|
||||||
void submitImageGeneration(dialog)
|
|
||||||
}
|
|
||||||
onSubmitIconSpritesheetGeneration={(dialog) =>
|
|
||||||
void submitIconSpritesheetGeneration(dialog)
|
|
||||||
}
|
|
||||||
onSubmitQuickEdit={() => void submitQuickEdit()}
|
|
||||||
onSubmitCharacterAnimation={() => void submitCharacterAnimation()}
|
|
||||||
onCloseGenerateComposer={() => {
|
|
||||||
setGenerateDialog((currentDialog) =>
|
|
||||||
currentDialog?.mode === 'generate'
|
|
||||||
? {
|
|
||||||
...currentDialog,
|
|
||||||
composerOpen: false,
|
|
||||||
}
|
|
||||||
: currentDialog,
|
|
||||||
);
|
|
||||||
setActiveTool('select');
|
|
||||||
}}
|
|
||||||
onUpdateSpecFormValue={updateSpecFormValue}
|
|
||||||
onUpdateIconDescription={updateIconDescription}
|
|
||||||
onAddIconDescription={addIconDescription}
|
|
||||||
onUpdateCharacterAnimationDuration={
|
|
||||||
updateCharacterAnimationDuration
|
|
||||||
}
|
|
||||||
onRememberImageModel={rememberImageModel}
|
|
||||||
/>
|
|
||||||
</ImageCanvasStageView>
|
</ImageCanvasStageView>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
CanvasGenerationDialogState,
|
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
import {
|
import {
|
||||||
@@ -32,28 +31,8 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDialog(
|
|
||||||
overrides: Partial<CanvasGenerationDialogState> = {},
|
|
||||||
): CanvasGenerationDialogState {
|
|
||||||
return {
|
|
||||||
id: 'dialog-a',
|
|
||||||
mode: 'generate',
|
|
||||||
prompt: '',
|
|
||||||
status: 'idle',
|
|
||||||
placeholder: {
|
|
||||||
x: 300,
|
|
||||||
y: 200,
|
|
||||||
width: 360,
|
|
||||||
height: 260,
|
|
||||||
originalWidth: 1024,
|
|
||||||
originalHeight: 1024,
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ImageCanvasStageControllerModel', () => {
|
describe('ImageCanvasStageControllerModel', () => {
|
||||||
it('derives selected layer, generation anchor, and overlay positions', () => {
|
it('derives selected layer, selected toolbar, and context image target', () => {
|
||||||
const selectedLayer = createLayer({ id: 'selected', x: 40, y: 60 });
|
const selectedLayer = createLayer({ id: 'selected', x: 40, y: 60 });
|
||||||
const generatedLayer = createLayer({
|
const generatedLayer = createLayer({
|
||||||
id: 'generated',
|
id: 'generated',
|
||||||
@@ -62,13 +41,11 @@ describe('ImageCanvasStageControllerModel', () => {
|
|||||||
width: 320,
|
width: 320,
|
||||||
height: 180,
|
height: 180,
|
||||||
});
|
});
|
||||||
const dialog = createDialog({ generatedLayerId: generatedLayer.id });
|
|
||||||
|
|
||||||
const model = resolveImageCanvasStageControllerModel({
|
const model = resolveImageCanvasStageControllerModel({
|
||||||
layers: [selectedLayer, generatedLayer],
|
layers: [selectedLayer, generatedLayer],
|
||||||
selectedLayerId: selectedLayer.id,
|
selectedLayerId: selectedLayer.id,
|
||||||
selectedLayerIds: [selectedLayer.id, generatedLayer.id],
|
selectedLayerIds: [selectedLayer.id, generatedLayer.id],
|
||||||
activeCanvasGenerationDialog: dialog,
|
|
||||||
imageContextMenu: { layerId: generatedLayer.id, x: 0, y: 0 },
|
imageContextMenu: { layerId: generatedLayer.id, x: 0, y: 0 },
|
||||||
contextMenu: null,
|
contextMenu: null,
|
||||||
viewport: { x: 10, y: 20, scale: 2 },
|
viewport: { x: 10, y: 20, scale: 2 },
|
||||||
@@ -78,9 +55,6 @@ describe('ImageCanvasStageControllerModel', () => {
|
|||||||
expect(model.selectedLayer).toBe(selectedLayer);
|
expect(model.selectedLayer).toBe(selectedLayer);
|
||||||
expect(model.selectedLayerCount).toBe(2);
|
expect(model.selectedLayerCount).toBe(2);
|
||||||
expect(model.hasMultipleSelectedLayers).toBe(true);
|
expect(model.hasMultipleSelectedLayers).toBe(true);
|
||||||
expect(model.activeGenerationLayer).toBe(generatedLayer);
|
|
||||||
expect(model.generationAnchor).toBe(generatedLayer);
|
|
||||||
expect(model.generationComposerStyle).toEqual({ left: 730, top: 710 });
|
|
||||||
expect(model.selectedToolbarStyle).toEqual({ left: 330, top: 128 });
|
expect(model.selectedToolbarStyle).toEqual({ left: 330, top: 128 });
|
||||||
expect(model.imageContextMenuLayer).toBe(generatedLayer);
|
expect(model.imageContextMenuLayer).toBe(generatedLayer);
|
||||||
});
|
});
|
||||||
@@ -100,7 +74,6 @@ describe('ImageCanvasStageControllerModel', () => {
|
|||||||
layers: [visibleLayer, hiddenLayer],
|
layers: [visibleLayer, hiddenLayer],
|
||||||
selectedLayerId: visibleLayer.id,
|
selectedLayerId: visibleLayer.id,
|
||||||
selectedLayerIds: [visibleLayer.id, hiddenLayer.id],
|
selectedLayerIds: [visibleLayer.id, hiddenLayer.id],
|
||||||
activeCanvasGenerationDialog: null,
|
|
||||||
imageContextMenu: null,
|
imageContextMenu: null,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
viewport: { x: 0, y: 0, scale: 1 },
|
viewport: { x: 0, y: 0, scale: 1 },
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ import {
|
|||||||
} from './ImageCanvasEditorModel';
|
} from './ImageCanvasEditorModel';
|
||||||
import type {
|
import type {
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
CanvasGenerationDialogState,
|
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
CanvasViewport,
|
CanvasViewport,
|
||||||
ImageContextMenuState,
|
ImageContextMenuState,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
import {
|
import {
|
||||||
resolveGenerationAnchor,
|
|
||||||
resolveGenerationComposerStyle,
|
|
||||||
resolveSelectedToolbarStyle,
|
resolveSelectedToolbarStyle,
|
||||||
} from './ImageCanvasOverlayModel';
|
} from './ImageCanvasOverlayModel';
|
||||||
|
|
||||||
@@ -22,9 +19,6 @@ export type ImageCanvasStageControllerModel = {
|
|||||||
selectedLayer: CanvasLayer | null;
|
selectedLayer: CanvasLayer | null;
|
||||||
selectedLayerCount: number;
|
selectedLayerCount: number;
|
||||||
hasMultipleSelectedLayers: boolean;
|
hasMultipleSelectedLayers: boolean;
|
||||||
activeGenerationLayer: CanvasLayer | null;
|
|
||||||
generationAnchor: CanvasLayer | CanvasGenerationDialogState['placeholder'] | null;
|
|
||||||
generationComposerStyle: ReturnType<typeof resolveGenerationComposerStyle>;
|
|
||||||
selectedToolbarStyle: ReturnType<typeof resolveSelectedToolbarStyle>;
|
selectedToolbarStyle: ReturnType<typeof resolveSelectedToolbarStyle>;
|
||||||
imageContextMenuLayer: CanvasLayer | null;
|
imageContextMenuLayer: CanvasLayer | null;
|
||||||
contextTargetIds: string[];
|
contextTargetIds: string[];
|
||||||
@@ -37,7 +31,6 @@ type ResolveImageCanvasStageControllerModelOptions = {
|
|||||||
layers: CanvasLayer[];
|
layers: CanvasLayer[];
|
||||||
selectedLayerId: string | null;
|
selectedLayerId: string | null;
|
||||||
selectedLayerIds: string[];
|
selectedLayerIds: string[];
|
||||||
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
|
|
||||||
imageContextMenu: ImageContextMenuState | null;
|
imageContextMenu: ImageContextMenuState | null;
|
||||||
contextMenu: CanvasContextMenuState | null;
|
contextMenu: CanvasContextMenuState | null;
|
||||||
viewport: CanvasViewport;
|
viewport: CanvasViewport;
|
||||||
@@ -48,7 +41,6 @@ export function resolveImageCanvasStageControllerModel({
|
|||||||
layers,
|
layers,
|
||||||
selectedLayerId,
|
selectedLayerId,
|
||||||
selectedLayerIds,
|
selectedLayerIds,
|
||||||
activeCanvasGenerationDialog,
|
|
||||||
imageContextMenu,
|
imageContextMenu,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -57,18 +49,6 @@ export function resolveImageCanvasStageControllerModel({
|
|||||||
const selectedLayer =
|
const selectedLayer =
|
||||||
layers.find((layer) => layer.id === selectedLayerId) ?? null;
|
layers.find((layer) => layer.id === selectedLayerId) ?? null;
|
||||||
const selectedLayerCount = selectedLayerIds.length;
|
const selectedLayerCount = selectedLayerIds.length;
|
||||||
const activeGenerationLayer =
|
|
||||||
activeCanvasGenerationDialog?.generatedLayerId
|
|
||||||
? (layers.find(
|
|
||||||
(layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId,
|
|
||||||
) ?? null)
|
|
||||||
: null;
|
|
||||||
const generationAnchor = activeCanvasGenerationDialog
|
|
||||||
? resolveGenerationAnchor({
|
|
||||||
dialog: activeCanvasGenerationDialog,
|
|
||||||
generatedLayer: activeGenerationLayer,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const imageContextMenuLayer = imageContextMenu
|
const imageContextMenuLayer = imageContextMenu
|
||||||
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
|
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
@@ -82,13 +62,6 @@ export function resolveImageCanvasStageControllerModel({
|
|||||||
selectedLayer,
|
selectedLayer,
|
||||||
selectedLayerCount,
|
selectedLayerCount,
|
||||||
hasMultipleSelectedLayers: selectedLayerCount > 1,
|
hasMultipleSelectedLayers: selectedLayerCount > 1,
|
||||||
activeGenerationLayer,
|
|
||||||
generationAnchor,
|
|
||||||
generationComposerStyle: resolveGenerationComposerStyle({
|
|
||||||
dialog: activeCanvasGenerationDialog,
|
|
||||||
anchor: generationAnchor,
|
|
||||||
viewport,
|
|
||||||
}),
|
|
||||||
selectedToolbarStyle: resolveSelectedToolbarStyle({
|
selectedToolbarStyle: resolveSelectedToolbarStyle({
|
||||||
selectedLayer,
|
selectedLayer,
|
||||||
viewport,
|
viewport,
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
CanvasTool,
|
||||||
|
GenerateDialogState,
|
||||||
|
ImageContextMenuState,
|
||||||
|
SidebarPanel,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||||
|
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
|
||||||
|
|
||||||
|
vi.mock('../../services/image-editor/editorImageReference', () => ({
|
||||||
|
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import('../../services/image-editor/editorProjectClient')
|
||||||
|
>('../../services/image-editor/editorProjectClient');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
editEditorImage: vi.fn(),
|
||||||
|
generateEditorCharacterAnimation: vi.fn(),
|
||||||
|
generateEditorIconSpritesheet: vi.fn(),
|
||||||
|
generateEditorImage: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||||
|
const id = overrides.id ?? 'layer-a';
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
resourceId: `resource-${id}`,
|
||||||
|
title: id,
|
||||||
|
src: `data:image/png;base64,${id}`,
|
||||||
|
x: 80,
|
||||||
|
y: 90,
|
||||||
|
width: 240,
|
||||||
|
height: 180,
|
||||||
|
originalWidth: 240,
|
||||||
|
originalHeight: 180,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenerationSurfaceHarness() {
|
||||||
|
const [layers, setLayers] = useState<CanvasLayer[]>([createLayer()]);
|
||||||
|
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
||||||
|
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||||||
|
useState<SidebarPanel | null>('assets');
|
||||||
|
const [, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||||
|
const [, setImageContextMenu] = useState<ImageContextMenuState | null>(null);
|
||||||
|
const layerCounterRef = useRef(0);
|
||||||
|
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const dialogs = useCanvasGenerationDialogs();
|
||||||
|
const activeDialog = dialogs.generateDialog;
|
||||||
|
const activeCanvasDialog =
|
||||||
|
activeDialog && 'id' in activeDialog
|
||||||
|
? (activeDialog as CanvasGenerationDialogState)
|
||||||
|
: null;
|
||||||
|
const surface = useImageCanvasGenerationSurface({
|
||||||
|
layers,
|
||||||
|
canvasSize: { width: 900, height: 640 },
|
||||||
|
viewport: { x: 10, y: 20, scale: 2 },
|
||||||
|
layerCounterRef,
|
||||||
|
specToolWrapRef,
|
||||||
|
characterSpecButtonRef,
|
||||||
|
characterReferenceButtonRef,
|
||||||
|
iconSpecButtonRef,
|
||||||
|
generateDialog: dialogs.generateDialog,
|
||||||
|
setGenerateDialog: dialogs.setGenerateDialog,
|
||||||
|
activeCanvasGenerationDialog: activeCanvasDialog,
|
||||||
|
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||||
|
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||||
|
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
||||||
|
removeCanvasGenerationDialogsByLayerId:
|
||||||
|
dialogs.removeCanvasGenerationDialogsByLayerId,
|
||||||
|
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||||
|
appendCanvasLayersWithResources: (nextLayers) =>
|
||||||
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
||||||
|
selectSingleLayer: () => {},
|
||||||
|
fitLayers: () => {},
|
||||||
|
setActiveTool,
|
||||||
|
setActiveSidebarPanel,
|
||||||
|
setMetadataLayer,
|
||||||
|
setImageContextMenu,
|
||||||
|
requestUpload: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span ref={specToolWrapRef}>规范入口</span>
|
||||||
|
<span data-testid="tool">{activeTool}</span>
|
||||||
|
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
||||||
|
<span data-testid="dialog">
|
||||||
|
{activeDialog
|
||||||
|
? `${activeDialog.mode}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
<span data-testid="composer-left">
|
||||||
|
{surface.generationComposerStyle?.left ?? '-'}
|
||||||
|
</span>
|
||||||
|
<span data-testid="composer-top">
|
||||||
|
{surface.generationComposerStyle?.top ?? '-'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => surface.switchGenerationTool('generate')}
|
||||||
|
>
|
||||||
|
切换生成
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => surface.switchGenerationTool('spec')}>
|
||||||
|
切换规范
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => surface.switchGenerationTool('character')}
|
||||||
|
>
|
||||||
|
切换角色
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => surface.switchGenerationTool('icon')}>
|
||||||
|
切换图标
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => surface.switchGenerationTool('text')}>
|
||||||
|
切换文字
|
||||||
|
</button>
|
||||||
|
{surface.generationComposerNode}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useImageCanvasGenerationSurface', () => {
|
||||||
|
it('switches generation tools while leaving non-generation tools untouched', () => {
|
||||||
|
render(<GenerationSurfaceHarness />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '切换文字' }));
|
||||||
|
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
|
||||||
|
expect(screen.getByTestId('tool').textContent).toBe('generate');
|
||||||
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||||
|
'generate:open:placeholder',
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives composer position and closes the generated image composer', () => {
|
||||||
|
render(<GenerationSurfaceHarness />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
|
||||||
|
expect(screen.getByTestId('composer-left').textContent).toBe('450');
|
||||||
|
expect(screen.getByTestId('composer-top').textContent).toBe('750');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
|
||||||
|
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||||
|
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||||||
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||||
|
'generate:closed:placeholder',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the spec menu through the generation surface', () => {
|
||||||
|
render(<GenerationSurfaceHarness />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '切换规范' }));
|
||||||
|
expect(screen.getByTestId('tool').textContent).toBe('spec');
|
||||||
|
const menu = screen.getByRole('menu', { name: '生成规范类型' });
|
||||||
|
expect(within(menu).getByRole('menuitem', { name: '角色形象规范' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
256
src/components/image-editor/useImageCanvasGenerationSurface.tsx
Normal file
256
src/components/image-editor/useImageCanvasGenerationSurface.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
type MutableRefObject,
|
||||||
|
type RefObject,
|
||||||
|
type SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||||
|
import {
|
||||||
|
resolveCharacterAnimationPanelStyle,
|
||||||
|
resolveGenerationAnchor,
|
||||||
|
resolveGenerationComposerStyle,
|
||||||
|
resolveIconComposerStyle,
|
||||||
|
resolveQuickEditPanelStyle,
|
||||||
|
} from './ImageCanvasOverlayModel';
|
||||||
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
CanvasTool,
|
||||||
|
CanvasViewport,
|
||||||
|
GenerateDialogState,
|
||||||
|
ImageContextMenuState,
|
||||||
|
SidebarPanel,
|
||||||
|
UploadTarget,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||||
|
|
||||||
|
type CanvasGenerationDialogUpdater = (
|
||||||
|
dialog: CanvasGenerationDialogState,
|
||||||
|
) => CanvasGenerationDialogState | null;
|
||||||
|
|
||||||
|
type ImageCanvasGenerationSurfaceOptions = {
|
||||||
|
layers: CanvasLayer[];
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
layerCounterRef: MutableRefObject<number>;
|
||||||
|
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||||
|
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
generateDialog: GenerateDialogState | null;
|
||||||
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||||
|
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
|
||||||
|
openCanvasGenerationDialog: (
|
||||||
|
dialog: Omit<CanvasGenerationDialogState, 'id'>,
|
||||||
|
) => void;
|
||||||
|
updateCanvasGenerationDialogById: (
|
||||||
|
dialogId: string,
|
||||||
|
updater: CanvasGenerationDialogUpdater,
|
||||||
|
) => void;
|
||||||
|
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
||||||
|
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
||||||
|
getGeneratingDialogPlaceholder: (
|
||||||
|
dialog: GenerateDialogState,
|
||||||
|
) => GenerateDialogState['placeholder'];
|
||||||
|
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
|
||||||
|
selectSingleLayer: (layerId: string | null) => void;
|
||||||
|
fitLayers: (targetLayers?: CanvasLayer[]) => void;
|
||||||
|
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
|
||||||
|
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||||||
|
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||||
|
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||||
|
requestUpload: (target: UploadTarget) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useImageCanvasGenerationSurface({
|
||||||
|
layers,
|
||||||
|
canvasSize,
|
||||||
|
viewport,
|
||||||
|
layerCounterRef,
|
||||||
|
specToolWrapRef,
|
||||||
|
characterSpecButtonRef,
|
||||||
|
characterReferenceButtonRef,
|
||||||
|
iconSpecButtonRef,
|
||||||
|
generateDialog,
|
||||||
|
setGenerateDialog,
|
||||||
|
activeCanvasGenerationDialog,
|
||||||
|
openCanvasGenerationDialog,
|
||||||
|
updateCanvasGenerationDialogById,
|
||||||
|
removeCanvasGenerationDialogById,
|
||||||
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
|
getGeneratingDialogPlaceholder,
|
||||||
|
appendCanvasLayersWithResources,
|
||||||
|
selectSingleLayer,
|
||||||
|
fitLayers,
|
||||||
|
setActiveTool,
|
||||||
|
setActiveSidebarPanel,
|
||||||
|
setMetadataLayer,
|
||||||
|
setImageContextMenu,
|
||||||
|
requestUpload,
|
||||||
|
}: ImageCanvasGenerationSurfaceOptions) {
|
||||||
|
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
||||||
|
layers,
|
||||||
|
canvasSize,
|
||||||
|
viewport,
|
||||||
|
layerCounterRef,
|
||||||
|
generateDialog,
|
||||||
|
setGenerateDialog,
|
||||||
|
openCanvasGenerationDialog,
|
||||||
|
updateCanvasGenerationDialogById,
|
||||||
|
removeCanvasGenerationDialogById,
|
||||||
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
|
getGeneratingDialogPlaceholder,
|
||||||
|
appendCanvasLayersWithResources,
|
||||||
|
selectSingleLayer,
|
||||||
|
fitLayers,
|
||||||
|
setActiveTool,
|
||||||
|
setActiveSidebarPanel,
|
||||||
|
setMetadataLayer,
|
||||||
|
setImageContextMenu,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeGenerationLayer =
|
||||||
|
activeCanvasGenerationDialog?.generatedLayerId
|
||||||
|
? (layers.find(
|
||||||
|
(layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId,
|
||||||
|
) ?? null)
|
||||||
|
: null;
|
||||||
|
const generationAnchor = resolveGenerationAnchor({
|
||||||
|
dialog: activeCanvasGenerationDialog,
|
||||||
|
generatedLayer: activeGenerationLayer,
|
||||||
|
});
|
||||||
|
const generationComposerStyle = resolveGenerationComposerStyle({
|
||||||
|
dialog: activeCanvasGenerationDialog,
|
||||||
|
anchor: generationAnchor,
|
||||||
|
viewport,
|
||||||
|
});
|
||||||
|
const iconComposerStyle = resolveIconComposerStyle({
|
||||||
|
dialog: activeCanvasGenerationDialog,
|
||||||
|
composerStyle: generationComposerStyle,
|
||||||
|
iconDescriptionCount: generationWorkflow.iconDescriptionValues.length,
|
||||||
|
});
|
||||||
|
const quickEditPanelStyle = resolveQuickEditPanelStyle({
|
||||||
|
panel: generationWorkflow.quickEditPanel,
|
||||||
|
sourceLayer: generationWorkflow.quickEditSourceLayer,
|
||||||
|
viewport,
|
||||||
|
canvasSize,
|
||||||
|
});
|
||||||
|
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
|
||||||
|
panel: generationWorkflow.characterAnimationPanel,
|
||||||
|
sourceLayer: generationWorkflow.characterAnimationSourceLayer,
|
||||||
|
viewport,
|
||||||
|
canvasSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchGenerationTool = useCallback(
|
||||||
|
(tool: CanvasTool) => {
|
||||||
|
if (tool === 'generate') {
|
||||||
|
generationWorkflow.openGenerateDialog();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tool === 'spec') {
|
||||||
|
generationWorkflow.setIsSpecMenuOpen((open) => !open);
|
||||||
|
setActiveTool('spec');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tool === 'character') {
|
||||||
|
generationWorkflow.openCharacterGenerationDialog();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tool === 'icon') {
|
||||||
|
generationWorkflow.openIconGenerationDialog();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[generationWorkflow, setActiveTool],
|
||||||
|
);
|
||||||
|
|
||||||
|
const generationComposerNode = (
|
||||||
|
<ImageCanvasGenerationComposerView
|
||||||
|
specToolWrapRef={specToolWrapRef}
|
||||||
|
characterSpecButtonRef={characterSpecButtonRef}
|
||||||
|
characterReferenceButtonRef={characterReferenceButtonRef}
|
||||||
|
iconSpecButtonRef={iconSpecButtonRef}
|
||||||
|
isSpecMenuOpen={generationWorkflow.isSpecMenuOpen}
|
||||||
|
isCharacterSpecMenuOpen={generationWorkflow.isCharacterSpecMenuOpen}
|
||||||
|
isCharacterReferenceMenuOpen={
|
||||||
|
generationWorkflow.isCharacterReferenceMenuOpen
|
||||||
|
}
|
||||||
|
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
|
||||||
|
isPickingCharacterSpecFromCanvas={
|
||||||
|
generationWorkflow.isPickingCharacterSpecFromCanvas
|
||||||
|
}
|
||||||
|
isPickingCharacterReferenceFromCanvas={
|
||||||
|
generationWorkflow.isPickingCharacterReferenceFromCanvas
|
||||||
|
}
|
||||||
|
isPickingIconSpecFromCanvas={
|
||||||
|
generationWorkflow.isPickingIconSpecFromCanvas
|
||||||
|
}
|
||||||
|
generateDialog={generateDialog}
|
||||||
|
generationComposerStyle={generationComposerStyle}
|
||||||
|
iconComposerStyle={iconComposerStyle}
|
||||||
|
quickEditPanel={generationWorkflow.quickEditPanel}
|
||||||
|
quickEditSourceLayer={generationWorkflow.quickEditSourceLayer}
|
||||||
|
quickEditPanelStyle={quickEditPanelStyle}
|
||||||
|
quickEditSizeOptions={generationWorkflow.quickEditSizeOptions}
|
||||||
|
quickEditModelOptions={generationWorkflow.quickEditModelOptions}
|
||||||
|
characterAnimationPanel={generationWorkflow.characterAnimationPanel}
|
||||||
|
characterAnimationSourceLayer={
|
||||||
|
generationWorkflow.characterAnimationSourceLayer
|
||||||
|
}
|
||||||
|
characterAnimationPanelStyle={characterAnimationPanelStyle}
|
||||||
|
characterAnimationPrice={generationWorkflow.characterAnimationPrice}
|
||||||
|
setGenerateDialog={setGenerateDialog}
|
||||||
|
setQuickEditPanel={generationWorkflow.setQuickEditPanel}
|
||||||
|
setCharacterAnimationPanel={
|
||||||
|
generationWorkflow.setCharacterAnimationPanel
|
||||||
|
}
|
||||||
|
setIsCharacterSpecMenuOpen={
|
||||||
|
generationWorkflow.setIsCharacterSpecMenuOpen
|
||||||
|
}
|
||||||
|
setIsCharacterReferenceMenuOpen={
|
||||||
|
generationWorkflow.setIsCharacterReferenceMenuOpen
|
||||||
|
}
|
||||||
|
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
|
||||||
|
setIsPickingCharacterSpecFromCanvas={
|
||||||
|
generationWorkflow.setIsPickingCharacterSpecFromCanvas
|
||||||
|
}
|
||||||
|
setIsPickingCharacterReferenceFromCanvas={
|
||||||
|
generationWorkflow.setIsPickingCharacterReferenceFromCanvas
|
||||||
|
}
|
||||||
|
setIsPickingIconSpecFromCanvas={
|
||||||
|
generationWorkflow.setIsPickingIconSpecFromCanvas
|
||||||
|
}
|
||||||
|
onOpenSpecDialog={generationWorkflow.openSpecDialog}
|
||||||
|
onRequestUpload={requestUpload}
|
||||||
|
onSubmitImageGeneration={(dialog) =>
|
||||||
|
void generationWorkflow.submitImageGeneration(dialog)
|
||||||
|
}
|
||||||
|
onSubmitIconSpritesheetGeneration={(dialog) =>
|
||||||
|
void generationWorkflow.submitIconSpritesheetGeneration(dialog)
|
||||||
|
}
|
||||||
|
onSubmitQuickEdit={() => void generationWorkflow.submitQuickEdit()}
|
||||||
|
onSubmitCharacterAnimation={() =>
|
||||||
|
void generationWorkflow.submitCharacterAnimation()
|
||||||
|
}
|
||||||
|
onCloseGenerateComposer={generationWorkflow.closeGenerateComposer}
|
||||||
|
onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue}
|
||||||
|
onUpdateIconDescription={generationWorkflow.updateIconDescription}
|
||||||
|
onAddIconDescription={generationWorkflow.addIconDescription}
|
||||||
|
onUpdateCharacterAnimationDuration={
|
||||||
|
generationWorkflow.updateCharacterAnimationDuration
|
||||||
|
}
|
||||||
|
onRememberImageModel={generationWorkflow.rememberImageModel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...generationWorkflow,
|
||||||
|
generationComposerStyle,
|
||||||
|
generationComposerNode,
|
||||||
|
switchGenerationTool,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,7 +53,6 @@ function StageControllerHarness({
|
|||||||
layers,
|
layers,
|
||||||
selectedLayerId,
|
selectedLayerId,
|
||||||
selectedLayerIds,
|
selectedLayerIds,
|
||||||
activeCanvasGenerationDialog: null,
|
|
||||||
imageContextMenu,
|
imageContextMenu,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
CanvasGenerationDialogState,
|
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
CanvasViewport,
|
CanvasViewport,
|
||||||
ImageContextMenuState,
|
ImageContextMenuState,
|
||||||
@@ -23,7 +22,6 @@ type UseImageCanvasStageControllerOptions = {
|
|||||||
layers: CanvasLayer[];
|
layers: CanvasLayer[];
|
||||||
selectedLayerId: string | null;
|
selectedLayerId: string | null;
|
||||||
selectedLayerIds: string[];
|
selectedLayerIds: string[];
|
||||||
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
|
|
||||||
imageContextMenu: ImageContextMenuState | null;
|
imageContextMenu: ImageContextMenuState | null;
|
||||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||||
contextMenu: CanvasContextMenuState | null;
|
contextMenu: CanvasContextMenuState | null;
|
||||||
@@ -42,7 +40,6 @@ export function useImageCanvasStageController({
|
|||||||
layers,
|
layers,
|
||||||
selectedLayerId,
|
selectedLayerId,
|
||||||
selectedLayerIds,
|
selectedLayerIds,
|
||||||
activeCanvasGenerationDialog,
|
|
||||||
imageContextMenu,
|
imageContextMenu,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
@@ -59,14 +56,12 @@ export function useImageCanvasStageController({
|
|||||||
layers,
|
layers,
|
||||||
selectedLayerId,
|
selectedLayerId,
|
||||||
selectedLayerIds,
|
selectedLayerIds,
|
||||||
activeCanvasGenerationDialog,
|
|
||||||
imageContextMenu,
|
imageContextMenu,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
viewport,
|
viewport,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeCanvasGenerationDialog,
|
|
||||||
canvasSize,
|
canvasSize,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
imageContextMenu,
|
imageContextMenu,
|
||||||
|
|||||||
@@ -4414,7 +4414,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.85rem;
|
left: 0.85rem;
|
||||||
bottom: 0.85rem;
|
bottom: 0.85rem;
|
||||||
z-index: 11;
|
z-index: 18;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
border: 1px solid #d9dee8;
|
border: 1px solid #d9dee8;
|
||||||
@@ -4702,7 +4702,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 0.85rem;
|
bottom: 0.85rem;
|
||||||
z-index: 10;
|
z-index: 18;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
max-width: min(calc(100% - 6.6rem), 34rem);
|
max-width: min(calc(100% - 6.6rem), 34rem);
|
||||||
|
|||||||
Reference in New Issue
Block a user