拆分图片画布上传工作流
新增图片文件读取模型和上传工作流 hook 把上传目标分发、登录续传、占位卡片和画布建层从主视图抽出 补充上传工作流单测并更新拆分计划和进度记录
This commit is contained in:
508
src/components/image-editor/useImageCanvasUploadWorkflow.ts
Normal file
508
src/components/image-editor/useImageCanvasUploadWorkflow.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user