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

新增编辑器外壳状态 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

@@ -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}
>

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,
};
}