拆分图片画布上传工作流

新增图片文件读取模型和上传工作流 hook

把上传目标分发、登录续传、占位卡片和画布建层从主视图抽出

补充上传工作流单测并更新拆分计划和进度记录
This commit is contained in:
2026-06-17 06:42:55 +08:00
parent eb583107f4
commit b1421159e6
7 changed files with 901 additions and 392 deletions

View File

@@ -15,7 +15,6 @@ import {
import { ApiClientError } from '../../services/apiClient';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
createEditorAsset,
editEditorImage,
type EditorIconSpritesheetGenerationResult,
type EditorIconSpritesheetIconResult,
@@ -149,19 +148,12 @@ import type {
SnapGuide,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { useCanvasHistory } from './useCanvasHistory';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
readImageFileAsDataUrl,
useImageCanvasAssetLibrary,
} from './useImageCanvasAssetLibrary';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
function isImageFile(file: File) {
return file.type.startsWith('image/');
}
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
function isEditableTarget(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
@@ -231,7 +223,6 @@ export function ImageCanvasEditorView() {
const authUi = useAuthUi();
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
@@ -296,7 +287,6 @@ export function ImageCanvasEditorView() {
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [uploadTarget, setUploadTarget] = useState<UploadTarget>('asset');
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
const [
isPickingCharacterSpecFromCanvas,
@@ -693,6 +683,32 @@ export function ImageCanvasEditorView() {
viewport,
openEditorLoginModal,
});
const {
uploadInputRef,
setUploadTarget,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
} = useImageCanvasUploadWorkflow({
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
assetFolders,
activeUploadFolderId,
canvasSize,
viewport,
activeTool,
allocateUploadIndex: () => {
layerCounterRef.current += 1;
return layerCounterRef.current;
},
setAssetFolders,
setAssets,
setLayers,
setGenerateDialog,
setActiveSidebarPanel,
appendCanvasLayersWithResources,
selectSingleLayer,
});
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
setGenerateDialog((currentDialog) =>
@@ -1423,55 +1439,6 @@ export function ImageCanvasEditorView() {
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
});
const addCharacterSpecReferenceFiles = async (files: FileList | File[]) => {
const imageFile = Array.from(files).find(isImageFile);
if (!imageFile) {
window.alert('请选择图片文件');
return;
}
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setCharacterGenerationIdle(currentDialog),
characterSpecReference: {
id: `upload-character-spec-${Date.now()}`,
label: imageFile.name || '角色形象规范',
src: imageSrc,
},
}
: currentDialog,
);
};
const addCharacterReferenceFiles = async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter(isImageFile);
if (!imageFiles.length) {
window.alert('请选择图片文件');
return;
}
const references = await Promise.all(
imageFiles.map(async (file, index) => ({
id: `upload-character-reference-${Date.now()}-${index}`,
label: file.name || `参考图${index + 1}`,
src: await readImageFileAsDataUrl(file),
})),
);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setCharacterGenerationIdle(currentDialog),
characterReferences: [
...(currentDialog.characterReferences ?? []),
...references,
],
}
: currentDialog,
);
};
const pickCharacterSpecFromLayer = (layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
@@ -1493,28 +1460,6 @@ export function ImageCanvasEditorView() {
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
});
const addIconSpecReferenceFiles = async (files: FileList | File[]) => {
const imageFile = Array.from(files).find(isImageFile);
if (!imageFile) {
window.alert('请选择图片文件');
return;
}
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...setIconGenerationIdle(currentDialog),
iconSpecReference: {
id: `upload-icon-spec-${Date.now()}`,
label: imageFile.name || '图标素材规范',
src: imageSrc,
},
}
: currentDialog,
);
};
const pickIconSpecFromLayer = (layer: CanvasLayer) => {
if (layer.assetKind !== 'icon-spec') {
return;
@@ -1533,276 +1478,6 @@ export function ImageCanvasEditorView() {
setImageContextMenu(null);
};
const addUploadedLayer = async (
file: File,
options: {
folderId?: string;
canvasPoint?: { x: number; y: number };
uploadIndex?: number;
addToCanvas?: boolean;
} = {},
) => {
if (!file.type.startsWith('image/')) {
window.alert('请选择图片文件');
return;
}
const fallbackWidth = 420;
const fallbackHeight = 315;
const uploadFolderId = assetFolders.some(
(folder) => folder.id === (options.folderId ?? activeUploadFolderId),
)
? (options.folderId ?? activeUploadFolderId)
: 'project';
const uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1;
layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex);
const uploadedAsset: EditorAsset = {
id: `upload-${uploadIndex}`,
label: file.name || '上传图片',
src: '',
width: fallbackWidth,
height: fallbackHeight,
folderId: uploadFolderId,
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: false,
uploadStatus: 'uploading',
uploadProgress: 8,
uploadMessage: '准备上传',
};
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === uploadFolderId
? {
...folder,
collapsed: false,
}
: folder,
),
);
let imageSrc = '';
try {
imageSrc = await readImageFileAsDataUrl(file);
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
src: imageSrc,
uploadProgress: 42,
uploadMessage: '读取图片',
}
: asset,
),
);
} catch {
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: '读取失败',
}
: asset,
),
);
return;
}
const screenPoint = options.canvasPoint ?? {
x: canvasSize.width / 2,
y: canvasSize.height / 2,
};
const fallbackScreenPoint = {
x: canvasSize.width > 0 ? canvasSize.width / 2 : 640,
y: canvasSize.height > 0 ? canvasSize.height / 2 : 360,
};
const normalizedScreenPoint = {
x: Number.isFinite(screenPoint.x) ? screenPoint.x : fallbackScreenPoint.x,
y: Number.isFinite(screenPoint.y) ? screenPoint.y : fallbackScreenPoint.y,
};
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
const worldCenterX = (normalizedScreenPoint.x - viewport.x) / safeScale;
const worldCenterY = (normalizedScreenPoint.y - viewport.y) / safeScale;
const nextLayer: CanvasLayer = {
id: `layer-upload-${uploadIndex}`,
resourceId: `local-resource-upload-${uploadIndex}`,
title: file.name || '上传图片',
src: imageSrc,
x: worldCenterX - fallbackWidth / 2,
y: worldCenterY - fallbackHeight / 2,
width: fallbackWidth,
height: fallbackHeight,
originalWidth: fallbackWidth,
originalHeight: fallbackHeight,
zIndex: uploadIndex + 10,
sourceType: 'uploaded',
sourceAssetId: `upload-${uploadIndex}`,
};
if (options.addToCanvas) {
appendCanvasLayersWithResources([nextLayer]);
}
if (options.addToCanvas) {
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadProgress: 68,
uploadMessage: '上传中',
}
: asset,
),
);
createEditorAsset({
folderId: uploadFolderId,
label: uploadedAsset.label,
imageSrc,
width: fallbackWidth,
height: fallbackHeight,
sourceType: 'uploaded',
})
.then((asset) => {
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === uploadedAsset.id
? {
...currentAsset,
id: asset.assetId,
folderId: asset.folderId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
persisted: true,
uploadStatus: undefined,
uploadProgress: undefined,
uploadMessage: undefined,
}
: currentAsset,
),
);
if (options.addToCanvas) {
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === nextLayer.id
? {
...currentLayer,
sourceAssetId: asset.assetId,
objectKey: asset.objectKey ?? currentLayer.objectKey,
assetObjectId:
asset.assetObjectId ?? currentLayer.assetObjectId,
}
: currentLayer,
),
);
}
})
.catch((error: unknown) => {
const isAuthError = isEditorAuthError(error);
if (isAuthError) {
openEditorLoginModal();
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: isAuthError ? '请先登录' : '上传失败',
}
: asset,
),
);
});
if (imageSrc) {
const uploadedImage = new Image();
uploadedImage.onload = () => {
const originalWidth = uploadedImage.naturalWidth || fallbackWidth;
const originalHeight = uploadedImage.naturalHeight || fallbackHeight;
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{ width: fallbackWidth, height: fallbackHeight },
);
if (options.addToCanvas) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === nextLayer.id
? {
...layer,
width,
height,
originalWidth,
originalHeight,
x: worldCenterX - width / 2,
y: worldCenterY - height / 2,
}
: layer,
),
);
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
width: originalWidth,
height: originalHeight,
}
: asset,
),
);
};
uploadedImage.src = imageSrc;
}
};
const addUploadedFiles = (
files: FileList | File[],
options: {
folderId?: string;
canvasPoint?: { x: number; y: number };
addToCanvas?: boolean;
} = {},
) => {
const imageFiles = Array.from(files);
const currentAuthUi = authUiRef.current;
if (currentAuthUi && !currentAuthUi.canAccessProtectedData) {
openEditorLoginModal(() => {
addUploadedFiles(imageFiles, options);
});
return;
}
imageFiles.forEach((file, index) => {
layerCounterRef.current += 1;
const uploadIndex = layerCounterRef.current;
void addUploadedLayer(file, {
...options,
addToCanvas: options.addToCanvas ?? false,
uploadIndex,
canvasPoint: options.canvasPoint
? {
x: options.canvasPoint.x + index * 28,
y: options.canvasPoint.y + index * 28,
}
: undefined,
});
});
};
const deleteLayerById = (targetLayerId: string | null) => {
if (!targetLayerId) {
return;
@@ -3031,8 +2706,7 @@ export function ImageCanvasEditorView() {
setIsPanning(false);
setSnapGuide(null);
if (tool === 'upload') {
setUploadTarget('asset');
uploadInputRef.current?.click();
requestUpload('asset');
return;
}
if (tool === 'generate') {
@@ -3203,22 +2877,7 @@ export function ImageCanvasEditorView() {
multiple
aria-label="上传图片文件"
hidden
onChange={(event) => {
const files = event.currentTarget.files;
if (files?.length) {
if (uploadTarget === 'character-spec') {
void addCharacterSpecReferenceFiles(files);
} else if (uploadTarget === 'character-reference') {
void addCharacterReferenceFiles(files);
} else if (uploadTarget === 'icon-spec') {
void addIconSpecReferenceFiles(files);
} else {
addUploadedFiles(files, { addToCanvas: activeTool === 'upload' });
}
}
setUploadTarget('asset');
event.currentTarget.value = '';
}}
onChange={handleUploadInputChange}
/>
{assetPointerDrag?.active ? (
<div
@@ -3520,10 +3179,7 @@ export function ImageCanvasEditorView() {
}
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
onOpenSpecDialog={openSpecDialog}
onRequestUpload={(target) => {
setUploadTarget(target);
uploadInputRef.current?.click();
}}
onRequestUpload={requestUpload}
onSubmitImageGeneration={(dialog) =>
void submitImageGeneration(dialog)
}

View File

@@ -0,0 +1,18 @@
export function isImageFile(file: File) {
return file.type.startsWith('image/');
}
export function readImageFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('图片读取失败'));
};
reader.onerror = () => reject(reader.error ?? new Error('图片读取失败'));
reader.readAsDataURL(file);
});
}

View File

@@ -29,6 +29,7 @@ import type {
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
export { readImageFileAsDataUrl } from './ImageCanvasFileModel';
function isEditorAuthError(error: unknown) {
return (
@@ -37,21 +38,6 @@ function isEditorAuthError(error: unknown) {
);
}
export function readImageFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('图片读取失败'));
};
reader.onerror = () => reject(reader.error ?? new Error('图片读取失败'));
reader.readAsDataURL(file);
});
}
export function useImageCanvasAssetLibrary({
assetListRef,
openEditorLoginModal,

View File

@@ -0,0 +1,329 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasLayer,
CanvasTool,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
const createEditorAssetMock = 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,
createEditorAsset: createEditorAssetMock,
};
});
function createDefaultFolder(): EditorAssetFolder {
return {
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
};
}
function createTestFile(name = '上传素材.png') {
return new File(['image'], name, { type: 'image/png' });
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function UploadWorkflowHarness({
canAccessProtectedData = true,
openEditorLoginModal = vi.fn(),
activeTool = 'select',
}: {
canAccessProtectedData?: boolean;
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
activeTool?: CanvasTool;
}) {
const [assetFolders, setAssetFolders] = useState<EditorAssetFolder[]>([
createDefaultFolder(),
]);
const [assets, setAssets] = useState<EditorAsset[]>([]);
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [generateDialog, setGenerateDialog] =
useState<GenerateDialogState | null>(null);
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const uploadIndexRef = useRef(0);
const workflow = useImageCanvasUploadWorkflow({
canAccessProtectedData,
openEditorLoginModal,
assetFolders,
activeUploadFolderId: 'project',
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
activeTool,
allocateUploadIndex: () => {
uploadIndexRef.current += 1;
return uploadIndexRef.current;
},
setAssetFolders,
setAssets,
setLayers,
setGenerateDialog,
setActiveSidebarPanel,
appendCanvasLayersWithResources: (nextLayers) => {
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
selectSingleLayer: setSelectedLayerId,
});
return (
<div>
<input
ref={workflow.uploadInputRef}
type="file"
aria-label="上传图片文件"
multiple
onChange={workflow.handleUploadInputChange}
/>
<span data-testid="assets">
{assets
.map(
(asset) =>
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}`,
)
.join('|')}
</span>
<span data-testid="folders">
{assetFolders
.map(
(folder) =>
`${folder.id}:${folder.collapsed ? 'collapsed' : 'open'}`,
)
.join('|')}
</span>
<span data-testid="layers">
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}`,
)
.join('|')}
</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="selected-layer">{selectedLayerId ?? '-'}</span>
<span data-testid="dialog">
{generateDialog
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}`
: '-'}
</span>
<button
type="button"
onClick={() => workflow.addUploadedFiles([createTestFile()])}
>
</button>
<button
type="button"
onClick={() =>
workflow.addUploadedFiles([createTestFile('画布素材.png')], {
addToCanvas: true,
canvasPoint: { x: 110, y: 120 },
})
}
>
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
})
}
>
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('character-spec')}
>
</button>
</div>
);
}
describe('useImageCanvasUploadWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
createEditorAssetMock.mockImplementation(async (input) => ({
assetId: `persisted-${input.label}`,
folderId: input.folderId,
label: input.label,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
objectKey: 'object-key-uploaded',
assetObjectId: 'asset-object-uploaded',
}));
});
it('opens login before creating placeholders and resumes the same upload after login', async () => {
const openEditorLoginModal = vi.fn();
const { rerender } = render(
<UploadWorkflowHarness
canAccessProtectedData={false}
openEditorLoginModal={openEditorLoginModal}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传素材' }));
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('assets').textContent).toBe('');
const resumeUpload = openEditorLoginModal.mock.calls[0]?.[0];
rerender(
<UploadWorkflowHarness
canAccessProtectedData
openEditorLoginModal={openEditorLoginModal}
/>,
);
act(() => {
(resumeUpload as () => void)();
});
await waitFor(() => {
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'persisted-上传素材.png:上传素材.png:project:ready:-',
);
});
});
it('creates an uploading asset card, adds a canvas layer, and patches the layer with the persisted asset id', async () => {
const deferredAsset = createDeferred<{
assetId: string;
folderId: string;
label: string;
imageSrc: string;
width: number;
height: number;
sourceType: 'uploaded';
objectKey: string;
assetObjectId: string;
}>();
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '上传到画布' }));
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'upload-1:画布素材.png:project:uploading:上传中',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:upload-1:-160:-107.5',
);
});
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
expect(screen.getByTestId('selected-layer').textContent).toBe(
'layer-upload-1',
);
deferredAsset.resolve({
assetId: 'asset-persisted-canvas',
folderId: 'project',
label: '画布素材.png',
imageSrc: 'data:image/png;base64,Y2FudmFz',
width: 420,
height: 315,
sourceType: 'uploaded',
objectKey: 'object-key-canvas',
assetObjectId: 'asset-object-canvas',
});
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'asset-persisted-canvas:画布素材.png:project:ready:-',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5',
);
});
});
it('marks upload cards as failed and reopens login on auth errors returned by asset creation', async () => {
const openEditorLoginModal = vi.fn();
createEditorAssetMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<UploadWorkflowHarness openEditorLoginModal={openEditorLoginModal} />,
);
fireEvent.click(screen.getByRole('button', { name: '上传素材' }));
await waitFor(() => {
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('assets').textContent).toContain(
'upload-1:上传素材.png:project:failed:请先登录',
);
});
});
it('dispatches file input uploads to generation references and resets failed state', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'character:failed:-:0:-',
);
});
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('角色规范.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'character:idle:角色规范.png:0:-',
);
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,508 @@
import {
type ChangeEvent,
type Dispatch,
type SetStateAction,
useCallback,
useRef,
useState,
} from 'react';
import { ApiClientError } from '../../services/apiClient';
import { createEditorAsset } from '../../services/image-editor/editorProjectClient';
import { resolveLayerResolutionSize } from './ImageCanvasEditorModel';
import type {
CanvasLayer,
CanvasTool,
CanvasViewport,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
SidebarPanel,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { isImageFile, readImageFileAsDataUrl } from './ImageCanvasFileModel';
type UploadFilesOptions = {
folderId?: string;
canvasPoint?: { x: number; y: number };
addToCanvas?: boolean;
};
type UploadAssetFileOptions = UploadFilesOptions & {
uploadIndex: number;
};
type UseImageCanvasUploadWorkflowOptions = {
canAccessProtectedData: boolean;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
assetFolders: EditorAssetFolder[];
activeUploadFolderId: string;
canvasSize: { width: number; height: number };
viewport: CanvasViewport;
activeTool: CanvasTool;
allocateUploadIndex: () => number;
setAssetFolders: Dispatch<SetStateAction<EditorAssetFolder[]>>;
setAssets: Dispatch<SetStateAction<EditorAsset[]>>;
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
function setFailedGenerationIdle(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
export function useImageCanvasUploadWorkflow({
canAccessProtectedData,
openEditorLoginModal,
assetFolders,
activeUploadFolderId,
canvasSize,
viewport,
activeTool,
allocateUploadIndex,
setAssetFolders,
setAssets,
setLayers,
setGenerateDialog,
setActiveSidebarPanel,
appendCanvasLayersWithResources,
selectSingleLayer,
}: UseImageCanvasUploadWorkflowOptions) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const canAccessProtectedDataRef = useRef(canAccessProtectedData);
const uploadTargetRef = useRef<UploadTarget>('asset');
const [uploadTarget, setUploadTargetState] = useState<UploadTarget>('asset');
canAccessProtectedDataRef.current = canAccessProtectedData;
const setUploadTarget: Dispatch<SetStateAction<UploadTarget>> = useCallback(
(nextTarget) => {
const resolvedTarget =
typeof nextTarget === 'function'
? nextTarget(uploadTargetRef.current)
: nextTarget;
uploadTargetRef.current = resolvedTarget;
setUploadTargetState(resolvedTarget);
},
[],
);
const addCharacterSpecReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFile = Array.from(files).find(isImageFile);
if (!imageFile) {
window.alert('请选择图片文件');
return;
}
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setFailedGenerationIdle(currentDialog),
characterSpecReference: {
id: `upload-character-spec-${Date.now()}`,
label: imageFile.name || '角色形象规范',
src: imageSrc,
},
}
: currentDialog,
);
},
[setGenerateDialog],
);
const addCharacterReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter(isImageFile);
if (!imageFiles.length) {
window.alert('请选择图片文件');
return;
}
const references = await Promise.all(
imageFiles.map(async (file, index) => ({
id: `upload-character-reference-${Date.now()}-${index}`,
label: file.name || `参考图${index + 1}`,
src: await readImageFileAsDataUrl(file),
})),
);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setFailedGenerationIdle(currentDialog),
characterReferences: [
...(currentDialog.characterReferences ?? []),
...references,
],
}
: currentDialog,
);
},
[setGenerateDialog],
);
const addIconSpecReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFile = Array.from(files).find(isImageFile);
if (!imageFile) {
window.alert('请选择图片文件');
return;
}
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...setFailedGenerationIdle(currentDialog),
iconSpecReference: {
id: `upload-icon-spec-${Date.now()}`,
label: imageFile.name || '图标素材规范',
src: imageSrc,
},
}
: currentDialog,
);
},
[setGenerateDialog],
);
const uploadAssetFile = useCallback(
async (file: File, options: UploadAssetFileOptions) => {
if (!isImageFile(file)) {
window.alert('请选择图片文件');
return;
}
const fallbackWidth = 420;
const fallbackHeight = 315;
const uploadFolderId = assetFolders.some(
(folder) => folder.id === (options.folderId ?? activeUploadFolderId),
)
? (options.folderId ?? activeUploadFolderId)
: 'project';
const uploadIndex = options.uploadIndex;
const uploadedAsset: EditorAsset = {
id: `upload-${uploadIndex}`,
label: file.name || '上传图片',
src: '',
width: fallbackWidth,
height: fallbackHeight,
folderId: uploadFolderId,
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: false,
uploadStatus: 'uploading',
uploadProgress: 8,
uploadMessage: '准备上传',
};
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === uploadFolderId
? {
...folder,
collapsed: false,
}
: folder,
),
);
let imageSrc = '';
try {
imageSrc = await readImageFileAsDataUrl(file);
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
src: imageSrc,
uploadProgress: 42,
uploadMessage: '读取图片',
}
: asset,
),
);
} catch {
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: '读取失败',
}
: asset,
),
);
return;
}
const screenPoint = options.canvasPoint ?? {
x: canvasSize.width / 2,
y: canvasSize.height / 2,
};
const fallbackScreenPoint = {
x: canvasSize.width > 0 ? canvasSize.width / 2 : 640,
y: canvasSize.height > 0 ? canvasSize.height / 2 : 360,
};
const normalizedScreenPoint = {
x: Number.isFinite(screenPoint.x)
? screenPoint.x
: fallbackScreenPoint.x,
y: Number.isFinite(screenPoint.y)
? screenPoint.y
: fallbackScreenPoint.y,
};
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
const worldCenterX = (normalizedScreenPoint.x - viewport.x) / safeScale;
const worldCenterY = (normalizedScreenPoint.y - viewport.y) / safeScale;
const nextLayer: CanvasLayer = {
id: `layer-upload-${uploadIndex}`,
resourceId: `local-resource-upload-${uploadIndex}`,
title: file.name || '上传图片',
src: imageSrc,
x: worldCenterX - fallbackWidth / 2,
y: worldCenterY - fallbackHeight / 2,
width: fallbackWidth,
height: fallbackHeight,
originalWidth: fallbackWidth,
originalHeight: fallbackHeight,
zIndex: uploadIndex + 10,
sourceType: 'uploaded',
sourceAssetId: `upload-${uploadIndex}`,
};
if (options.addToCanvas) {
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadProgress: 68,
uploadMessage: '上传中',
}
: asset,
),
);
createEditorAsset({
folderId: uploadFolderId,
label: uploadedAsset.label,
imageSrc,
width: fallbackWidth,
height: fallbackHeight,
sourceType: 'uploaded',
})
.then((asset) => {
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === uploadedAsset.id
? {
...currentAsset,
id: asset.assetId,
folderId: asset.folderId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
persisted: true,
uploadStatus: undefined,
uploadProgress: undefined,
uploadMessage: undefined,
}
: currentAsset,
),
);
if (options.addToCanvas) {
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === nextLayer.id
? {
...currentLayer,
sourceAssetId: asset.assetId,
objectKey: asset.objectKey ?? currentLayer.objectKey,
assetObjectId:
asset.assetObjectId ?? currentLayer.assetObjectId,
}
: currentLayer,
),
);
}
})
.catch((error: unknown) => {
const isAuthError = isEditorAuthError(error);
if (isAuthError) {
openEditorLoginModal();
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: isAuthError ? '请先登录' : '上传失败',
}
: asset,
),
);
});
if (imageSrc) {
const uploadedImage = new Image();
uploadedImage.onload = () => {
const originalWidth = uploadedImage.naturalWidth || fallbackWidth;
const originalHeight = uploadedImage.naturalHeight || fallbackHeight;
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{ width: fallbackWidth, height: fallbackHeight },
);
if (options.addToCanvas) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === nextLayer.id
? {
...layer,
width,
height,
originalWidth,
originalHeight,
x: worldCenterX - width / 2,
y: worldCenterY - height / 2,
}
: layer,
),
);
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
width: originalWidth,
height: originalHeight,
}
: asset,
),
);
};
uploadedImage.src = imageSrc;
}
},
[
activeUploadFolderId,
appendCanvasLayersWithResources,
assetFolders,
canvasSize.height,
canvasSize.width,
openEditorLoginModal,
selectSingleLayer,
setActiveSidebarPanel,
setAssetFolders,
setAssets,
setLayers,
viewport.scale,
viewport.x,
viewport.y,
],
);
const addUploadedFiles = useCallback(
(files: FileList | File[], options: UploadFilesOptions = {}) => {
const imageFiles = Array.from(files);
if (!canAccessProtectedDataRef.current) {
openEditorLoginModal(() => {
addUploadedFiles(imageFiles, options);
});
return;
}
imageFiles.forEach((file, index) => {
const uploadIndex = allocateUploadIndex();
void uploadAssetFile(file, {
...options,
addToCanvas: options.addToCanvas ?? false,
uploadIndex,
canvasPoint: options.canvasPoint
? {
x: options.canvasPoint.x + index * 28,
y: options.canvasPoint.y + index * 28,
}
: undefined,
});
});
},
[
allocateUploadIndex,
openEditorLoginModal,
uploadAssetFile,
],
);
const requestUpload = useCallback((target: UploadTarget = 'asset') => {
setUploadTarget(target);
uploadInputRef.current?.click();
}, []);
const handleUploadInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const files = event.currentTarget.files;
const currentUploadTarget = uploadTargetRef.current;
if (files?.length) {
if (currentUploadTarget === 'character-spec') {
void addCharacterSpecReferenceFiles(files);
} else if (currentUploadTarget === 'character-reference') {
void addCharacterReferenceFiles(files);
} else if (currentUploadTarget === 'icon-spec') {
void addIconSpecReferenceFiles(files);
} else {
addUploadedFiles(files, { addToCanvas: activeTool === 'upload' });
}
}
setUploadTarget('asset');
event.currentTarget.value = '';
},
[
activeTool,
addCharacterReferenceFiles,
addCharacterSpecReferenceFiles,
addIconSpecReferenceFiles,
addUploadedFiles,
setUploadTarget,
],
);
return {
uploadInputRef,
uploadTarget,
setUploadTarget,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
addCharacterSpecReferenceFiles,
addCharacterReferenceFiles,
addIconSpecReferenceFiles,
};
}