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, 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>; setAssets: Dispatch>; setLayers: Dispatch>; setGenerateDialog: Dispatch>; 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, appendCanvasLayersWithResources, selectSingleLayer, }: UseImageCanvasUploadWorkflowOptions) { const uploadInputRef = useRef(null); const canAccessProtectedDataRef = useRef(canAccessProtectedData); const uploadTargetRef = useRef('asset'); const [uploadTarget, setUploadTargetState] = useState('asset'); canAccessProtectedDataRef.current = canAccessProtectedData; const setUploadTarget: Dispatch> = 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 addSpecReferenceFiles = 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 === 'spec' ? { ...setFailedGenerationIdle(currentDialog), specReference: { id: `upload-spec-reference-${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); } 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, 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 openUploadPicker = useCallback( (target: UploadTarget) => { setUploadTarget(target); uploadInputRef.current?.click(); }, [setUploadTarget], ); const requestUpload = useCallback( (target: UploadTarget = 'asset') => { if (target === 'asset' && !canAccessProtectedDataRef.current) { openEditorLoginModal(); return; } openUploadPicker(target); }, [openEditorLoginModal, openUploadPicker], ); const handleUploadInputChange = useCallback( (event: ChangeEvent) => { const files = event.currentTarget.files; const currentUploadTarget = uploadTargetRef.current; if (files?.length) { if (currentUploadTarget === 'spec-reference') { void addSpecReferenceFiles(files); } else 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, addSpecReferenceFiles, addIconSpecReferenceFiles, addUploadedFiles, setUploadTarget, ], ); return { uploadInputRef, uploadTarget, setUploadTarget, requestUpload, handleUploadInputChange, addUploadedFiles, addSpecReferenceFiles, addCharacterSpecReferenceFiles, addCharacterReferenceFiles, addIconSpecReferenceFiles, }; }