diff --git a/package-lock.json b/package-lock.json index 528bddb5..9d5e617a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@vitejs/plugin-react": "^5.0.4", "cannon-es": "^0.20.0", "dotenv": "^17.2.3", + "jszip": "^3.10.1", "lucide-react": "^0.546.0", "motion": "^12.23.24", "qrcode": "^1.5.4", @@ -2749,6 +2750,12 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3925,6 +3932,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3964,8 +3977,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.8", @@ -4029,6 +4041,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4203,6 +4221,48 @@ "dev": true, "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4225,6 +4285,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4886,6 +4955,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5114,6 +5189,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -5463,6 +5544,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5996,9 +6083,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -9503,6 +9588,11 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10331,6 +10421,11 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -10360,8 +10455,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "1.3.8", @@ -10408,6 +10502,11 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -10536,6 +10635,46 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true }, + "jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10555,6 +10694,14 @@ "type-check": "~0.4.0" } }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "requires": { + "immediate": "~3.0.5" + } + }, "lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -10944,6 +11091,11 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11099,6 +11251,11 @@ } } }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -11334,6 +11491,11 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11701,9 +11863,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "optional": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "v8-to-istanbul": { "version": "9.3.0", diff --git a/package.json b/package.json index de61e0f3..126f396f 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@vitejs/plugin-react": "^5.0.4", "cannon-es": "^0.20.0", "dotenv": "^17.2.3", + "jszip": "^3.10.1", "lucide-react": "^0.546.0", "motion": "^12.23.24", "qrcode": "^1.5.4", diff --git a/src/components/image-editor/ImageCanvasEditorPrimitives.tsx b/src/components/image-editor/ImageCanvasEditorPrimitives.tsx index 8b82d2b5..2cf75a5b 100644 --- a/src/components/image-editor/ImageCanvasEditorPrimitives.tsx +++ b/src/components/image-editor/ImageCanvasEditorPrimitives.tsx @@ -69,9 +69,11 @@ export type SidebarMediaItemProps = { primaryClassName?: string; actions?: ReactNode; titleNode?: ReactNode; + previewOverlay?: ReactNode; + footerNode?: ReactNode; draggable?: boolean; - onDragStart?: DragEventHandler; - onDragEnd?: DragEventHandler; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; onDragOver?: DragEventHandler; onDrop?: DragEventHandler; onPointerDown?: PointerEventHandler; @@ -93,6 +95,8 @@ export function SidebarMediaItem({ primaryClassName, actions, titleNode, + previewOverlay, + footerNode, draggable, onDragStart, onDragEnd, @@ -119,6 +123,9 @@ export function SidebarMediaItem({ className={primaryClassName} onClick={onPrimaryClick} aria-label={primaryLabel} + draggable={draggable} + onDragStart={onDragStart} + onDragEnd={onDragEnd} >
{titleNode ?? {title}} {detail} + {footerNode}
{actions} diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 6017f7b3..34da2091 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import JSZip from 'jszip'; +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; @@ -19,7 +21,69 @@ const deleteEditorAssetMock = vi.hoisted(() => vi.fn()); const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); +const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); +const openLoginModalMock = vi.hoisted(() => vi.fn()); +const authUiMockState = vi.hoisted(() => ({ + value: null as { + user: AuthUser | null; + canAccessProtectedData: boolean; + openLoginModal: typeof openLoginModalMock; + requireAuth: ReturnType; + openSettingsModal: ReturnType; + openAccountModal: ReturnType; + setCurrentUser: ReturnType; + logout: ReturnType; + musicVolume: number; + setMusicVolume: ReturnType; + platformTheme: 'light'; + setPlatformTheme: ReturnType; + isHydratingSettings: boolean; + isPersistingSettings: boolean; + settingsError: string | null; + } | null, +})); + +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => authUiMockState.value, +})); + +const imageEditorTestUser: AuthUser = { + id: 'user-editor-test', + publicUserCode: 'UEDITOR', + displayName: '画布测试用户', + avatarUrl: null, + phoneNumberMasked: '139****4806', + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, +}; + +function createAuthUiMock(user: AuthUser | null = imageEditorTestUser) { + return { + user, + canAccessProtectedData: Boolean(user), + openLoginModal: openLoginModalMock, + requireAuth: vi.fn((action: () => void) => { + if (user) { + action(); + return; + } + openLoginModalMock(action); + }), + openSettingsModal: vi.fn(), + openAccountModal: vi.fn(), + setCurrentUser: vi.fn(), + logout: vi.fn(), + musicVolume: 0.5, + setMusicVolume: vi.fn(), + platformTheme: 'light' as const, + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }; +} const defaultProjectLayers = [ { @@ -116,6 +180,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { loadEditorAssetLibrary: loadEditorAssetLibraryMock, loadEditorProject: loadEditorProjectMock, loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, + renameEditorProject: renameEditorProjectMock, saveEditorProjectLayout: saveEditorProjectLayoutMock, updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, @@ -123,16 +188,20 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }); function dispatchPointerEvent( - target: Element, + target: Element | Window, type: string, - init: MouseEventInit & { pointerId: number }, + init: PointerEventInit & { pointerId: number }, ) { - const event = new MouseEvent(type, { + const PointerEventConstructor = + typeof PointerEvent === 'undefined' ? MouseEvent : PointerEvent; + const event = new PointerEventConstructor(type, { bubbles: true, cancelable: true, ...init, }); Object.defineProperty(event, 'pointerId', { value: init.pointerId }); + Object.defineProperty(event, 'pointerType', { value: 'mouse' }); + Object.defineProperty(event, 'isPrimary', { value: true }); fireEvent(target, event); } @@ -161,8 +230,25 @@ async function renderLoadedEditor() { await screen.findByRole('button', { name: '添加拼图素材' }); } +async function readZipText(zip: JSZip, path: string) { + const file = zip.file(path); + expect(file).toBeTruthy(); + return file!.async('string'); +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + describe('ImageCanvasEditorView', () => { beforeEach(() => { + authUiMockState.value = createAuthUiMock(); loadOrCreateRecentEditorProjectMock.mockResolvedValue({ projectId: 'editor-project-default', title: '默认项目', @@ -192,6 +278,14 @@ describe('ImageCanvasEditorView', () => { height: input.height, sourceType: input.sourceType, })); + renameEditorProjectMock.mockImplementation(async (projectId, title) => ({ + projectId, + title, + viewport: { x: 0, y: 0, scale: 1 }, + layers: defaultProjectLayers, + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + })); createEditorAssetFolderMock.mockResolvedValue({ folderId: 'folder-role-persisted', label: '角色上传', @@ -252,7 +346,9 @@ describe('ImageCanvasEditorView', () => { loadEditorAssetLibraryMock.mockReset(); loadEditorProjectMock.mockReset(); loadOrCreateRecentEditorProjectMock.mockReset(); + renameEditorProjectMock.mockReset(); saveEditorProjectLayoutMock.mockReset(); + openLoginModalMock.mockReset(); window.history.replaceState(null, '', '/editor/canvas'); }); @@ -279,6 +375,29 @@ describe('ImageCanvasEditorView', () => { expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled(); }); + it('opens the login modal when direct project loading is unauthorized', async () => { + authUiMockState.value = createAuthUiMock(null); + loadEditorProjectMock.mockRejectedValueOnce( + new ApiClientError({ + message: '未授权访问', + status: 401, + code: 'UNAUTHORIZED', + }), + ); + window.history.replaceState( + null, + '', + '/editor/canvas?projectid=editor-project-private', + ); + + render(); + + await waitFor(() => { + expect(openLoginModalMock).toHaveBeenCalledWith(expect.any(Function)); + }); + expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); + }); + it('offers a topbar entry back to the project page', async () => { await renderLoadedEditor(); @@ -294,6 +413,237 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull(); }); + it('renames the current project from the canvas topbar', async () => { + await renderLoadedEditor(); + + fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' })); + const titleInput = screen.getByLabelText('项目名称'); + fireEvent.change(titleInput, { target: { value: '新画布项目' } }); + fireEvent.click(screen.getByRole('button', { name: '保存项目名称' })); + + await waitFor(() => { + expect(renameEditorProjectMock).toHaveBeenCalledWith( + 'editor-project-default', + '新画布项目', + ); + }); + expect(await screen.findByRole('heading', { name: '新画布项目' })).toBeTruthy(); + expect(screen.queryByLabelText('项目名称')).toBeNull(); + }); + + it('cancels project rename editing with Escape', async () => { + await renderLoadedEditor(); + + fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' })); + const titleInput = screen.getByLabelText('项目名称'); + fireEvent.change(titleInput, { target: { value: '不会保存' } }); + fireEvent.keyDown(titleInput, { key: 'Escape' }); + + expect(renameEditorProjectMock).not.toHaveBeenCalled(); + expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); + expect(screen.queryByLabelText('项目名称')).toBeNull(); + }); + + it('exports valid canvas assets as a zip from the topbar', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-export', + title: '导出项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-data-a', + resourceId: 'resource-data-a', + title: '素材/A', + src: 'data:image/png;base64,YQ==', + x: 12, + y: 24, + width: 320, + height: 220, + originalWidth: 640, + originalHeight: 440, + zIndex: 1, + sourceType: 'uploaded', + sourceAssetId: 'asset-data-a', + groupId: 'group-a', + hidden: true, + locked: true, + flipX: true, + }, + { + layerId: 'layer-data-a-copy', + resourceId: 'resource-data-a-copy', + title: '素材/A 副本', + src: 'data:image/png;base64,YQ==', + x: 42, + y: 54, + width: 320, + height: 220, + originalWidth: 640, + originalHeight: 440, + zIndex: 2, + sourceType: 'uploaded', + sourceAssetId: 'asset-data-a', + }, + { + layerId: 'layer-generated', + resourceId: 'resource-generated', + title: '生成图', + src: '/generated-ok.png', + x: 70, + y: 80, + width: 360, + height: 360, + originalWidth: 1024, + originalHeight: 1024, + zIndex: 3, + sourceType: 'generated', + prompt: '明亮主视觉', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'task-1', + }, + { + layerId: 'layer-failed', + resourceId: 'resource-failed', + title: '失败图', + src: '/missing.png', + x: 90, + y: 100, + width: 120, + height: 120, + originalWidth: 120, + originalHeight: 120, + zIndex: 4, + sourceType: 'generated', + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-data-a', + folderId: 'project', + label: '素材/A', + imageSrc: 'data:image/png;base64,YQ==', + width: 640, + height: 440, + sourceType: 'uploaded', + }, + ], + }); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (url: string) => { + if (url === '/generated-ok.png') { + return new Response(new Blob(['generated'], { type: 'image/png' })); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + const originalCreateObjectUrl = URL.createObjectURL; + const originalRevokeObjectUrl = URL.revokeObjectURL; + const originalAnchorClick = HTMLAnchorElement.prototype.click; + let exportedBlob: Blob | null = null; + let downloadName = ''; + URL.createObjectURL = vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:editor-export'; + }); + URL.revokeObjectURL = vi.fn(); + HTMLAnchorElement.prototype.click = vi.fn(function click(this: HTMLAnchorElement) { + downloadName = this.download; + }); + + try { + render(); + await screen.findByRole('heading', { name: '导出项目' }); + await waitFor(() => { + expect( + (screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + }); + fireEvent.click(screen.getByRole('button', { name: '下载画布素材' })); + + await waitFor(() => { + expect(exportedBlob).toBeTruthy(); + }); + expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u); + + const zip = await JSZip.loadAsync(exportedBlob!); + expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy(); + expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy(); + expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull(); + + const metadata = JSON.parse( + await readZipText(zip, '导出项目-画布素材/metadata.json'), + ); + expect(metadata.projectId).toBe('editor-project-export'); + expect(metadata.layers).toHaveLength(4); + expect(metadata.layers[0].file).toBe('images/001-素材 A.png'); + expect(metadata.layers[1].file).toBe('images/001-素材 A.png'); + expect(metadata.layers[0].canvas.hidden).toBe(true); + expect(metadata.layers[0].canvas.locked).toBe(true); + expect(metadata.layers[0].canvas.flipX).toBe(true); + expect(metadata.layers[0].canvas.groupId).toBe('group-a'); + expect(metadata.layers[2].sourceType).toBe('generated'); + expect(metadata.layers[2].prompt).toBe('明亮主视觉'); + expect(metadata.layers[3].file).toBeNull(); + expect(metadata.layers[3].exportError).toContain('404'); + expect(metadata.failedImages).toHaveLength(1); + expect( + await readZipText(zip, '导出项目-画布素材/manifest.txt'), + ).toContain('失败素材数量:1'); + expect(screen.getByText('部分素材未能导出')).toBeTruthy(); + } finally { + globalThis.fetch = originalFetch; + URL.createObjectURL = originalCreateObjectUrl; + URL.revokeObjectURL = originalRevokeObjectUrl; + HTMLAnchorElement.prototype.click = originalAnchorClick; + } + }); + + it('disables the canvas asset export entry when there are no valid layers', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-empty', + title: '空项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }); + + render(); + await screen.findByRole('heading', { name: '空项目' }); + + expect( + (screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + it('does not inject built-in mock assets or layers when persistence returns empty data', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-empty', @@ -326,7 +676,7 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('region', { name: '项目素材' })).toBeTruthy(); }); - it('removes invalid uploaded layers when the canvas opens', async () => { + it('keeps canvas layers when their account asset has been removed from the library', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-invalid-layer', title: '包含失效素材的项目', @@ -393,21 +743,67 @@ describe('ImageCanvasEditorView', () => { await waitFor(() => { expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy(); - expect(screen.queryByAltText('画布图片:已删除素材')).toBeNull(); + expect(screen.getByAltText('画布图片:已删除素材')).toBeTruthy(); }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith( + 'editor-project-invalid-layer', + expect.objectContaining({ + layers: [ + expect.objectContaining({ + layerId: 'layer-valid-asset', + sourceAssetId: 'asset-a', + }), + ], + }), + ); + }); + + it('keeps resource-backed canvas layers when their account asset is not loaded', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-resource-layer', + title: '历史工程资源项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-resource-backed', + resourceId: 'resource-backed', + title: '历史画布图片', + src: 'data:image/png;base64,aGlzdG9yeQ==', + x: 120, + y: 120, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }); + + render(); + await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( - 'editor-project-invalid-layer', - expect.objectContaining({ - layers: [ - expect.objectContaining({ - layerId: 'layer-valid-asset', - sourceAssetId: 'asset-a', - }), - ], - }), - ); + expect(screen.getByAltText('画布图片:历史画布图片')).toBeTruthy(); }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith( + 'editor-project-resource-layer', + expect.objectContaining({ layers: [] }), + ); }); it('toggles the shared sidebar from canvas panel buttons', async () => { @@ -623,6 +1019,12 @@ describe('ImageCanvasEditorView', () => { fireEvent.dragOver(roleFolder, { dataTransfer, }); + await waitFor(() => { + expect(screen.queryByText('添加到素材')).toBeNull(); + expect(roleFolder.className).toContain( + 'image-canvas-editor__asset-folder--move-target', + ); + }); fireEvent.drop(roleFolder, { dataTransfer, }); @@ -637,6 +1039,107 @@ describe('ImageCanvasEditorView', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('pins the asset move target when the target folder name is outside the asset panel viewport', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + { + folderId: 'folder-role', + label: '角色', + sortOrder: 100, + collapsed: false, + systemDefault: false, + }, + ], + assets: [ + { + assetId: 'asset-puzzle', + folderId: 'project', + label: '拼图素材', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + }, + ], + }); + render(); + + const sourceAsset = await screen.findByRole('button', { name: '添加拼图素材' }); + const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row'); + const roleFolder = screen.getByRole('region', { name: '角色' }); + const assetList = document.querySelector('.image-canvas-editor__asset-list'); + const roleHeader = roleFolder.querySelector( + '[data-asset-folder-header-id="folder-role"]', + ); + const dataTransfer = createDataTransferStub(); + + if (!sourceAssetRow || !assetList || !roleHeader) { + throw new Error('asset drag elements should exist'); + } + + vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 260, + bottom: 200, + width: 260, + height: 200, + toJSON: () => ({}), + } as DOMRect); + const roleHeaderRect = vi + .spyOn(roleHeader, 'getBoundingClientRect') + .mockReturnValue({ + x: 0, + y: 260, + top: 260, + left: 0, + right: 260, + bottom: 288, + width: 260, + height: 28, + toJSON: () => ({}), + } as DOMRect); + + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + fireEvent.dragOver(roleFolder, { dataTransfer }); + + await waitFor(() => { + expect( + document.querySelector( + '.image-canvas-editor__asset-folder-sticky-target', + )?.textContent, + ).toContain('角色'); + }); + + roleHeaderRect.mockReturnValue({ + x: 0, + y: 40, + top: 40, + left: 0, + right: 260, + bottom: 68, + width: 260, + height: 28, + toJSON: () => ({}), + } as DOMRect); + fireEvent.dragOver(roleFolder, { dataTransfer }); + + await waitFor(() => { + expect( + document.querySelector('.image-canvas-editor__asset-folder-sticky-target'), + ).toBeNull(); + }); + }); + it('uploads multiple files as account-level assets without adding canvas layers', async () => { render(); @@ -654,6 +1157,55 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull(); }); + it('shows an uploading placeholder card before restoring the normal asset card', async () => { + const deferredAsset = createDeferred<{ + assetId: string; + folderId: string; + label: string; + imageSrc: string; + width: number; + height: number; + sourceType: 'uploaded'; + }>(); + createEditorAssetMock.mockImplementationOnce(async (input) => { + await deferredAsset.promise; + return { + assetId: 'asset-uploading-finished', + folderId: input.folderId, + label: input.label, + imageSrc: input.imageSrc, + width: input.width, + height: input.height, + sourceType: 'uploaded', + }; + }); + render(); + + await userEvent.upload( + screen.getByLabelText('上传图片文件'), + new File(['image'], '上传进度.png', { type: 'image/png' }), + ); + + expect(await screen.findByRole('button', { name: '上传中上传进度.png' })).toBeTruthy(); + expect(screen.getByLabelText('素材上传进度.png上传进度')).toBeTruthy(); + expect(screen.queryByRole('button', { name: '添加上传进度.png' })).toBeNull(); + + deferredAsset.resolve({ + assetId: 'asset-uploading-finished', + folderId: 'project', + label: '上传进度.png', + imageSrc: 'data:image/png;base64,aW1hZ2U=', + width: 420, + height: 315, + sourceType: 'uploaded', + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: '添加上传进度.png' })).toBeTruthy(); + }); + expect(screen.queryByRole('button', { name: '上传中上传进度.png' })).toBeNull(); + }); + it('supports asset selection mode and batch delete with shared toolbar', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ @@ -1028,6 +1580,101 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('button', { name: '选择图层测试上传B.png' })).toBeTruthy(); }); + it('adds an asset library image to the canvas by dragging it onto the viewport', async () => { + await renderLoadedEditor(); + + const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' }); + const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row'); + const viewport = screen.getByLabelText('画布工作区'); + const dataTransfer = createDataTransferStub(); + + if (!sourceAssetRow) { + throw new Error('asset row should exist'); + } + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + fireEvent.dragOver(viewport, { + clientX: 520, + clientY: 300, + dataTransfer, + }); + + await waitFor(() => { + expect(screen.getByText('添加到画布')).toBeTruthy(); + }); + + fireEvent.drop(viewport, { + clientX: 520, + clientY: 300, + dataTransfer, + }); + + await waitFor(() => { + expect(screen.queryByText('添加到画布')).toBeNull(); + }); + expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy(); + expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy(); + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + imageSrc: '/creation-type-references/match3d.webp', + sourceType: 'uploaded', + }), + ); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + }); + + it('adds an asset library image to the canvas with pointer dragging', async () => { + await renderLoadedEditor(); + + const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' }); + const viewport = screen.getByLabelText('画布工作区'); + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + x: 320, + y: 80, + top: 80, + left: 320, + right: 1120, + bottom: 680, + width: 800, + height: 600, + toJSON: () => ({}), + } as DOMRect); + + dispatchPointerEvent(sourceAsset, 'pointerdown', { + button: 0, + pointerId: 81, + clientX: 160, + clientY: 220, + }); + dispatchPointerEvent(window, 'pointermove', { + pointerId: 81, + clientX: 520, + clientY: 300, + }); + + await waitFor(() => { + expect(screen.getByText('添加到画布')).toBeTruthy(); + }); + expect(screen.getAllByText('抓大鹅素材').length).toBeGreaterThan(1); + + dispatchPointerEvent(window, 'pointerup', { + pointerId: 81, + clientX: 520, + clientY: 300, + }); + + expect(screen.queryByText('添加到画布')).toBeNull(); + expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy(); + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + imageSrc: '/creation-type-references/match3d.webp', + sourceType: 'uploaded', + }), + ); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + }); + it('shows a canvas drop overlay while dragging uploaded images over the canvas', async () => { await renderLoadedEditor(); @@ -1057,6 +1704,42 @@ describe('ImageCanvasEditorView', () => { await waitFor(() => { expect(screen.getByAltText('画布图片:画布提示.png')).toBeTruthy(); }); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: '画布提示.png', + resourceId: 'resource-editor-project-default-420', + sourceAssetId: 'persisted-画布提示.png', + }), + ]), + }), + ); + }); + }); + + it('removes a dropped canvas upload when project resource persistence fails', async () => { + createEditorProjectResourceMock.mockRejectedValueOnce(new Error('unauthorized')); + await renderLoadedEditor(); + + const viewport = screen.getByLabelText('画布工作区'); + fireEvent.drop(viewport, { + clientX: 430, + clientY: 260, + dataTransfer: { + files: [new File(['image'], '未保存图片.png', { type: 'image/png' })], + types: ['Files'], + }, + }); + + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.queryByAltText('画布图片:未保存图片.png')).toBeNull(); + }); }); it('drops files into the asset panel only once without creating canvas layers', async () => { @@ -1258,6 +1941,7 @@ describe('ImageCanvasEditorView', () => { it('adds assets from the sidebar and supports zoom buttons', async () => { await renderLoadedEditor(); + const user = userEvent.setup(); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }).className, @@ -1267,7 +1951,7 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(screen.getByRole('menuitem', { name: '放大' })); expect(screen.getByRole('button', { name: '当前缩放比例 116%' })).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + await user.click(screen.getByRole('button', { name: '添加声浪素材' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy(); @@ -1311,10 +1995,42 @@ describe('ImageCanvasEditorView', () => { expect(within(panelToolbar).getByRole('button', { name: '切换小地图' })).toBeTruthy(); fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' })); - expect(screen.getByRole('menu', { name: '画布背景色菜单' })).toBeTruthy(); - fireEvent.click(screen.getByRole('menuitem', { name: '切换画布背景色为暖灰' })); + expect(screen.getByRole('dialog', { name: '画布背景设置' })).toBeTruthy(); + expect(screen.getByText('画布背景色')).toBeTruthy(); + expect(screen.getByRole('button', { name: '关闭画布背景设置' })).toBeTruthy(); - expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(243, 240, 234)'); + fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' })); + + expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)'); + + fireEvent.change(screen.getByLabelText('自定义画布背景色'), { + target: { value: '#ffffff' }, + }); + expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(255, 255, 255)'); + + const hexInput = screen.getByLabelText('画布背景十六进制颜色') as HTMLInputElement; + fireEvent.change(hexInput, { + target: { value: '#abc' }, + }); + expect(hexInput.value).toBe('AABBCC'); + expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)'); + + fireEvent.change(hexInput, { + target: { value: 'not-a-color' }, + }); + expect(hexInput.value).toBe('not-a-color'); + expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)'); + + fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' })); + expect(hexInput.value).toBe('F8FAFC'); + expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)'); + + fireEvent.click(screen.getByRole('button', { name: '关闭画布背景设置' })); + expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull(); + + fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' })); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull(); fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' })); expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); @@ -1549,7 +2265,7 @@ describe('ImageCanvasEditorView', () => { expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain( 'image-canvas-editor__generation-submit', ); - expect(screen.queryByRole('toolbar', { name: 'AI画布工具栏' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张明亮的拼图主视觉' }, @@ -1580,6 +2296,21 @@ describe('ImageCanvasEditorView', () => { }); fireEvent.click(screen.getByRole('button', { name: '打开素材' })); expect(screen.getByRole('button', { name: /添加生成图片/u })).toBeTruthy(); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: expect.stringMatching(/^生成图片/u), + sourceType: 'generated', + sourceAssetId: 'persisted-生成图片 3', + resourceId: 'resource-editor-project-default-1024', + }), + ]), + }), + ); + }); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!; const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 71d5589b..17e8059f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -31,6 +31,7 @@ import { WandSparkles, X, } from 'lucide-react'; +import JSZip from 'jszip'; import { type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, @@ -58,6 +59,7 @@ import { loadEditorAssetLibrary, loadEditorProject, loadOrCreateRecentEditorProject, + renameEditorProject, saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, @@ -79,6 +81,7 @@ import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { UnifiedModal } from '../common/UnifiedModal'; +import { useAuthUi } from '../auth/AuthUiContext'; type EditorAsset = { id: string; @@ -97,6 +100,9 @@ type EditorAsset = { taskId?: string; objectKey?: string; assetObjectId?: string; + uploadStatus?: 'uploading' | 'failed'; + uploadProgress?: number; + uploadMessage?: string; }; type CanvasLayer = { @@ -190,6 +196,17 @@ type AssetMarqueeState = { currentY: number; }; +type AssetPointerDragState = { + pointerId: number; + assetId: string; + startClientX: number; + startClientY: number; + currentClientX: number; + currentClientY: number; + active: boolean; + dropFolderId: string | null; +}; + type CanvasMarqueeState = { pointerId: number; startX: number; @@ -227,6 +244,54 @@ type CanvasContextMenuState = type UploadDropTarget = 'canvas' | 'assets' | null; +type CanvasAssetExportImage = { + key: string; + file: string; + layer: CanvasLayer; + blob?: Blob; + error?: string; +}; + +type CanvasAssetExportLayerMetadata = { + layerId: string; + title: string; + file: string | null; + width: number; + height: number; + canvas: { + x: number; + y: number; + width: number; + height: number; + zIndex: number; + hidden: boolean; + locked: boolean; + flipX: boolean; + flipY: boolean; + groupId: string | null; + }; + sourceType: CanvasLayer['sourceType']; + prompt: string | null; + actualPrompt: string | null; + model: string | null; + provider: string | null; + taskId: string | null; + exportError?: string; +}; + +type CanvasAssetExportMetadata = { + projectId: string | null; + projectTitle: string; + exportedAt: string; + layers: CanvasAssetExportLayerMetadata[]; + failedImages: Array<{ + key: string; + title: string; + src: string; + error: string; + }>; +}; + type DragState = | { kind: 'pan'; @@ -288,11 +353,14 @@ const CONTEXT_MENU_SIZE = { }; const CONTEXT_MENU_VIEWPORT_MARGIN = 8; const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; +const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc'; const CANVAS_BACKGROUND_OPTIONS = [ + { label: '默认浅灰', value: DEFAULT_CANVAS_BACKGROUND_COLOR }, + { label: '黑色', value: '#000000' }, { label: '白色', value: '#ffffff' }, - { label: '浅灰', value: '#f8fafc' }, - { label: '暖灰', value: '#f3f0ea' }, - { label: '冷蓝', value: '#eef6ff' }, + { label: '绿色', value: '#00e515' }, + { label: '紫色', value: '#a66cf2' }, + { label: '淡紫', value: '#d6c5f4' }, ]; function clamp(value: number, min: number, max: number) { @@ -303,6 +371,127 @@ function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } +function normalizeHexColorInput(value: string) { + const trimmedInput = value.trim().toLowerCase(); + const trimmed = trimmedInput.startsWith('#') ? trimmedInput : `#${trimmedInput}`; + const shortHexMatch = /^#([0-9a-f]{3})$/.exec(trimmed); + if (shortHexMatch?.[1]) { + const [red, green, blue] = shortHexMatch[1].split(''); + return `#${red}${red}${green}${green}${blue}${blue}`; + } + if (/^#[0-9a-f]{6}$/.test(trimmed)) { + return trimmed; + } + return null; +} + +function sanitizeExportFilePart(value: string, fallback: string) { + const sanitized = value + .trim() + .replace(/[\\/:*?"<>|]/gu, ' ') + .replace(/\s+/gu, ' ') + .slice(0, 48) + .trim(); + return sanitized || fallback; +} + +function formatExportDate(date: Date) { + const year = String(date.getFullYear()).padStart(4, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +function getLayerExportKey(layer: CanvasLayer) { + return layer.assetObjectId || layer.objectKey || layer.sourceAssetId || layer.src; +} + +function getImageExtensionFromTypeOrSrc(type: string | undefined, src: string) { + const normalizedType = type?.toLowerCase() ?? ''; + if (normalizedType.includes('jpeg') || normalizedType.includes('jpg')) { + return 'jpg'; + } + if (normalizedType.includes('webp')) { + return 'webp'; + } + if (normalizedType.includes('gif')) { + return 'gif'; + } + const srcExtension = /\.([a-z0-9]{2,5})(?:[?#].*)?$/iu.exec(src)?.[1]?.toLowerCase(); + if (srcExtension && ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(srcExtension)) { + return srcExtension === 'jpeg' ? 'jpg' : srcExtension; + } + return 'png'; +} + +function dataUrlToBlob(dataUrl: string) { + const match = /^data:([^;,]+)?(;base64)?,(.*)$/u.exec(dataUrl); + if (!match) { + throw new Error('无法解析 data:image 素材'); + } + const mimeType = match[1] || 'application/octet-stream'; + const isBase64 = Boolean(match[2]); + const payload = match[3] ?? ''; + const binary = isBase64 ? window.atob(payload) : decodeURIComponent(payload); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new Blob([bytes], { type: mimeType }); +} + +async function blobToUint8Array(blob: Blob) { + const arrayBuffer = + typeof blob.arrayBuffer === 'function' + ? await blob.arrayBuffer() + : await new Response(blob).arrayBuffer(); + return new Uint8Array(arrayBuffer); +} + +async function readLayerImageBlob(layer: CanvasLayer) { + if (layer.src.startsWith('data:image/')) { + return dataUrlToBlob(layer.src); + } + const response = await fetch(layer.src); + if (!response.ok) { + throw new Error(`图片读取失败:${response.status}`); + } + return response.blob(); +} + +function buildLayerExportMetadata( + layer: CanvasLayer, + file: string | null, + exportError?: string, +): CanvasAssetExportLayerMetadata { + return { + layerId: layer.id, + title: layer.title, + file, + width: layer.originalWidth, + height: layer.originalHeight, + canvas: { + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + zIndex: layer.zIndex, + hidden: Boolean(layer.hidden), + locked: Boolean(layer.locked), + flipX: Boolean(layer.flipX), + flipY: Boolean(layer.flipY), + groupId: layer.groupId ?? null, + }, + sourceType: layer.sourceType, + prompt: layer.prompt ?? null, + actualPrompt: layer.actualPrompt ?? null, + model: layer.model ?? null, + provider: layer.provider ?? null, + taskId: layer.taskId ?? null, + ...(exportError ? { exportError } : {}), + }; +} + function triggerPlaceholderAction(label: string) { window.alert(`${label}功能建设中`); } @@ -518,15 +707,23 @@ function resolveContextMenuPosition( } function getDraggedAssetId(dataTransfer: DataTransfer) { - if ( - !dataTransfer.types.includes(ASSET_DRAG_MIME_TYPE) || - typeof dataTransfer.getData !== 'function' - ) { + if (typeof dataTransfer.getData !== 'function') { + return ''; + } + const draggedAssetId = dataTransfer.getData(ASSET_DRAG_MIME_TYPE); + if (draggedAssetId) { + return draggedAssetId; + } + if (!hasDataTransferType(dataTransfer, ASSET_DRAG_MIME_TYPE)) { return ''; } return dataTransfer.getData(ASSET_DRAG_MIME_TYPE); } +function hasDataTransferType(dataTransfer: DataTransfer, type: string) { + return Array.from(dataTransfer.types).includes(type); +} + function isCanvasSourceType(value: unknown): value is CanvasLayer['sourceType'] { return value === 'uploaded' || value === 'generated' || value === 'mock_generated'; } @@ -544,11 +741,8 @@ function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { ); } -function isLayerValidForAssetLibrary(layer: CanvasLayer, assets: EditorAsset[]) { - if (isGeneratedLayer(layer)) { - return true; - } - return assets.some((asset) => isLayerLinkedToAsset(layer, asset)); +function isLayerValidForAssetLibrary(layer: CanvasLayer, _assets: EditorAsset[]) { + return layer.src.trim().length > 0; } function getLayerBounds(targetLayers: CanvasLayer[]) { @@ -643,6 +837,7 @@ function resolveImageGenerationErrorMessage(error: unknown) { } export function ImageCanvasEditorView() { + const authUi = useAuthUi(); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const uploadInputRef = useRef(null); @@ -654,6 +849,15 @@ export function ImageCanvasEditorView() { const undoStackRef = useRef([]); const redoStackRef = useRef([]); const layersRef = useRef([]); + const assetsRef = useRef([]); + const assetPointerDragRef = useRef(null); + const addAssetLayerRef = useRef< + (asset: EditorAsset, position?: { x: number; y: number }) => void + >(() => {}); + const moveAssetToFolderRef = useRef< + (assetId: string, folderId: string) => void + >(() => {}); + const suppressAssetClickRef = useRef(false); const viewportRef = useRef({ x: -260, y: 70, @@ -665,8 +869,17 @@ export function ImageCanvasEditorView() { const dragHistoryCapturedRef = useRef(false); const [projectId, setProjectId] = useState(null); const [projectTitle, setProjectTitle] = useState('未命名画布'); + const [projectRenameValue, setProjectRenameValue] = useState('未命名画布'); + const [isRenamingProject, setIsRenamingProject] = useState(false); + const [isProjectRenameSaving, setIsProjectRenameSaving] = useState(false); + const [projectRenameError, setProjectRenameError] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); const [isAssetLibraryReady, setIsAssetLibraryReady] = useState(false); + const [assetExportStatus, setAssetExportStatus] = useState<{ + tone: 'info' | 'success' | 'error'; + message: string; + } | null>(null); + const [isExportingAssets, setIsExportingAssets] = useState(false); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [viewport, setViewport] = useState({ @@ -708,10 +921,13 @@ export function ImageCanvasEditorView() { const [isPanning, setIsPanning] = useState(false); const [snapGuide, setSnapGuide] = useState(null); const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); - const [isBackgroundMenuOpen, setIsBackgroundMenuOpen] = useState(false); + const [isBackgroundPanelOpen, setIsBackgroundPanelOpen] = useState(false); const [isMinimapOpen, setIsMinimapOpen] = useState(true); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( - CANVAS_BACKGROUND_OPTIONS[1]?.value ?? '#f8fafc', + DEFAULT_CANVAS_BACKGROUND_COLOR, + ); + const [backgroundHexValue, setBackgroundHexValue] = useState( + DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [metadataLayer, setMetadataLayer] = useState(null); const [generateDialog, setGenerateDialog] = @@ -723,8 +939,22 @@ export function ImageCanvasEditorView() { useState(null); const [uploadDropTarget, setUploadDropTarget] = useState(null); + const [assetMoveDropFolderId, setAssetMoveDropFolderId] = + useState(null); + const [isAssetMoveDropHeaderPinned, setIsAssetMoveDropHeaderPinned] = + useState(false); + const [assetPointerDrag, setAssetPointerDrag] = + useState(null); const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; + const applyCanvasBackgroundColor = useCallback((value: string) => { + const normalizedColor = normalizeHexColorInput(value); + if (!normalizedColor) { + return; + } + setCanvasBackgroundColor(normalizedColor); + setBackgroundHexValue(normalizedColor); + }, []); const selectedLayer = useMemo( () => layers.find((layer) => layer.id === selectedLayerId) ?? null, [layers, selectedLayerId], @@ -781,11 +1011,22 @@ export function ImageCanvasEditorView() { const allSelectableAssetsSelected = selectableAssets.length > 0 && selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); + const assetMoveDropFolder = useMemo( + () => + assetMoveDropFolderId + ? groupedAssets.find((folder) => folder.id === assetMoveDropFolderId) ?? null + : null, + [assetMoveDropFolderId, groupedAssets], + ); useEffect(() => { layersRef.current = layers; }, [layers]); + useEffect(() => { + assetsRef.current = assets; + }, [assets]); + useEffect(() => { viewportRef.current = viewport; }, [viewport]); @@ -976,7 +1217,9 @@ export function ImageCanvasEditorView() { return; } setProjectId(project.projectId); - setProjectTitle(project.title?.trim() || '未命名画布'); + const nextProjectTitle = project.title?.trim() || '未命名画布'; + setProjectTitle(nextProjectTitle); + setProjectRenameValue(nextProjectTitle); setViewport(project.viewport); const hydratedLayers = project.layers .map(hydrateLayer) @@ -989,8 +1232,11 @@ export function ImageCanvasEditorView() { setHistoryVersion((version) => version + 1); setIsProjectReady(true); }) - .catch(() => { + .catch((error: unknown) => { if (!cancelled) { + if (error instanceof ApiClientError && error.status === 401) { + authUi?.openLoginModal(() => window.location.reload()); + } setIsProjectReady(false); } }); @@ -998,7 +1244,7 @@ export function ImageCanvasEditorView() { return () => { cancelled = true; }; - }, []); + }, [authUi]); useEffect(() => { let cancelled = false; @@ -1071,7 +1317,7 @@ export function ImageCanvasEditorView() { if (event.key === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); - setIsBackgroundMenuOpen(false); + setIsBackgroundPanelOpen(false); setContextMenu(null); setGenerateDialog((currentDialog) => currentDialog?.status === 'generating' @@ -1128,6 +1374,121 @@ export function ImageCanvasEditorView() { return () => window.removeEventListener('pointerdown', closeContextMenu); }, [contextMenu]); + useEffect(() => { + const resolveCanvasPoint = (clientX: number, clientY: number) => { + const rect = canvasViewportRef.current?.getBoundingClientRect(); + if ( + !rect || + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + const resolveAssetFolderId = (clientX: number, clientY: number) => { + const listElement = assetListRef.current; + if (!listElement) { + return null; + } + const listRect = listElement.getBoundingClientRect(); + if ( + clientX < listRect.left || + clientX > listRect.right || + clientY < listRect.top || + clientY > listRect.bottom + ) { + return null; + } + const folderElements = [ + ...listElement.querySelectorAll('[data-asset-folder-id]'), + ]; + const matchedFolder = folderElements.find((element) => { + const rect = element.getBoundingClientRect(); + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ); + }); + return matchedFolder?.dataset.assetFolderId ?? null; + }; + + const updatePointerDrag = (event: PointerEvent) => { + const currentDrag = assetPointerDragRef.current; + if (!currentDrag || currentDrag.pointerId !== event.pointerId) { + return; + } + const distance = Math.hypot( + event.clientX - currentDrag.startClientX, + event.clientY - currentDrag.startClientY, + ); + const nextDrag = { + ...currentDrag, + currentClientX: event.clientX, + currentClientY: event.clientY, + active: currentDrag.active || distance > 4, + dropFolderId: resolveAssetFolderId(event.clientX, event.clientY), + }; + assetPointerDragRef.current = nextDrag; + setAssetPointerDrag(nextDrag); + const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY); + setUploadDropTarget(canvasPoint ? 'canvas' : null); + updateAssetMoveDropFolder(nextDrag.dropFolderId); + }; + + const finishPointerDrag = (event: PointerEvent) => { + const currentDrag = assetPointerDragRef.current; + if (!currentDrag || currentDrag.pointerId !== event.pointerId) { + return; + } + const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY); + const dropFolderId = + resolveAssetFolderId(event.clientX, event.clientY) ?? + currentDrag.dropFolderId; + const draggedAsset = assetsRef.current.find( + (asset) => asset.id === currentDrag.assetId, + ); + assetPointerDragRef.current = null; + setAssetPointerDrag(null); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + if (!currentDrag.active || !draggedAsset) { + return; + } + if (dropFolderId && dropFolderId !== draggedAsset.folderId) { + suppressAssetClickRef.current = true; + window.setTimeout(() => { + suppressAssetClickRef.current = false; + }, 0); + moveAssetToFolderRef.current(draggedAsset.id, dropFolderId); + return; + } + if (canvasPoint) { + suppressAssetClickRef.current = true; + window.setTimeout(() => { + suppressAssetClickRef.current = false; + }, 0); + addAssetLayerRef.current(draggedAsset, canvasPoint); + } + }; + + window.addEventListener('pointermove', updatePointerDrag); + window.addEventListener('pointerup', finishPointerDrag); + window.addEventListener('pointercancel', finishPointerDrag); + return () => { + window.removeEventListener('pointermove', updatePointerDrag); + window.removeEventListener('pointerup', finishPointerDrag); + window.removeEventListener('pointercancel', finishPointerDrag); + }; + }, []); + useEffect(() => { const blockBrowserZoom = (event: WheelEvent) => { const editorElement = editorRootRef.current; @@ -1449,6 +1810,144 @@ export function ImageCanvasEditorView() { ); }; + const exportCanvasAssets = async () => { + if (isExportingAssets) { + return; + } + const exportableLayers = layers + .filter((layer) => isLayerValidForAssetLibrary(layer, assets)) + .sort((left, right) => left.zIndex - right.zIndex); + if (!exportableLayers.length) { + setAssetExportStatus({ + tone: 'info', + message: '当前画布没有可导出的素材', + }); + return; + } + + setIsExportingAssets(true); + setAssetExportStatus(null); + + try { + const exportedAt = new Date(); + const projectName = sanitizeExportFilePart(projectTitle, '未命名画布'); + const rootFolderName = `${projectName}-画布素材`; + const zip = new JSZip(); + const rootFolder = zip.folder(rootFolderName) ?? zip; + const imagesFolder = rootFolder.folder('images') ?? rootFolder; + const imageByKey = new Map(); + const usedFileNames = new Map(); + + for (const layer of exportableLayers) { + const key = getLayerExportKey(layer); + if (imageByKey.has(key)) { + continue; + } + const index = imageByKey.size + 1; + const safeTitle = sanitizeExportFilePart(layer.title, '画布素材'); + const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`; + const duplicateCount = usedFileNames.get(baseFileName) ?? 0; + usedFileNames.set(baseFileName, duplicateCount + 1); + const indexedFileName = + duplicateCount > 0 ? `${baseFileName}-${duplicateCount + 1}` : baseFileName; + + try { + const blob = await readLayerImageBlob(layer); + const extension = getImageExtensionFromTypeOrSrc(blob.type, layer.src); + const file = `images/${indexedFileName}.${extension}`; + imageByKey.set(key, { + key, + file, + layer, + blob, + }); + imagesFolder.file(`${indexedFileName}.${extension}`, await blobToUint8Array(blob)); + } catch (error) { + imageByKey.set(key, { + key, + file: `images/${indexedFileName}.png`, + layer, + error: error instanceof Error ? error.message : '图片读取失败', + }); + } + } + + const failedImages = [...imageByKey.values()].filter((image) => image.error); + const successfulImages = [...imageByKey.values()].filter((image) => image.blob); + if (!successfulImages.length) { + setAssetExportStatus({ + tone: 'error', + message: '素材导出失败', + }); + return; + } + + const metadata: CanvasAssetExportMetadata = { + projectId, + projectTitle, + exportedAt: exportedAt.toISOString(), + layers: exportableLayers.map((layer) => { + const image = imageByKey.get(getLayerExportKey(layer)); + return buildLayerExportMetadata( + layer, + image?.blob ? image.file : null, + image?.error, + ); + }), + failedImages: failedImages.map((image) => ({ + key: image.key, + title: image.layer.title, + src: image.layer.src, + error: image.error ?? '图片读取失败', + })), + }; + const manifest = [ + `项目:${projectTitle}`, + `导出时间:${metadata.exportedAt}`, + `素材数量:${successfulImages.length}`, + `图层数量:${exportableLayers.length}`, + failedImages.length ? `失败素材数量:${failedImages.length}` : null, + ] + .filter(Boolean) + .join('\n'); + + rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2)); + rootFolder.file('manifest.txt', manifest); + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + if ( + typeof URL.createObjectURL !== 'function' || + typeof URL.revokeObjectURL !== 'function' + ) { + setAssetExportStatus({ + tone: 'error', + message: '当前浏览器不支持素材下载', + }); + return; + } + + const downloadUrl = URL.createObjectURL(zipBlob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(downloadUrl); + setAssetExportStatus({ + tone: failedImages.length ? 'error' : 'success', + message: failedImages.length ? '部分素材未能导出' : '画布素材已导出', + }); + } catch { + setAssetExportStatus({ + tone: 'error', + message: '素材导出失败', + }); + } finally { + setIsExportingAssets(false); + } + }; + const exportContextLayer = () => { const targetIds = getContextTargetLayerIds(); const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); @@ -1464,6 +1963,47 @@ export function ImageCanvasEditorView() { setContextMenu(null); }; + const startProjectRename = () => { + setProjectRenameValue(projectTitle); + setProjectRenameError(null); + setIsRenamingProject(true); + }; + + const cancelProjectRename = () => { + setProjectRenameValue(projectTitle); + setProjectRenameError(null); + setIsRenamingProject(false); + }; + + const submitProjectRename = () => { + const nextTitle = projectRenameValue.trim(); + if (!nextTitle) { + setProjectRenameError('项目名称不能为空'); + return; + } + if (!projectId || nextTitle === projectTitle) { + setProjectRenameValue(projectTitle); + setProjectRenameError(null); + setIsRenamingProject(false); + return; + } + setIsProjectRenameSaving(true); + setProjectRenameError(null); + renameEditorProject(projectId, nextTitle) + .then((project) => { + const savedTitle = project.title?.trim() || nextTitle; + setProjectTitle(savedTitle); + setProjectRenameValue(savedTitle); + setIsRenamingProject(false); + }) + .catch((error: unknown) => { + setProjectRenameError( + error instanceof Error ? error.message : '重命名项目失败', + ); + }) + .finally(() => setIsProjectRenameSaving(false)); + }; + const deleteContextLayers = () => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { @@ -1481,14 +2021,60 @@ export function ImageCanvasEditorView() { setContextMenu(null); }; - const createProjectResourceForLayer = ( - layer: CanvasLayer, - options: { onCreated?: (resourceId: string) => void } = {}, - ) => { - if (!projectId) { + const updateAssetMoveDropFolder = (folderId: string | null) => { + setAssetMoveDropFolderId(folderId); + if (!folderId) { + setIsAssetMoveDropHeaderPinned(false); return; } - createEditorProjectResource(projectId, { + const listElement = assetListRef.current; + const headerElement = + [...(listElement?.querySelectorAll( + '[data-asset-folder-header-id]', + ) ?? [])].find( + (element) => element.dataset.assetFolderHeaderId === folderId, + ) ?? null; + if (!listElement || !headerElement) { + setIsAssetMoveDropHeaderPinned(false); + return; + } + const listRect = listElement.getBoundingClientRect(); + const headerRect = headerElement.getBoundingClientRect(); + setIsAssetMoveDropHeaderPinned( + headerRect.top < listRect.top || headerRect.bottom > listRect.bottom, + ); + }; + + const saveProjectLayoutNow = useCallback( + (nextLayers: CanvasLayer[], nextViewport = viewportRef.current) => { + if (!projectId || !isProjectReady) { + return Promise.resolve(); + } + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + return saveEditorProjectLayout(projectId, { + viewport: nextViewport, + layers: nextLayers.map(serializeLayer), + }); + }, + [isProjectReady, projectId], + ); + + const createProjectResourceForLayer = ( + layer: CanvasLayer, + options: { + onCreated?: (resourceId: string) => void; + onFailed?: () => void; + saveLayout?: boolean; + } = {}, + ) => { + if (!projectId) { + options.onFailed?.(); + return Promise.resolve(null); + } + return createEditorProjectResource(projectId, { imageSrc: layer.src, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, @@ -1504,25 +2090,33 @@ export function ImageCanvasEditorView() { }) .then((resource) => { options.onCreated?.(resource.resourceId); - setLayers((currentLayers) => - currentLayers.map((currentLayer) => + setLayers((currentLayers) => { + const nextLayers = currentLayers.map((currentLayer) => currentLayer.id === layer.id ? { ...currentLayer, resourceId: resource.resourceId, } : currentLayer, - ), - ); + ); + if (options.saveLayout) { + void saveProjectLayoutNow(nextLayers).catch(() => {}); + } + return nextLayers; + }); + return resource; }) - .catch(() => {}); + .catch(() => { + options.onFailed?.(); + return null; + }); }; const createAssetFromGeneratedLayer = (layer: CanvasLayer) => { const targetFolder = assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; if (!targetFolder) { - return; + return Promise.resolve(null); } const temporaryAssetId = `generated-asset-${layer.id}`; const temporaryAsset: EditorAsset = { @@ -1556,7 +2150,7 @@ export function ImageCanvasEditorView() { ), ); - createEditorAsset({ + return createEditorAsset({ folderId: targetFolder.id, label: layer.title, imageSrc: layer.src, @@ -1596,8 +2190,8 @@ export function ImageCanvasEditorView() { : currentAsset, ), ); - setLayers((currentLayers) => - currentLayers.map((currentLayer) => + setLayers((currentLayers) => { + const nextLayers = currentLayers.map((currentLayer) => currentLayer.id === layer.id ? { ...currentLayer, @@ -1606,13 +2200,17 @@ export function ImageCanvasEditorView() { assetObjectId: asset.assetObjectId ?? currentLayer.assetObjectId, } : currentLayer, - ), - ); + ); + void saveProjectLayoutNow(nextLayers).catch(() => {}); + return nextLayers; + }); + return asset; }) .catch(() => { setAssets((currentAssets) => currentAssets.filter((asset) => asset.id !== temporaryAssetId), ); + return null; }); }; @@ -1629,11 +2227,29 @@ export function ImageCanvasEditorView() { }, ); captureCanvasHistory(); - setLayers((currentLayers) => [...currentLayers, nextLayer]); + setLayers((currentLayers) => { + const nextLayers = [...currentLayers, nextLayer]; + void saveProjectLayoutNow(nextLayers).catch(() => {}); + return nextLayers; + }); selectSingleLayer(nextLayer.id); setHoveredLayerId(null); - createProjectResourceForLayer(nextLayer); + createProjectResourceForLayer(nextLayer, { + onFailed: () => { + setLayers((currentLayers) => + currentLayers.filter((currentLayer) => currentLayer.id !== nextLayer.id), + ); + setSelectedLayerIds((currentIds) => + currentIds.filter((layerId) => layerId !== nextLayer.id), + ); + setSelectedLayerId((currentId) => + currentId === nextLayer.id ? null : currentId, + ); + }, + saveLayout: true, + }); }; + addAssetLayerRef.current = addAssetLayer; const startRenamingAsset = (asset: EditorAsset) => { setRenamingAsset({ @@ -1705,6 +2321,7 @@ export function ImageCanvasEditorView() { }); } }; + moveAssetToFolderRef.current = moveAssetToFolder; const toggleAssetFolder = (folderId: string) => { const nextFolder = assetFolders.find((folder) => folder.id === folderId); @@ -2066,6 +2683,27 @@ export function ImageCanvasEditorView() { reader.readAsDataURL(file); }); + const updateUploadingAsset = ( + assetId: string, + patch: Partial< + Pick< + EditorAsset, + 'uploadProgress' | 'uploadStatus' | 'uploadMessage' | 'src' | 'width' | 'height' + > + >, + ) => { + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.id === assetId + ? { + ...asset, + ...patch, + } + : asset, + ), + ); + }; + const addUploadedLayer = async ( file: File, options: { @@ -2082,13 +2720,55 @@ export function ImageCanvasEditorView() { const uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1; layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex); - const imageSrc = await readImageFileAsDataUrl(file); const fallbackWidth = 420; const fallbackHeight = 315; const uploadFolderId = assetFolders.some((folder) => folder.id === (options.folderId ?? activeUploadFolderId)) ? (options.folderId ?? activeUploadFolderId) : 'project'; + 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); + updateUploadingAsset(uploadedAsset.id, { + src: imageSrc, + uploadProgress: 42, + uploadMessage: '读取图片', + }); + } catch { + updateUploadingAsset(uploadedAsset.id, { + uploadStatus: 'failed', + uploadProgress: 100, + uploadMessage: '读取失败', + }); + return; + } + const screenPoint = options.canvasPoint ?? { x: canvasSize.width / 2, y: canvasSize.height / 2, @@ -2118,37 +2798,19 @@ export function ImageCanvasEditorView() { zIndex: uploadIndex + 10, sourceType: 'uploaded', }; - const uploadedAsset: EditorAsset = { - id: `upload-${uploadIndex}`, - label: file.name || '上传图片', - src: imageSrc, - width: fallbackWidth, - height: fallbackHeight, - folderId: uploadFolderId, - sourceKind: 'uploaded', - sourceType: 'uploaded', - persisted: false, - }; if (options.addToCanvas) { setLayers((currentLayers) => [...currentLayers, nextLayer]); } - setAssets((currentAssets) => [...currentAssets, uploadedAsset]); - setAssetFolders((currentFolders) => - currentFolders.map((folder) => - folder.id === uploadFolderId - ? { - ...folder, - collapsed: false, - } - : folder, - ), - ); if (options.addToCanvas) { selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); } + updateUploadingAsset(uploadedAsset.id, { + uploadProgress: 68, + uploadMessage: '上传中', + }); createEditorAsset({ folderId: uploadFolderId, label: uploadedAsset.label, @@ -2172,15 +2834,53 @@ export function ImageCanvasEditorView() { objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, persisted: true, + uploadStatus: undefined, + uploadProgress: undefined, + uploadMessage: undefined, } : currentAsset, ), ); + if (options.addToCanvas) { + setLayers((currentLayers) => { + const nextLayers = currentLayers.map((currentLayer) => + currentLayer.id === nextLayer.id + ? { + ...currentLayer, + sourceAssetId: asset.assetId, + objectKey: asset.objectKey ?? currentLayer.objectKey, + assetObjectId: asset.assetObjectId ?? currentLayer.assetObjectId, + } + : currentLayer, + ); + void saveProjectLayoutNow(nextLayers).catch(() => {}); + return nextLayers; + }); + } }) - .catch(() => {}); + .catch(() => { + updateUploadingAsset(uploadedAsset.id, { + uploadStatus: 'failed', + uploadProgress: 100, + uploadMessage: '上传失败', + }); + }); if (options.addToCanvas) { - createProjectResourceForLayer(nextLayer); + createProjectResourceForLayer(nextLayer, { + onFailed: () => { + setLayers((currentLayers) => + currentLayers.filter((currentLayer) => currentLayer.id !== nextLayer.id), + ); + setSelectedLayerIds((currentIds) => + currentIds.filter((layerId) => layerId !== nextLayer.id), + ); + setSelectedLayerId((currentId) => + currentId === nextLayer.id ? null : currentId, + ); + }, + saveLayout: true, + }); } if (imageSrc) { @@ -2382,8 +3082,30 @@ export function ImageCanvasEditorView() { if (options.sourceLayer) { fitLayers([options.sourceLayer, nextLayer]); } - createAssetFromGeneratedLayer(nextLayer); - createProjectResourceForLayer(nextLayer); + void createAssetFromGeneratedLayer(nextLayer); + void createProjectResourceForLayer(nextLayer, { + onFailed: () => { + setLayers((currentLayers) => + currentLayers.filter((currentLayer) => currentLayer.id !== nextLayer.id), + ); + setSelectedLayerIds((currentIds) => + currentIds.filter((layerId) => layerId !== nextLayer.id), + ); + setSelectedLayerId((currentId) => + currentId === nextLayer.id ? null : currentId, + ); + setGenerateDialog((currentDialog) => + currentDialog?.generatedLayerId === nextLayer.id + ? { + ...currentDialog, + generatedLayerId: undefined, + errorMessage: '图片已生成,但保存到画布失败,请稍后重试。', + } + : currentDialog, + ); + }, + saveLayout: true, + }); }; const submitImageGeneration = async (dialog: GenerateDialogState) => { @@ -2577,7 +3299,13 @@ export function ImageCanvasEditorView() { }; const handleCanvasDragOver = (event: ReactDragEvent) => { - if (event.dataTransfer.types.includes('Files')) { + if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { + event.preventDefault(); + setUploadDropTarget('canvas'); + event.dataTransfer.dropEffect = 'copy'; + return; + } + if (hasDataTransferType(event.dataTransfer, 'Files')) { event.preventDefault(); setUploadDropTarget('canvas'); event.dataTransfer.dropEffect = 'copy'; @@ -2592,23 +3320,40 @@ export function ImageCanvasEditorView() { } }; + const getCanvasDropPoint = (event: ReactDragEvent) => { + const rect = canvasViewportRef.current?.getBoundingClientRect(); + const fallbackPoint = { + x: canvasSize.width / 2, + y: canvasSize.height / 2, + }; + if (!rect || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) { + return fallbackPoint; + } + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + }; + const handleCanvasDrop = (event: ReactDragEvent) => { + const draggedAssetId = getDraggedAssetId(event.dataTransfer); + if (draggedAssetId) { + const draggedAsset = assets.find((asset) => asset.id === draggedAssetId); + if (!draggedAsset) { + return; + } + event.preventDefault(); + setUploadDropTarget(null); + addAssetLayer(draggedAsset, getCanvasDropPoint(event)); + return; + } const files = event.dataTransfer.files; if (!files.length) { return; } event.preventDefault(); setUploadDropTarget(null); - const rect = canvasViewportRef.current?.getBoundingClientRect(); - const canvasPoint = rect - ? { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - } - : { - x: canvasSize.width / 2, - y: canvasSize.height / 2, - }; + const canvasPoint = getCanvasDropPoint(event); const defaultFolder = assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; addUploadedFiles(files, { @@ -3031,6 +3776,18 @@ export function ImageCanvasEditorView() { event.currentTarget.value = ''; }} /> + {assetPointerDrag?.active ? ( + + ) : null} {activeSidebarPanel ? (