拆分图片画布编辑器外壳状态
新增编辑器外壳状态 hook 抽出项目重命名、背景设置、侧栏和工具状态 补充外壳状态单测并更新拆分记录
This commit is contained in:
@@ -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<HTMLElement>) {
|
||||
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<HTMLElement | null>(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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||||
useState<SidebarPanel | null>('assets');
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: -260,
|
||||
y: 70,
|
||||
@@ -199,20 +179,9 @@ export function ImageCanvasEditorView() {
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
||||
const [isSpacePanning, setIsSpacePanning] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
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 [imageContextMenu, setImageContextMenu] =
|
||||
useState<ImageContextMenuState | null>(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<HTMLDivElement>,
|
||||
dialog: CanvasGenerationDialogState,
|
||||
@@ -1606,12 +1541,6 @@ export function ImageCanvasEditorView() {
|
||||
setActiveTool(tool);
|
||||
};
|
||||
|
||||
const toggleSidebarPanel = (panel: SidebarPanel) => {
|
||||
setActiveSidebarPanel((currentPanel) =>
|
||||
currentPanel === panel ? null : panel,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={editorRootRef}
|
||||
@@ -1719,7 +1648,7 @@ export function ImageCanvasEditorView() {
|
||||
className="image-canvas-editor__project-title-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitProjectRename();
|
||||
submitProjectRename(projectId);
|
||||
}}
|
||||
>
|
||||
<PlatformTextField
|
||||
@@ -1730,7 +1659,7 @@ export function ImageCanvasEditorView() {
|
||||
className="image-canvas-editor__project-title-input"
|
||||
onChange={(event) => {
|
||||
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}
|
||||
>
|
||||
|
||||
224
src/components/image-editor/useImageCanvasEditorChrome.test.tsx
Normal file
224
src/components/image-editor/useImageCanvasEditorChrome.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
177
src/components/image-editor/useImageCanvasEditorChrome.ts
Normal file
177
src/components/image-editor/useImageCanvasEditorChrome.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user