diff --git a/TRACKING.md b/TRACKING.md index 66848a34..625fcb12 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -125,3 +125,4 @@ - 2026-06-17 前端拆分第八阶段:新增 `useImageCanvasProjectPersistence`,把项目加载、`projectId` 状态、未就绪资源队列、工程资源创建、资源创建后即时保存和 450ms 自动保存从主视图抽出;新增 hook 单测锁定新增图层资源创建后保存真实 `resourceId` 的 layout。验证命令:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。 - 2026-06-17 前端拆分第九阶段:新增 `useCanvasGenerationDialogs`,把画布生成对象的 active / inactive 注册表、归档、激活、按 id 更新 / 删除、按图层清理和生成中最新占位框查询从主视图抽出;主视图继续保留生成提交、结果落图、quick edit 和跨图层副作用。同步把 `画布背景设置` 调整为 Lovart 式紧凑色板弹层。验证命令:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录弹出 `账号入口`,关闭后点击 `画布背景色` 显示色域、色相条、圆形预设和 HEX 输入,点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 对话框,`AI画布工具栏` 保持可见。 - 2026-06-17 前端拆分第十阶段:新增 `useImageCanvasAssetLibrary`,把账号级素材库加载、文件夹新建 / 折叠 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和素材库 401 登录弹窗从主视图抽出;主视图继续保留上传读取、上传进度、拖到画布坐标、画布图层创建和工程资源持久化。新增 hook 单测覆盖素材库归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后生成占位和 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见;登录临时开发账号后上传图片成功进入 `项目素材`,点击素材加入画布,切换 `图层` 可看到对应图层,控制台无前端 error。 +- 2026-06-17 前端拆分第十一阶段:新增 `ImageCanvasFileModel` 和 `useImageCanvasUploadWorkflow`,把隐藏上传 input、上传目标分发、未登录续传、上传占位卡片、素材落库、拖到画布建层、生成参考图上传从主视图抽出;主视图保留画布 drop 外层判断和项目资源持久化注入。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;上传图片后素材数增加,点击素材加入画布,切换 `图层` 面板可看到 2 个图层,登录后控制台无前端 error。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 7eacca13..a96272a4 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -99,14 +99,25 @@ - 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。 - 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。 +## 第十一阶段模块 + +- `ImageCanvasFileModel.ts` + - 承载图片文件判定和 `FileReader` Data URL 读取工具,供素材上传、生成参考图上传和后续导入能力复用。 + - 该模块不依赖素材库状态,避免把通用文件读取继续挂在素材库 hook 上。 + +- `useImageCanvasUploadWorkflow.ts` + - 承载图片画布上传工作流:隐藏文件 input、上传目标分发、未登录拦截和登录后续传、上传占位卡片、文件读取、素材落库、拖到画布建层、选中新图层、打开图层侧栏,以及角色 / 图标生成参考图上传。 + - 主视图继续负责画布 drop 外层事件判断、素材库已有素材加入画布、项目资源持久化 hook 注入和画布历史捕获,避免上传 hook 反向成为画布全局状态真相。 + - 该 hook 用独立单测覆盖登录续传、上传占位 / 成功回写、上传到画布建层、鉴权失败和生成参考图分发,主视图保留 DOM 级 smoke 覆盖侧栏上传、画布 drop 上传和文件夹定向上传。 + ## 后续阶段 - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 -- 上传状态模型:上传占位卡片、读取图片、登录后续传、素材落库和拖到画布创建图层仍在主视图与侧栏之间协作,后续需要等上传错误恢复规则进一步稳定后再收口。 +- 生成状态机模型之后,可继续评估快速编辑 / 角色动画结果回写是否已经稳定到足以形成深模块。 ## 验证计划 -- `npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` +- `npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `git diff --check` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 91545d7a..11b605ec 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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(null); const canvasViewportRef = useRef(null); - const uploadInputRef = useRef(null); const assetListRef = useRef(null); const dragStateRef = useRef(null); const assetPointerDragRef = useRef(null); @@ -296,7 +287,6 @@ export function ImageCanvasEditorView() { DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [metadataLayer, setMetadataLayer] = useState(null); - const [uploadTarget, setUploadTarget] = useState('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 ? (
{ - setUploadTarget(target); - uploadInputRef.current?.click(); - }} + onRequestUpload={requestUpload} onSubmitImageGeneration={(dialog) => void submitImageGeneration(dialog) } diff --git a/src/components/image-editor/ImageCanvasFileModel.ts b/src/components/image-editor/ImageCanvasFileModel.ts new file mode 100644 index 00000000..14ba912c --- /dev/null +++ b/src/components/image-editor/ImageCanvasFileModel.ts @@ -0,0 +1,18 @@ +export function isImageFile(file: File) { + return file.type.startsWith('image/'); +} + +export function readImageFileAsDataUrl(file: File) { + return new Promise((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); + }); +} diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index 13f7e870..48e44cd9 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -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((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, diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx new file mode 100644 index 00000000..0cba475b --- /dev/null +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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([ + createDefaultFolder(), + ]); + const [assets, setAssets] = useState([]); + const [layers, setLayers] = useState([]); + const [generateDialog, setGenerateDialog] = + useState(null); + const [activeSidebarPanel, setActiveSidebarPanel] = + useState('assets'); + const [selectedLayerId, setSelectedLayerId] = useState(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 ( +
+ + + {assets + .map( + (asset) => + `${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}`, + ) + .join('|')} + + + {assetFolders + .map( + (folder) => + `${folder.id}:${folder.collapsed ? 'collapsed' : 'open'}`, + ) + .join('|')} + + + {layers + .map( + (layer) => + `${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}`, + ) + .join('|')} + + {activeSidebarPanel ?? '-'} + {selectedLayerId ?? '-'} + + {generateDialog + ? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}` + : '-'} + + + + + +
+ ); +} + +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( + , + ); + + 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( + , + ); + 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(); + + 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( + , + ); + + 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(); + + 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(); + }); +}); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts new file mode 100644 index 00000000..ed225811 --- /dev/null +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -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>; + setAssets: Dispatch>; + setLayers: Dispatch>; + setGenerateDialog: Dispatch>; + setActiveSidebarPanel: 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, + setActiveSidebarPanel, + 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 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) => { + 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, + }; +}