拆分图片画布上传工作流
新增图片文件读取模型和上传工作流 hook 把上传目标分发、登录续传、占位卡片和画布建层从主视图抽出 补充上传工作流单测并更新拆分计划和进度记录
This commit is contained in:
@@ -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 前端拆分第八阶段:新增 `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 前端拆分第九阶段:新增 `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 前端拆分第十阶段:新增 `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。
|
||||||
|
|||||||
@@ -99,14 +99,25 @@
|
|||||||
- 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。
|
- 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。
|
||||||
- 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。
|
- 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。
|
||||||
|
|
||||||
|
## 第十一阶段模块
|
||||||
|
|
||||||
|
- `ImageCanvasFileModel.ts`
|
||||||
|
- 承载图片文件判定和 `FileReader` Data URL 读取工具,供素材上传、生成参考图上传和后续导入能力复用。
|
||||||
|
- 该模块不依赖素材库状态,避免把通用文件读取继续挂在素材库 hook 上。
|
||||||
|
|
||||||
|
- `useImageCanvasUploadWorkflow.ts`
|
||||||
|
- 承载图片画布上传工作流:隐藏文件 input、上传目标分发、未登录拦截和登录后续传、上传占位卡片、文件读取、素材落库、拖到画布建层、选中新图层、打开图层侧栏,以及角色 / 图标生成参考图上传。
|
||||||
|
- 主视图继续负责画布 drop 外层事件判断、素材库已有素材加入画布、项目资源持久化 hook 注入和画布历史捕获,避免上传 hook 反向成为画布全局状态真相。
|
||||||
|
- 该 hook 用独立单测覆盖登录续传、上传占位 / 成功回写、上传到画布建层、鉴权失败和生成参考图分发,主视图保留 DOM 级 smoke 覆盖侧栏上传、画布 drop 上传和文件夹定向上传。
|
||||||
|
|
||||||
## 后续阶段
|
## 后续阶段
|
||||||
|
|
||||||
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
|
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 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 typecheck`
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import { ApiClientError } from '../../services/apiClient';
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||||
import {
|
import {
|
||||||
createEditorAsset,
|
|
||||||
editEditorImage,
|
editEditorImage,
|
||||||
type EditorIconSpritesheetGenerationResult,
|
type EditorIconSpritesheetGenerationResult,
|
||||||
type EditorIconSpritesheetIconResult,
|
type EditorIconSpritesheetIconResult,
|
||||||
@@ -149,19 +148,12 @@ import type {
|
|||||||
SnapGuide,
|
SnapGuide,
|
||||||
SpecFormValues,
|
SpecFormValues,
|
||||||
SpecGenerationType,
|
SpecGenerationType,
|
||||||
UploadTarget,
|
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
import { useCanvasHistory } from './useCanvasHistory';
|
import { useCanvasHistory } from './useCanvasHistory';
|
||||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||||
import {
|
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
||||||
readImageFileAsDataUrl,
|
|
||||||
useImageCanvasAssetLibrary,
|
|
||||||
} from './useImageCanvasAssetLibrary';
|
|
||||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||||
|
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||||
function isImageFile(file: File) {
|
|
||||||
return file.type.startsWith('image/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableTarget(event: KeyboardEvent) {
|
function isEditableTarget(event: KeyboardEvent) {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
@@ -231,7 +223,6 @@ export function ImageCanvasEditorView() {
|
|||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStateRef = useRef<DragState | null>(null);
|
const dragStateRef = useRef<DragState | null>(null);
|
||||||
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
||||||
@@ -296,7 +287,6 @@ export function ImageCanvasEditorView() {
|
|||||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||||
);
|
);
|
||||||
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||||
const [uploadTarget, setUploadTarget] = useState<UploadTarget>('asset');
|
|
||||||
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
|
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
|
||||||
const [
|
const [
|
||||||
isPickingCharacterSpecFromCanvas,
|
isPickingCharacterSpecFromCanvas,
|
||||||
@@ -693,6 +683,32 @@ export function ImageCanvasEditorView() {
|
|||||||
viewport,
|
viewport,
|
||||||
openEditorLoginModal,
|
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(() => {
|
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
|
||||||
setGenerateDialog((currentDialog) =>
|
setGenerateDialog((currentDialog) =>
|
||||||
@@ -1423,55 +1439,6 @@ export function ImageCanvasEditorView() {
|
|||||||
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
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) => {
|
const pickCharacterSpecFromLayer = (layer: CanvasLayer) => {
|
||||||
setGenerateDialog((currentDialog) =>
|
setGenerateDialog((currentDialog) =>
|
||||||
currentDialog?.mode === 'character'
|
currentDialog?.mode === 'character'
|
||||||
@@ -1493,28 +1460,6 @@ export function ImageCanvasEditorView() {
|
|||||||
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
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) => {
|
const pickIconSpecFromLayer = (layer: CanvasLayer) => {
|
||||||
if (layer.assetKind !== 'icon-spec') {
|
if (layer.assetKind !== 'icon-spec') {
|
||||||
return;
|
return;
|
||||||
@@ -1533,276 +1478,6 @@ export function ImageCanvasEditorView() {
|
|||||||
setImageContextMenu(null);
|
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) => {
|
const deleteLayerById = (targetLayerId: string | null) => {
|
||||||
if (!targetLayerId) {
|
if (!targetLayerId) {
|
||||||
return;
|
return;
|
||||||
@@ -3031,8 +2706,7 @@ export function ImageCanvasEditorView() {
|
|||||||
setIsPanning(false);
|
setIsPanning(false);
|
||||||
setSnapGuide(null);
|
setSnapGuide(null);
|
||||||
if (tool === 'upload') {
|
if (tool === 'upload') {
|
||||||
setUploadTarget('asset');
|
requestUpload('asset');
|
||||||
uploadInputRef.current?.click();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tool === 'generate') {
|
if (tool === 'generate') {
|
||||||
@@ -3203,22 +2877,7 @@ export function ImageCanvasEditorView() {
|
|||||||
multiple
|
multiple
|
||||||
aria-label="上传图片文件"
|
aria-label="上传图片文件"
|
||||||
hidden
|
hidden
|
||||||
onChange={(event) => {
|
onChange={handleUploadInputChange}
|
||||||
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 = '';
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{assetPointerDrag?.active ? (
|
{assetPointerDrag?.active ? (
|
||||||
<div
|
<div
|
||||||
@@ -3520,10 +3179,7 @@ export function ImageCanvasEditorView() {
|
|||||||
}
|
}
|
||||||
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
|
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
|
||||||
onOpenSpecDialog={openSpecDialog}
|
onOpenSpecDialog={openSpecDialog}
|
||||||
onRequestUpload={(target) => {
|
onRequestUpload={requestUpload}
|
||||||
setUploadTarget(target);
|
|
||||||
uploadInputRef.current?.click();
|
|
||||||
}}
|
|
||||||
onSubmitImageGeneration={(dialog) =>
|
onSubmitImageGeneration={(dialog) =>
|
||||||
void submitImageGeneration(dialog)
|
void submitImageGeneration(dialog)
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/components/image-editor/ImageCanvasFileModel.ts
Normal file
18
src/components/image-editor/ImageCanvasFileModel.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
EditorAsset,
|
EditorAsset,
|
||||||
EditorAssetFolder,
|
EditorAssetFolder,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
|
export { readImageFileAsDataUrl } from './ImageCanvasFileModel';
|
||||||
|
|
||||||
function isEditorAuthError(error: unknown) {
|
function isEditorAuthError(error: unknown) {
|
||||||
return (
|
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({
|
export function useImageCanvasAssetLibrary({
|
||||||
assetListRef,
|
assetListRef,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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