拆分图片画布编辑器外壳状态

新增编辑器外壳状态 hook

抽出项目重命名、背景设置、侧栏和工具状态

补充外壳状态单测并更新拆分记录
This commit is contained in:
2026-06-17 08:58:43 +08:00
parent be3d91f1c5
commit e67e921c67
5 changed files with 450 additions and 114 deletions

View File

@@ -131,3 +131,4 @@
- 2026-06-17 前端拆分第十四阶段:新增 `useImageCanvasLayerCommands`,把画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显隐、锁定、翻转、删除选中图层、按 id 删除和单图导出委托从主视图抽出;主视图保留菜单定位、画布事件、生成、上传、项目持久化和实际导出下载。验证命令:`npm run test -- src/components/image-editor/useImageCanvasLayerCommands.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,关闭后 `画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具``Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后新标签素材、画布图层、返回项目入口、小地图和底部工具栏可见,控制台无前端 error。 - 2026-06-17 前端拆分第十四阶段:新增 `useImageCanvasLayerCommands`,把画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显隐、锁定、翻转、删除选中图层、按 id 删除和单图导出委托从主视图抽出;主视图保留菜单定位、画布事件、生成、上传、项目持久化和实际导出下载。验证命令:`npm run test -- src/components/image-editor/useImageCanvasLayerCommands.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,关闭后 `画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具``Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后新标签素材、画布图层、返回项目入口、小地图和底部工具栏可见,控制台无前端 error。
- 2026-06-17 前端拆分第十五阶段:新增 `useImageCanvasGenerationWorkflow`,把生成入口、规范 / 角色 / 图标 / 修改 / 快速编辑 / 角色动画状态机、真实生成提交、结果落图、失败恢复和删除图层后的生成态清理从主视图抽出;主视图保留画布事件、浮层定位、上传、项目资源持久化和历史捕获。验证命令:`npm run test -- 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:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,关闭登录后 `画布背景色` 打开完整 `画布背景设置` 面板,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;点击 `生成工具``Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后上传素材成功,素材数增加,点击素材可加入画布,切换 `图层` 面板可看到对应图层,登录后控制台无前端 error。 - 2026-06-17 前端拆分第十五阶段:新增 `useImageCanvasGenerationWorkflow`,把生成入口、规范 / 角色 / 图标 / 修改 / 快速编辑 / 角色动画状态机、真实生成提交、结果落图、失败恢复和删除图层后的生成态清理从主视图抽出;主视图保留画布事件、浮层定位、上传、项目资源持久化和历史捕获。验证命令:`npm run test -- 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:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,关闭登录后 `画布背景色` 打开完整 `画布背景设置` 面板,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;点击 `生成工具``Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后上传素材成功,素材数增加,点击素材可加入画布,切换 `图层` 面板可看到对应图层,登录后控制台无前端 error。
- 2026-06-17 上传鉴权回归修正:普通素材上传入口在未登录时先打开 `账号入口`,不再先弹系统文件选择器;登录后用户再次点击上传即可打开文件选择器,避免浏览器拦截登录后异步触发的系统选择器。拖拽 / 已选中文件的续传逻辑仍保留,角色 / 图标生成参考图仍作为本地引用上传,不强制登录。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口``画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;未登录点击侧栏上传直接弹登录,不出现文件选择器;登录后再次点击上传可以打开文件选择器并上传成功,素材计数从 4 增至 5`AI画布工具栏` 保持可见。 - 2026-06-17 上传鉴权回归修正:普通素材上传入口在未登录时先打开 `账号入口`,不再先弹系统文件选择器;登录后用户再次点击上传即可打开文件选择器,避免浏览器拦截登录后异步触发的系统选择器。拖拽 / 已选中文件的续传逻辑仍保留,角色 / 图标生成参考图仍作为本地引用上传,不强制登录。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口``画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;未登录点击侧栏上传直接弹登录,不出现文件选择器;登录后再次点击上传可以打开文件选择器并上传成功,素材计数从 4 增至 5`AI画布工具栏` 保持可见。
- 2026-06-17 前端拆分第十六阶段:新增 `useImageCanvasEditorChrome`,把项目标题 / 重命名、侧栏开关、当前工具、缩放菜单、背景设置、小地图和背景 HEX 状态从主视图抽出;主视图继续保留上传 / 生成 / 键盘 Escape 的跨工作流编排。新增 hook 单测覆盖重命名、鉴权登录、背景色输入、面板开关和工具状态;主视图从 2039 行降至 1966 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck`

View File

@@ -139,14 +139,21 @@
- 主视图继续负责画布事件、生成对话框定位样式、占位框拖拽、DOM 渲染、上传入口、工程资源持久化和历史捕获;生成 hook 只接收状态 setter 与落图 / 选中 / 适合视图回调。 - 主视图继续负责画布事件、生成对话框定位样式、占位框拖拽、DOM 渲染、上传入口、工程资源持久化和历史捕获;生成 hook 只接收状态 setter 与落图 / 选中 / 适合视图回调。
- 该 hook 用独立单测覆盖打开生成占位、普通生图落图、快速编辑落图并适配源图、删除源图时清理 quick edit / edit dialog、角色动画入口过滤和隐藏生成面板不删除占位框避免后续拆分再次导致生成工具或底部工具栏状态回退。 - 该 hook 用独立单测覆盖打开生成占位、普通生图落图、快速编辑落图并适配源图、删除源图时清理 quick edit / edit dialog、角色动画入口过滤和隐藏生成面板不删除占位框避免后续拆分再次导致生成工具或底部工具栏状态回退。
## 第十六阶段模块
- `useImageCanvasEditorChrome.ts`
- 承载编辑器 chrome 状态:项目标题 / 重命名、左侧栏开关、当前工具、缩放菜单、背景设置面板、小地图开关、画布背景色和 HEX 输入。
- 主视图继续负责真正跨工作流的动作编排,例如上传工具触发上传工作流、生成工具触发生成工作流、项目加载后注入标题、键盘 Escape 同时关闭生成 / 快速编辑 / 图片菜单等非 chrome 面板。
- 该 hook 用独立单测覆盖项目重命名、鉴权失败登录、背景色合法 / 非法 HEX、侧栏切换、缩放 / 背景面板关闭、小地图和工具状态,避免后续改顶部栏或左下 dock 时把这些状态重新散回主视图。
## 后续阶段 ## 后续阶段
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 - 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
- 生成对象定位、画布 pointer 事件、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框和角色动画优先传 `objectKey` 的历史保护规则。 - 生成对象定位、画布 pointer 事件、素材入画布、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
## 验证计划 ## 验证计划
- `npm run test -- src/components/image-editor/useImageCanvasLayerCommands.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` - `npm run test -- src/components/image-editor/useImageCanvasEditorChrome.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`

View File

@@ -11,8 +11,6 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { ApiClientError } from '../../services/apiClient';
import { renameEditorProject } from '../../services/image-editor/editorProjectClient';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField'; import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal'; import { UnifiedModal } from '../common/UnifiedModal';
@@ -43,7 +41,6 @@ import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView'; import { ImageCanvasStageView } from './ImageCanvasStageView';
import { import {
ASSET_DRAG_MIME_TYPE, ASSET_DRAG_MIME_TYPE,
DEFAULT_CANVAS_BACKGROUND_COLOR,
DEFAULT_CANVAS_SIZE, DEFAULT_CANVAS_SIZE,
TOOLBAR_HALF_WIDTH, TOOLBAR_HALF_WIDTH,
clamp, clamp,
@@ -52,7 +49,6 @@ import {
hasDataTransferType, hasDataTransferType,
isGeneratedLayer, isGeneratedLayer,
isLayerLinkedToAsset, isLayerLinkedToAsset,
normalizeCanvasBackgroundHex,
resolveContextMenuPosition, resolveContextMenuPosition,
serializeLayer, serializeLayer,
} from './ImageCanvasEditorModel'; } from './ImageCanvasEditorModel';
@@ -76,13 +72,13 @@ import type {
DragState, DragState,
EditorAsset, EditorAsset,
ImageContextMenuState, ImageContextMenuState,
SidebarPanel,
SnapGuide, SnapGuide,
} from './ImageCanvasEditorTypes'; } from './ImageCanvasEditorTypes';
import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasHistory } from './useCanvasHistory';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
@@ -145,13 +141,6 @@ function getPointerId(event: ReactPointerEvent<HTMLElement>) {
return Number.isFinite(nativeId) ? nativeId : -1; return Number.isFinite(nativeId) ? nativeId : -1;
} }
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
export function ImageCanvasEditorView() { export function ImageCanvasEditorView() {
const authUi = useAuthUi(); const authUi = useAuthUi();
const editorRootRef = useRef<HTMLElement | null>(null); const editorRootRef = useRef<HTMLElement | null>(null);
@@ -177,15 +166,6 @@ export function ImageCanvasEditorView() {
() => {}, () => {},
); );
const suppressAssetClickRef = useRef(false); const suppressAssetClickRef = useRef(false);
const [projectTitle, setProjectTitle] = useState('未命名画布');
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
const [isRenamingProject, setIsRenamingProject] = useState(false);
const [isProjectRenameSaving, setIsProjectRenameSaving] = useState(false);
const [projectRenameError, setProjectRenameError] = useState<string | null>(
null,
);
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [viewport, setViewport] = useState<CanvasViewport>({ const [viewport, setViewport] = useState<CanvasViewport>({
x: -260, x: -260,
y: 70, y: 70,
@@ -199,20 +179,9 @@ export function ImageCanvasEditorView() {
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null); const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]); const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null); const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
const [isSpacePanning, setIsSpacePanning] = useState(false); const [isSpacePanning, setIsSpacePanning] = useState(false);
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const [snapGuide, setSnapGuide] = useState<SnapGuide | null>(null); const [snapGuide, setSnapGuide] = useState<SnapGuide | null>(null);
const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false);
const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] =
useState(false);
const [isMinimapOpen, setIsMinimapOpen] = useState(true);
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
const [canvasBackgroundHexValue, setCanvasBackgroundHexValue] = useState(
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null); const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [imageContextMenu, setImageContextMenu] = const [imageContextMenu, setImageContextMenu] =
useState<ImageContextMenuState | null>(null); useState<ImageContextMenuState | null>(null);
@@ -241,15 +210,36 @@ export function ImageCanvasEditorView() {
}, },
[], [],
); );
const applyCanvasBackgroundColor = useCallback((color: string) => { const {
const normalizedColor = normalizeCanvasBackgroundHex(color); projectTitle,
if (!normalizedColor) { setProjectTitle,
return false; projectRenameValue,
} setProjectRenameValue,
setCanvasBackgroundColor(normalizedColor); isRenamingProject,
setCanvasBackgroundHexValue(normalizedColor); isProjectRenameSaving,
return true; projectRenameError,
}, []); activeSidebarPanel,
setActiveSidebarPanel,
activeTool,
setActiveTool,
isZoomMenuOpen,
isBackgroundSettingsOpen,
isMinimapOpen,
canvasBackgroundColor,
canvasBackgroundHexValue,
startProjectRename,
cancelProjectRename,
submitProjectRename,
resetProjectRenameError,
applyCanvasBackgroundColor,
handleCanvasBackgroundHexChange,
closeEditorChromePanels,
toggleSidebarPanel,
toggleZoomMenu,
closeZoomMenu,
toggleBackgroundSettings,
toggleMinimap,
} = useImageCanvasEditorChrome({ openEditorLoginModal });
const removeCanvasLayersLinkedToAssets = useCallback( const removeCanvasLayersLinkedToAssets = useCallback(
(deletedAssets: EditorAsset[]) => { (deletedAssets: EditorAsset[]) => {
if (!deletedAssets.length) { if (!deletedAssets.length) {
@@ -804,9 +794,7 @@ export function ImageCanvasEditorView() {
} }
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
setActiveSidebarPanel(null); closeEditorChromePanels();
setIsZoomMenuOpen(false);
setIsBackgroundSettingsOpen(false);
setIsSpecMenuOpen(false); setIsSpecMenuOpen(false);
setImageContextMenu(null); setImageContextMenu(null);
setContextMenu(null); setContextMenu(null);
@@ -863,7 +851,7 @@ export function ImageCanvasEditorView() {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('keyup', handleKeyUp);
}; };
}, [redoCanvasChange, undoCanvasChange]); }, [closeEditorChromePanels, redoCanvasChange, undoCanvasChange]);
useEffect(() => { useEffect(() => {
const blockBrowserZoom = (event: WheelEvent) => { const blockBrowserZoom = (event: WheelEvent) => {
@@ -1025,50 +1013,6 @@ export function ImageCanvasEditorView() {
}; };
addAssetLayerRef.current = addAssetLayer; addAssetLayerRef.current = addAssetLayer;
const startProjectRename = () => {
setProjectRenameValue(projectTitle);
setProjectRenameError(null);
setIsRenamingProject(true);
};
const cancelProjectRename = () => {
setProjectRenameValue(projectTitle);
setProjectRenameError(null);
setIsRenamingProject(false);
};
const submitProjectRename = () => {
const nextTitle = projectRenameValue.trim();
if (!nextTitle) {
setProjectRenameError('项目名称不能为空');
return;
}
if (!projectId || nextTitle === projectTitle) {
setProjectRenameValue(projectTitle);
setProjectRenameError(null);
setIsRenamingProject(false);
return;
}
setIsProjectRenameSaving(true);
setProjectRenameError(null);
renameEditorProject(projectId, nextTitle)
.then((project) => {
const savedTitle = project.title?.trim() || nextTitle;
setProjectTitle(savedTitle);
setProjectRenameValue(savedTitle);
setIsRenamingProject(false);
})
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
setProjectRenameError(
error instanceof Error ? error.message : '重命名项目失败',
);
})
.finally(() => setIsProjectRenameSaving(false));
};
moveAssetToFolderRef.current = moveAssetToFolder; moveAssetToFolderRef.current = moveAssetToFolder;
deleteLayerByIdRef.current = deleteLayerById; deleteLayerByIdRef.current = deleteLayerById;
@@ -1366,15 +1310,6 @@ export function ImageCanvasEditorView() {
}); });
}; };
const handleCanvasBackgroundHexChange = (nextValue: string) => {
setCanvasBackgroundHexValue(nextValue);
const normalizedColor = normalizeCanvasBackgroundHex(nextValue);
if (normalizedColor) {
setCanvasBackgroundColor(normalizedColor);
setCanvasBackgroundHexValue(normalizedColor);
}
};
const handleGenerationFramePointerDown = ( const handleGenerationFramePointerDown = (
event: ReactPointerEvent<HTMLDivElement>, event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState, dialog: CanvasGenerationDialogState,
@@ -1606,12 +1541,6 @@ export function ImageCanvasEditorView() {
setActiveTool(tool); setActiveTool(tool);
}; };
const toggleSidebarPanel = (panel: SidebarPanel) => {
setActiveSidebarPanel((currentPanel) =>
currentPanel === panel ? null : panel,
);
};
return ( return (
<section <section
ref={editorRootRef} ref={editorRootRef}
@@ -1719,7 +1648,7 @@ export function ImageCanvasEditorView() {
className="image-canvas-editor__project-title-form" className="image-canvas-editor__project-title-form"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
submitProjectRename(); submitProjectRename(projectId);
}} }}
> >
<PlatformTextField <PlatformTextField
@@ -1730,7 +1659,7 @@ export function ImageCanvasEditorView() {
className="image-canvas-editor__project-title-input" className="image-canvas-editor__project-title-input"
onChange={(event) => { onChange={(event) => {
setProjectRenameValue(event.target.value); setProjectRenameValue(event.target.value);
setProjectRenameError(null); resetProjectRenameError();
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -1884,15 +1813,13 @@ export function ImageCanvasEditorView() {
onFitLayers={fitLayers} onFitLayers={fitLayers}
onUndoCanvasChange={undoCanvasChange} onUndoCanvasChange={undoCanvasChange}
onRedoCanvasChange={redoCanvasChange} onRedoCanvasChange={redoCanvasChange}
onToggleZoomMenu={() => setIsZoomMenuOpen((open) => !open)} onToggleZoomMenu={toggleZoomMenu}
onCloseZoomMenu={() => setIsZoomMenuOpen(false)} onCloseZoomMenu={closeZoomMenu}
onToggleBackgroundSettings={() => onToggleBackgroundSettings={toggleBackgroundSettings}
setIsBackgroundSettingsOpen((isOpen) => !isOpen)
}
onApplyCanvasBackgroundColor={applyCanvasBackgroundColor} onApplyCanvasBackgroundColor={applyCanvasBackgroundColor}
onCanvasBackgroundHexChange={handleCanvasBackgroundHexChange} onCanvasBackgroundHexChange={handleCanvasBackgroundHexChange}
onToggleSidebarPanel={toggleSidebarPanel} onToggleSidebarPanel={toggleSidebarPanel}
onToggleMinimap={() => setIsMinimapOpen((open) => !open)} onToggleMinimap={toggleMinimap}
onMinimapPointerDown={handleMinimapPointerDown} onMinimapPointerDown={handleMinimapPointerDown}
onSwitchTool={switchTool} onSwitchTool={switchTool}
> >

View File

@@ -0,0 +1,224 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
renameEditorProject: renameEditorProjectMock,
};
});
function ChromeHarness({
openEditorLoginModal = vi.fn(),
}: {
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
}) {
const chrome = useImageCanvasEditorChrome({ openEditorLoginModal });
return (
<div>
<span data-testid="title">{chrome.projectTitle}</span>
<span data-testid="rename-value">{chrome.projectRenameValue}</span>
<span data-testid="renaming">{String(chrome.isRenamingProject)}</span>
<span data-testid="saving">{String(chrome.isProjectRenameSaving)}</span>
<span data-testid="rename-error">{chrome.projectRenameError ?? '-'}</span>
<span data-testid="sidebar">{chrome.activeSidebarPanel ?? '-'}</span>
<span data-testid="tool">{chrome.activeTool}</span>
<span data-testid="zoom">{String(chrome.isZoomMenuOpen)}</span>
<span data-testid="background-open">
{String(chrome.isBackgroundSettingsOpen)}
</span>
<span data-testid="minimap">{String(chrome.isMinimapOpen)}</span>
<span data-testid="background-color">
{chrome.canvasBackgroundColor}
</span>
<span data-testid="background-hex">
{chrome.canvasBackgroundHexValue}
</span>
<button type="button" onClick={chrome.startProjectRename}>
start rename
</button>
<button type="button" onClick={chrome.cancelProjectRename}>
cancel rename
</button>
<button
type="button"
onClick={() => chrome.submitProjectRename('project-1')}
>
submit rename
</button>
<button
type="button"
onClick={() => chrome.submitProjectRename(null)}
>
submit without project
</button>
<button
type="button"
onClick={() => chrome.setProjectRenameValue(' 新项目 ')}
>
set rename
</button>
<button
type="button"
onClick={() => chrome.setProjectRenameValue(' ')}
>
blank rename
</button>
<button type="button" onClick={() => chrome.setProjectTitle('已有项目')}>
set title
</button>
<button type="button" onClick={() => chrome.setActiveTool('hand')}>
set hand
</button>
<button type="button" onClick={() => chrome.toggleSidebarPanel('assets')}>
toggle assets
</button>
<button type="button" onClick={() => chrome.toggleSidebarPanel('layers')}>
toggle layers
</button>
<button type="button" onClick={chrome.toggleZoomMenu}>
toggle zoom
</button>
<button type="button" onClick={chrome.toggleBackgroundSettings}>
toggle background
</button>
<button type="button" onClick={chrome.toggleMinimap}>
toggle minimap
</button>
<button type="button" onClick={chrome.closeEditorChromePanels}>
close panels
</button>
<button
type="button"
onClick={() => chrome.applyCanvasBackgroundColor('#abc')}
>
apply short hex
</button>
<button
type="button"
onClick={() => chrome.handleCanvasBackgroundHexChange('#not-a-color')}
>
invalid hex
</button>
<button
type="button"
onClick={() => chrome.handleCanvasBackgroundHexChange('#ffffff')}
>
valid hex
</button>
</div>
);
}
describe('useImageCanvasEditorChrome', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renames projects and resets rename state from the saved title', async () => {
renameEditorProjectMock.mockResolvedValueOnce({
projectId: 'project-1',
title: '后端项目名',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-17T00:00:00.000Z',
});
render(<ChromeHarness />);
fireEvent.click(screen.getByRole('button', { name: 'set title' }));
fireEvent.click(screen.getByRole('button', { name: 'start rename' }));
expect(screen.getByTestId('rename-value').textContent).toBe('已有项目');
fireEvent.click(screen.getByRole('button', { name: 'set rename' }));
fireEvent.click(screen.getByRole('button', { name: 'submit rename' }));
expect(screen.getByTestId('saving').textContent).toBe('true');
await waitFor(() => {
expect(renameEditorProjectMock).toHaveBeenCalledWith(
'project-1',
'新项目',
);
});
expect(screen.getByTestId('title').textContent).toBe('后端项目名');
expect(screen.getByTestId('rename-value').textContent).toBe('后端项目名');
expect(screen.getByTestId('renaming').textContent).toBe('false');
expect(screen.getByTestId('saving').textContent).toBe('false');
});
it('validates rename input and opens login on rename auth errors', async () => {
const openEditorLoginModal = vi.fn();
renameEditorProjectMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(<ChromeHarness openEditorLoginModal={openEditorLoginModal} />);
fireEvent.click(screen.getByRole('button', { name: 'blank rename' }));
fireEvent.click(screen.getByRole('button', { name: 'submit rename' }));
expect(screen.getByTestId('rename-error').textContent).toBe(
'项目名称不能为空',
);
expect(renameEditorProjectMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'set rename' }));
fireEvent.click(screen.getByRole('button', { name: 'submit rename' }));
await waitFor(() => {
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
});
expect(screen.getByTestId('rename-error').textContent).toBe('未授权访问');
});
it('manages background colors and chrome panel toggles', () => {
render(<ChromeHarness />);
fireEvent.click(screen.getByRole('button', { name: 'apply short hex' }));
expect(screen.getByTestId('background-color').textContent).toBe('#aabbcc');
expect(screen.getByTestId('background-hex').textContent).toBe('#aabbcc');
fireEvent.click(screen.getByRole('button', { name: 'invalid hex' }));
expect(screen.getByTestId('background-color').textContent).toBe('#aabbcc');
expect(screen.getByTestId('background-hex').textContent).toBe(
'#not-a-color',
);
fireEvent.click(screen.getByRole('button', { name: 'valid hex' }));
expect(screen.getByTestId('background-color').textContent).toBe('#ffffff');
expect(screen.getByTestId('background-hex').textContent).toBe('#ffffff');
fireEvent.click(screen.getByRole('button', { name: 'toggle assets' }));
expect(screen.getByTestId('sidebar').textContent).toBe('-');
fireEvent.click(screen.getByRole('button', { name: 'toggle layers' }));
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
fireEvent.click(screen.getByRole('button', { name: 'toggle zoom' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle background' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle minimap' }));
fireEvent.click(screen.getByRole('button', { name: 'set hand' }));
expect(screen.getByTestId('zoom').textContent).toBe('true');
expect(screen.getByTestId('background-open').textContent).toBe('true');
expect(screen.getByTestId('minimap').textContent).toBe('false');
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
screen.getByRole('button', { name: 'close panels' }).click();
});
expect(screen.getByTestId('sidebar').textContent).toBe('-');
expect(screen.getByTestId('zoom').textContent).toBe('false');
expect(screen.getByTestId('background-open').textContent).toBe('false');
});
});

View File

@@ -0,0 +1,177 @@
import { useCallback, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import { renameEditorProject } from '../../services/image-editor/editorProjectClient';
import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
normalizeCanvasBackgroundHex,
} from './ImageCanvasEditorModel';
import type {
CanvasTool,
SidebarPanel,
} from './ImageCanvasEditorTypes';
type UseImageCanvasEditorChromeOptions = {
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
export function useImageCanvasEditorChrome({
openEditorLoginModal,
}: UseImageCanvasEditorChromeOptions) {
const [projectTitle, setProjectTitle] = useState('未命名画布');
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
const [isRenamingProject, setIsRenamingProject] = useState(false);
const [isProjectRenameSaving, setIsProjectRenameSaving] = useState(false);
const [projectRenameError, setProjectRenameError] = useState<string | null>(
null,
);
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false);
const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] =
useState(false);
const [isMinimapOpen, setIsMinimapOpen] = useState(true);
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
const [canvasBackgroundHexValue, setCanvasBackgroundHexValue] = useState(
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
const applyCanvasBackgroundColor = useCallback((color: string) => {
const normalizedColor = normalizeCanvasBackgroundHex(color);
if (!normalizedColor) {
return false;
}
setCanvasBackgroundColor(normalizedColor);
setCanvasBackgroundHexValue(normalizedColor);
return true;
}, []);
const handleCanvasBackgroundHexChange = useCallback((nextValue: string) => {
setCanvasBackgroundHexValue(nextValue);
const normalizedColor = normalizeCanvasBackgroundHex(nextValue);
if (normalizedColor) {
setCanvasBackgroundColor(normalizedColor);
setCanvasBackgroundHexValue(normalizedColor);
}
}, []);
const startProjectRename = useCallback(() => {
setProjectRenameValue(projectTitle);
setProjectRenameError(null);
setIsRenamingProject(true);
}, [projectTitle]);
const cancelProjectRename = useCallback(() => {
setProjectRenameValue(projectTitle);
setProjectRenameError(null);
setIsRenamingProject(false);
}, [projectTitle]);
const submitProjectRename = useCallback(
(projectId: string | null) => {
const nextTitle = projectRenameValue.trim();
if (!nextTitle) {
setProjectRenameError('项目名称不能为空');
return;
}
if (!projectId || nextTitle === projectTitle) {
setProjectRenameValue(projectTitle);
setProjectRenameError(null);
setIsRenamingProject(false);
return;
}
setIsProjectRenameSaving(true);
setProjectRenameError(null);
renameEditorProject(projectId, nextTitle)
.then((project: Awaited<ReturnType<typeof renameEditorProject>>) => {
const savedTitle = project.title?.trim() || nextTitle;
setProjectTitle(savedTitle);
setProjectRenameValue(savedTitle);
setIsRenamingProject(false);
})
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
setProjectRenameError(
error instanceof Error ? error.message : '重命名项目失败',
);
})
.finally(() => setIsProjectRenameSaving(false));
},
[openEditorLoginModal, projectRenameValue, projectTitle],
);
const resetProjectRenameError = useCallback(() => {
setProjectRenameError(null);
}, []);
const closeEditorChromePanels = useCallback(() => {
setActiveSidebarPanel(null);
setIsZoomMenuOpen(false);
setIsBackgroundSettingsOpen(false);
}, []);
const toggleSidebarPanel = useCallback((panel: SidebarPanel) => {
setActiveSidebarPanel((currentPanel) =>
currentPanel === panel ? null : panel,
);
}, []);
const toggleZoomMenu = useCallback(() => {
setIsZoomMenuOpen((open) => !open);
}, []);
const closeZoomMenu = useCallback(() => {
setIsZoomMenuOpen(false);
}, []);
const toggleBackgroundSettings = useCallback(() => {
setIsBackgroundSettingsOpen((isOpen) => !isOpen);
}, []);
const toggleMinimap = useCallback(() => {
setIsMinimapOpen((open) => !open);
}, []);
return {
projectTitle,
setProjectTitle,
projectRenameValue,
setProjectRenameValue,
isRenamingProject,
isProjectRenameSaving,
projectRenameError,
activeSidebarPanel,
setActiveSidebarPanel,
activeTool,
setActiveTool,
isZoomMenuOpen,
isBackgroundSettingsOpen,
isMinimapOpen,
canvasBackgroundColor,
canvasBackgroundHexValue,
startProjectRename,
cancelProjectRename,
submitProjectRename,
resetProjectRenameError,
applyCanvasBackgroundColor,
handleCanvasBackgroundHexChange,
closeEditorChromePanels,
toggleSidebarPanel,
toggleZoomMenu,
closeZoomMenu,
toggleBackgroundSettings,
toggleMinimap,
};
}