From e67e921c6758f16c29a4a299c5f9f25e61b5400c Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 08:58:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=BC=96=E8=BE=91=E5=99=A8=E5=A4=96=E5=A3=B3=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增编辑器外壳状态 hook 抽出项目重命名、背景设置、侧栏和工具状态 补充外壳状态单测并更新拆分记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 11 +- .../image-editor/ImageCanvasEditorView.tsx | 151 +++--------- .../useImageCanvasEditorChrome.test.tsx | 224 ++++++++++++++++++ .../useImageCanvasEditorChrome.ts | 177 ++++++++++++++ 5 files changed, 450 insertions(+), 114 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasEditorChrome.test.tsx create mode 100644 src/components/image-editor/useImageCanvasEditorChrome.ts diff --git a/TRACKING.md b/TRACKING.md index ea81ffd8..b45f8ab6 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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 前端拆分第十五阶段:新增 `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 前端拆分第十六阶段:新增 `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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 0155262b..58245bed 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -139,14 +139,21 @@ - 主视图继续负责画布事件、生成对话框定位样式、占位框拖拽、DOM 渲染、上传入口、工程资源持久化和历史捕获;生成 hook 只接收状态 setter 与落图 / 选中 / 适合视图回调。 - 该 hook 用独立单测覆盖打开生成占位、普通生图落图、快速编辑落图并适配源图、删除源图时清理 quick edit / edit dialog、角色动画入口过滤和隐藏生成面板不删除占位框,避免后续拆分再次导致生成工具或底部工具栏状态回退。 +## 第十六阶段模块 + +- `useImageCanvasEditorChrome.ts` + - 承载编辑器 chrome 状态:项目标题 / 重命名、左侧栏开关、当前工具、缩放菜单、背景设置面板、小地图开关、画布背景色和 HEX 输入。 + - 主视图继续负责真正跨工作流的动作编排,例如上传工具触发上传工作流、生成工具触发生成工作流、项目加载后注入标题、键盘 Escape 同时关闭生成 / 快速编辑 / 图片菜单等非 chrome 面板。 + - 该 hook 用独立单测覆盖项目重命名、鉴权失败登录、背景色合法 / 非法 HEX、侧栏切换、缩放 / 背景面板关闭、小地图和工具状态,避免后续改顶部栏或左下 dock 时把这些状态重新散回主视图。 + ## 后续阶段 - 后续可继续选择更高内聚的交互 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 check:encoding` - `git diff --check` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ed1f7578..0f2fd8ce 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -11,8 +11,6 @@ import { useState, } from 'react'; -import { ApiClientError } from '../../services/apiClient'; -import { renameEditorProject } from '../../services/image-editor/editorProjectClient'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { UnifiedModal } from '../common/UnifiedModal'; @@ -43,7 +41,6 @@ import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; import { ASSET_DRAG_MIME_TYPE, - DEFAULT_CANVAS_BACKGROUND_COLOR, DEFAULT_CANVAS_SIZE, TOOLBAR_HALF_WIDTH, clamp, @@ -52,7 +49,6 @@ import { hasDataTransferType, isGeneratedLayer, isLayerLinkedToAsset, - normalizeCanvasBackgroundHex, resolveContextMenuPosition, serializeLayer, } from './ImageCanvasEditorModel'; @@ -76,13 +72,13 @@ import type { DragState, EditorAsset, ImageContextMenuState, - SidebarPanel, SnapGuide, } from './ImageCanvasEditorTypes'; import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; +import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; @@ -145,13 +141,6 @@ function getPointerId(event: ReactPointerEvent) { return Number.isFinite(nativeId) ? nativeId : -1; } -function isEditorAuthError(error: unknown) { - return ( - error instanceof ApiClientError && - (error.status === 401 || error.status === 403) - ); -} - export function ImageCanvasEditorView() { const authUi = useAuthUi(); const editorRootRef = useRef(null); @@ -177,15 +166,6 @@ export function ImageCanvasEditorView() { () => {}, ); 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( - null, - ); - const [activeSidebarPanel, setActiveSidebarPanel] = - useState('assets'); const [viewport, setViewport] = useState({ x: -260, y: 70, @@ -199,20 +179,9 @@ export function ImageCanvasEditorView() { const [selectedLayerId, setSelectedLayerId] = useState(null); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); - const [activeTool, setActiveTool] = useState('select'); const [isSpacePanning, setIsSpacePanning] = useState(false); const [isPanning, setIsPanning] = useState(false); const [snapGuide, setSnapGuide] = useState(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(null); const [imageContextMenu, setImageContextMenu] = useState(null); @@ -241,15 +210,36 @@ export function ImageCanvasEditorView() { }, [], ); - const applyCanvasBackgroundColor = useCallback((color: string) => { - const normalizedColor = normalizeCanvasBackgroundHex(color); - if (!normalizedColor) { - return false; - } - setCanvasBackgroundColor(normalizedColor); - setCanvasBackgroundHexValue(normalizedColor); - return true; - }, []); + const { + 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, + } = useImageCanvasEditorChrome({ openEditorLoginModal }); const removeCanvasLayersLinkedToAssets = useCallback( (deletedAssets: EditorAsset[]) => { if (!deletedAssets.length) { @@ -804,9 +794,7 @@ export function ImageCanvasEditorView() { } } if (event.key === 'Escape') { - setActiveSidebarPanel(null); - setIsZoomMenuOpen(false); - setIsBackgroundSettingsOpen(false); + closeEditorChromePanels(); setIsSpecMenuOpen(false); setImageContextMenu(null); setContextMenu(null); @@ -863,7 +851,7 @@ export function ImageCanvasEditorView() { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; - }, [redoCanvasChange, undoCanvasChange]); + }, [closeEditorChromePanels, redoCanvasChange, undoCanvasChange]); useEffect(() => { const blockBrowserZoom = (event: WheelEvent) => { @@ -1025,50 +1013,6 @@ export function ImageCanvasEditorView() { }; 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; 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 = ( event: ReactPointerEvent, dialog: CanvasGenerationDialogState, @@ -1606,12 +1541,6 @@ export function ImageCanvasEditorView() { setActiveTool(tool); }; - const toggleSidebarPanel = (panel: SidebarPanel) => { - setActiveSidebarPanel((currentPanel) => - currentPanel === panel ? null : panel, - ); - }; - return (
{ event.preventDefault(); - submitProjectRename(); + submitProjectRename(projectId); }} > { setProjectRenameValue(event.target.value); - setProjectRenameError(null); + resetProjectRenameError(); }} onKeyDown={(event) => { if (event.key === 'Escape') { @@ -1884,15 +1813,13 @@ export function ImageCanvasEditorView() { onFitLayers={fitLayers} onUndoCanvasChange={undoCanvasChange} onRedoCanvasChange={redoCanvasChange} - onToggleZoomMenu={() => setIsZoomMenuOpen((open) => !open)} - onCloseZoomMenu={() => setIsZoomMenuOpen(false)} - onToggleBackgroundSettings={() => - setIsBackgroundSettingsOpen((isOpen) => !isOpen) - } + onToggleZoomMenu={toggleZoomMenu} + onCloseZoomMenu={closeZoomMenu} + onToggleBackgroundSettings={toggleBackgroundSettings} onApplyCanvasBackgroundColor={applyCanvasBackgroundColor} onCanvasBackgroundHexChange={handleCanvasBackgroundHexChange} onToggleSidebarPanel={toggleSidebarPanel} - onToggleMinimap={() => setIsMinimapOpen((open) => !open)} + onToggleMinimap={toggleMinimap} onMinimapPointerDown={handleMinimapPointerDown} onSwitchTool={switchTool} > diff --git a/src/components/image-editor/useImageCanvasEditorChrome.test.tsx b/src/components/image-editor/useImageCanvasEditorChrome.test.tsx new file mode 100644 index 00000000..03f8da9f --- /dev/null +++ b/src/components/image-editor/useImageCanvasEditorChrome.test.tsx @@ -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 ( +
+ {chrome.projectTitle} + {chrome.projectRenameValue} + {String(chrome.isRenamingProject)} + {String(chrome.isProjectRenameSaving)} + {chrome.projectRenameError ?? '-'} + {chrome.activeSidebarPanel ?? '-'} + {chrome.activeTool} + {String(chrome.isZoomMenuOpen)} + + {String(chrome.isBackgroundSettingsOpen)} + + {String(chrome.isMinimapOpen)} + + {chrome.canvasBackgroundColor} + + + {chrome.canvasBackgroundHexValue} + + + + + + + + + + + + + + + + + + +
+ ); +} + +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(); + + 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(); + + 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(); + + 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'); + }); +}); diff --git a/src/components/image-editor/useImageCanvasEditorChrome.ts b/src/components/image-editor/useImageCanvasEditorChrome.ts new file mode 100644 index 00000000..87cc65f8 --- /dev/null +++ b/src/components/image-editor/useImageCanvasEditorChrome.ts @@ -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( + null, + ); + const [activeSidebarPanel, setActiveSidebarPanel] = + useState('assets'); + const [activeTool, setActiveTool] = useState('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>) => { + 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, + }; +}