From f993651b5cbcc89a745a063ddfc6c6361fb54f2f Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 16 Jun 2026 23:31:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=92=8C=E8=83=8C=E6=99=AF=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复编辑器接口 401 / 403 触发登录弹窗 补齐未登录上传登录后续传和上传失败提示 恢复画布背景设置面板并支持预设色、自定义颜色、HEX 输入和默认恢复 补充编辑器回归测试和 TRACKING 验证记录 --- TRACKING.md | 1 + .../ImageCanvasEditorView.test.tsx | 1134 +++++++- .../image-editor/ImageCanvasEditorView.tsx | 2381 +++++++++++++++-- src/index.css | 409 ++- .../image-editor/editorProjectClient.test.ts | 45 - .../image-editor/editorProjectClient.ts | 23 - 6 files changed, 3642 insertions(+), 351 deletions(-) diff --git a/TRACKING.md b/TRACKING.md index 1f352b27..1f5c9e0e 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -108,3 +108,4 @@ - 2026-06-14 组件复用修正:编辑器侧栏素材和图层缩略图通过 `SidebarMediaItem` 改为复用 `PlatformMediaFrame`,删除缩略图内部图片填充的重复 CSS,统一媒体预览框和 fallback 结构;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformMediaFrame.test.tsx`、`npm run typecheck`。 - 2026-06-14 组件复用修正:画布图片 hover 尺寸标签改为复用 `PlatformPillBadge tone="lightOverlay"`,局部 CSS 只保留定位和深色覆盖,不再重复维护 badge 的圆角、字号和基础排版;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformPillBadge.test.tsx`、`npm run typecheck`。 - 2026-06-14 组件复用修正:生成跟随框的关闭按钮改为复用 `PlatformIconButton variant="surfaceFloating"`,编辑器薄包装 `EditorIconButton` 增加 variant 透传,删除局部关闭按钮基础 chrome;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformIconButton.test.tsx`、`npm run typecheck`。 +- 2026-06-16 编辑器回归修正:工程 / 素材 / 上传等编辑器请求恢复全局 401 / 403 登录弹窗;未登录上传会先弹登录并在登录后续传;画布背景入口恢复为 `画布背景设置` 面板,支持预设色、自定义颜色、HEX 输入、非法值不应用、恢复默认和 Escape 关闭。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器 smoke:`http://127.0.0.1:10006/editor/canvas` 未登录打开 `账号入口`,登录后上传素材成功,背景面板打开后点击“暖灰”使画布背景变为 `rgb(243, 240, 234)`。 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 10acc77c..f604082b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -9,9 +9,12 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import JSZip from 'jszip'; +import type { ContextType } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditorView } from './ImageCanvasEditorView'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); @@ -21,14 +24,135 @@ const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); +const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn()); 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()); +type AuthValue = NonNullable>; + +function createAuthValue(overrides: Partial = {}): AuthValue { + return { + user: null, + canAccessProtectedData: false, + openLoginModal: vi.fn(), + requireAuth: vi.fn((action: () => void) => action()), + openSettingsModal: vi.fn(), + openAccountModal: vi.fn(), + setCurrentUser: vi.fn(), + logout: vi.fn(), + musicVolume: 0.5, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + ...overrides, + }; +} + +const defaultEditorProjectResources = [ + { + resourceId: 'resource-puzzle', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + }, + { + resourceId: 'resource-big-fish', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/big-fish.webp', + width: 720, + height: 405, + sourceType: 'uploaded', + }, +]; + +const defaultEditorProjectLayers = [ + { + layerId: 'layer-puzzle', + resourceId: 'resource-puzzle', + title: '拼图素材', + x: 470, + y: 300, + width: 640, + height: 640, + originalWidth: 640, + originalHeight: 640, + zIndex: 1, + sourceType: 'uploaded', + }, + { + layerId: 'layer-big-fish', + resourceId: 'resource-big-fish', + title: '大鱼素材', + x: 930, + y: 360, + width: 720, + height: 405, + originalWidth: 720, + originalHeight: 405, + zIndex: 2, + sourceType: 'uploaded', + }, +]; + +const defaultEditorAssetLibraryAssets = [ + { + assetId: 'asset-puzzle', + folderId: 'project', + label: '拼图素材', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + }, + { + assetId: 'asset-match3d', + folderId: 'project', + label: '抓大鹅素材', + imageSrc: '/creation-type-references/match3d.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + }, + { + assetId: 'asset-big-fish', + folderId: 'project', + label: '大鱼素材', + imageSrc: '/creation-type-references/big-fish.webp', + width: 720, + height: 405, + sourceType: 'uploaded', + }, + { + assetId: 'asset-bark-battle', + folderId: 'project', + label: '声浪素材', + imageSrc: '/creation-type-references/bark-battle.webp', + width: 640, + height: 900, + sourceType: 'uploaded', + }, + { + assetId: 'asset-visual-novel', + folderId: 'project', + label: '视觉小说素材', + imageSrc: '/creation-type-references/visual-novel.webp', + width: 720, + height: 405, + sourceType: 'uploaded', + }, +]; + vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') @@ -47,7 +171,9 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { loadEditorAssetLibrary: loadEditorAssetLibraryMock, loadEditorProject: loadEditorProjectMock, loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, + renameEditorProject: renameEditorProjectMock, saveEditorProjectLayout: saveEditorProjectLayoutMock, + updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, }; }); @@ -66,17 +192,66 @@ function dispatchPointerEvent( fireEvent(target, event); } +function immediateAsync(value: T) { + return { + then(onFulfilled: (value: T) => unknown) { + onFulfilled(value); + return { + catch() {}, + }; + }, + }; +} + +function createDataTransferStub() { + const store = new Map(); + return { + files: [], + types: [] as string[], + dropEffect: 'none', + effectAllowed: 'all', + setData(type: string, value: string) { + store.set(type, value); + if (!this.types.includes(type)) { + this.types.push(type); + } + }, + getData(type: string) { + return store.get(type) ?? ''; + }, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +async function readZipText(zip: JSZip, path: string) { + const file = zip.file(path); + expect(file).toBeTruthy(); + return file!.async('string'); +} + describe('ImageCanvasEditorView', () => { beforeEach(() => { - loadOrCreateRecentEditorProjectMock.mockResolvedValue({ + loadOrCreateRecentEditorProjectMock.mockImplementation(() => + immediateAsync({ projectId: 'editor-project-default', title: '默认项目', viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], + layers: defaultEditorProjectLayers, + resources: defaultEditorProjectResources, updatedAt: '2026-06-12T00:00:00.000Z', - }); - loadEditorAssetLibraryMock.mockResolvedValue({ + }), + ); + loadEditorAssetLibraryMock.mockImplementation(() => + immediateAsync({ folders: [ { folderId: 'project', @@ -86,8 +261,9 @@ describe('ImageCanvasEditorView', () => { systemDefault: true, }, ], - assets: [], - }); + assets: defaultEditorAssetLibraryAssets, + }), + ); createEditorAssetMock.mockImplementation(async (input) => ({ assetId: `persisted-${input.label}`, folderId: input.folderId, @@ -103,6 +279,23 @@ describe('ImageCanvasEditorView', () => { collapsed: false, systemDefault: false, }); + updateEditorAssetMock.mockImplementation(async (assetId, input) => ({ + assetId, + folderId: input.folderId ?? 'project', + label: input.label ?? '拼图素材', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + })); + renameEditorProjectMock.mockImplementation(async (projectId, title) => ({ + projectId, + title, + viewport: { x: 0, y: 0, scale: 1 }, + layers: defaultEditorProjectLayers, + resources: defaultEditorProjectResources, + updatedAt: '2026-06-12T00:00:00.000Z', + })); updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({ folderId, label: input.label ?? '角色上传', @@ -145,12 +338,14 @@ describe('ImageCanvasEditorView', () => { createEditorAssetMock.mockReset(); createEditorProjectResourceMock.mockReset(); createEditorAssetFolderMock.mockReset(); + updateEditorAssetMock.mockReset(); updateEditorAssetFolderMock.mockReset(); deleteEditorAssetFolderMock.mockReset(); deleteEditorAssetMock.mockReset(); loadEditorAssetLibraryMock.mockReset(); loadEditorProjectMock.mockReset(); loadOrCreateRecentEditorProjectMock.mockReset(); + renameEditorProjectMock.mockReset(); saveEditorProjectLayoutMock.mockReset(); window.history.replaceState(null, '', '/editor/canvas'); }); @@ -180,6 +375,327 @@ describe('ImageCanvasEditorView', () => { expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled(); }); + it('shows the loaded project title and a topbar entry back to projects', async () => { + render(); + + expect(await screen.findByRole('heading', { name: '默认项目' })).toBeTruthy(); + const projectLink = screen.getByRole('link', { name: '返回项目页面' }); + + expect(projectLink.getAttribute('href')).toBe('/project'); + expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull(); + }); + + it('opens login modal when the asset library is unauthorized', async () => { + const openLoginModal = vi.fn(); + loadEditorAssetLibraryMock.mockRejectedValueOnce( + new ApiClientError({ + message: '未授权访问', + status: 401, + code: 'UNAUTHORIZED', + }), + ); + + render( + + + , + ); + + await waitFor(() => { + expect(openLoginModal).toHaveBeenCalledTimes(1); + }); + }); + + it('renames the current project from the canvas topbar', async () => { + render(); + + await screen.findByRole('heading', { name: '默认项目' }); + fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' })); + fireEvent.change(screen.getByLabelText('项目名称'), { + target: { value: '新画布项目' }, + }); + fireEvent.click(screen.getByRole('button', { name: '保存项目名称' })); + + await waitFor(() => { + expect(renameEditorProjectMock).toHaveBeenCalledWith( + 'editor-project-default', + '新画布项目', + ); + }); + expect(await screen.findByRole('heading', { name: '新画布项目' })).toBeTruthy(); + }); + + it('does not inject built-in mock assets when the persisted library is empty', 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(); + + expect( + await screen.findByRole('region', { name: '项目素材' }), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); + expect(screen.queryByRole('button', { name: '添加大鱼素材' })).toBeNull(); + expect(screen.queryByAltText(/画布图片:拼图素材/u)).toBeNull(); + }); + + it('exports valid canvas assets as a zip from the topbar with metadata', 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-export', + 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('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + { + folderId: 'legacy-project', + label: '旧项目素材', + sortOrder: 1, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }); + + render(); + + expect( + await screen.findByRole('region', { name: '项目素材' }), + ).toBeTruthy(); + expect(screen.queryByRole('region', { name: '旧项目素材' })).toBeNull(); + expect(screen.getAllByRole('button', { name: /上传到/u })).toHaveLength(1); + }); + it('toggles the shared sidebar from canvas panel buttons', () => { render(); @@ -354,6 +870,73 @@ describe('ImageCanvasEditorView', () => { expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role'); }); + it('moves an asset to another folder when dragging inside the asset library', 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 projectFolder = screen.getByRole('region', { name: '项目素材' }); + const roleFolder = screen.getByRole('region', { name: '角色' }); + const dataTransfer = createDataTransferStub(); + + if (!sourceAssetRow) { + throw new Error('asset row should exist'); + } + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + 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 }); + + expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-puzzle', { + folderId: 'folder-role', + }); + expect( + within(projectFolder).queryByRole('button', { name: '添加拼图素材' }), + ).toBeNull(); + expect( + within(roleFolder).getByRole('button', { name: '添加拼图素材' }), + ).toBeTruthy(); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + }); + it('uploads multiple files as account-level assets without adding canvas layers', async () => { render(); @@ -375,6 +958,138 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull(); }); + it('opens login before uploading assets while logged out and resumes after login', async () => { + const openLoginModal = vi.fn(); + const authValue = createAuthValue({ openLoginModal }); + + const { rerender } = render( + + + , + ); + + await userEvent.upload(screen.getByLabelText('上传图片文件'), [ + new File(['image'], '登录后上传.png', { type: 'image/png' }), + ]); + + expect(openLoginModal).toHaveBeenCalledTimes(1); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '上传失败登录后上传.png' })).toBeNull(); + + const resumeUpload = openLoginModal.mock.calls[0]?.[0]; + expect(typeof resumeUpload).toBe('function'); + rerender( + + + , + ); + act(() => { + (resumeUpload as () => void)(); + }); + + await waitFor(() => { + expect(createEditorAssetMock).toHaveBeenCalledTimes(1); + }); + }); + + 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.mockReturnValueOnce(deferredAsset.promise); + render(); + + await userEvent.upload(screen.getByLabelText('上传图片文件'), [ + new File(['image'], '素材上传进度.png', { type: 'image/png' }), + ]); + + expect( + await screen.findByLabelText('素材素材上传进度.png上传进度'), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '上传中素材上传进度.png' })).toBeTruthy(); + + deferredAsset.resolve({ + assetId: 'asset-upload-progress', + folderId: 'project', + label: '素材上传进度.png', + imageSrc: 'data:image/png;base64,cHJvZ3Jlc3M=', + width: 420, + height: 315, + sourceType: 'uploaded', + }); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: '添加素材上传进度.png' }), + ).toBeTruthy(); + }); + expect( + screen.queryByLabelText('素材素材上传进度.png上传进度'), + ).toBeNull(); + }); + + it('opens login when asset creation returns unauthorized during upload', async () => { + const openLoginModal = vi.fn(); + createEditorAssetMock.mockRejectedValueOnce( + new ApiClientError({ + message: '未授权访问', + status: 401, + code: 'UNAUTHORIZED', + }), + ); + + render( + + + , + ); + + await userEvent.upload(screen.getByLabelText('上传图片文件'), [ + new File(['image'], '过期登录.png', { type: 'image/png' }), + ]); + + await waitFor(() => { + expect(openLoginModal).toHaveBeenCalledTimes(1); + }); + expect(screen.getByText('请先登录')).toBeTruthy(); + }); + it('supports asset selection mode and batch delete with shared toolbar', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ @@ -426,6 +1141,77 @@ describe('ImageCanvasEditorView', () => { ).toBeNull(); }); + it('removes canvas layers linked to deleted assets', async () => { + const user = userEvent.setup(); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-a', + folderId: 'project', + label: '账号素材A', + imageSrc: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + { + assetId: 'asset-b', + folderId: 'project', + label: '账号素材B', + imageSrc: 'data:image/png;base64,Yg==', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + ], + }); + render(); + + await user.click( + await screen.findByRole('button', { name: '添加账号素材A' }), + ); + await user.click(screen.getByRole('button', { name: '添加账号素材B' })); + expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy(); + expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '素材选择模式' })); + await user.click( + within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole( + 'button', + { name: '全选' }, + ), + ); + await waitFor(() => { + expect( + within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( + /已选 2/u, + ), + ).toBeTruthy(); + }); + await user.click( + within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole( + 'button', + { name: '删除' }, + ), + ); + + await waitFor(() => { + expect(screen.queryByAltText('画布图片:账号素材A')).toBeNull(); + expect(screen.queryByAltText('画布图片:账号素材B')).toBeNull(); + }); + expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); + expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b'); + }); + it('selects multiple assets with a marquee in asset selection mode', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ @@ -743,6 +1529,53 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull(); }); + it('adds an asset library image to the canvas by dragging it onto the viewport', async () => { + render(); + + const sourceAsset = await screen.findByRole('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('blocks the browser context menu inside the editor workspace', () => { render(); @@ -757,6 +1590,158 @@ describe('ImageCanvasEditorView', () => { expect(contextMenuEvent.defaultPrevented).toBe(true); }); + it('shows the blank canvas context menu with paste disabled, zoom, and fit all', () => { + render(); + + const viewport = screen.getByLabelText('画布工作区'); + fireEvent.contextMenu(viewport, { + clientX: 320, + clientY: 220, + }); + + const menu = screen.getByRole('menu', { name: '画布右键菜单' }); + expect( + (within(menu).getByRole('menuitem', { name: '粘贴' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '显示画布所有元素' }), + ).toBeTruthy(); + }); + + it('copies, cuts, and pastes layers from the context menus', () => { + render(); + + fireEvent.contextMenu( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + clientX: 510, + clientY: 330, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); + + fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { + clientX: 360, + clientY: 240, + }); + const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' }); + expect( + (within(copyPasteMenu).getByRole('menuitem', { + name: '粘贴', + }) as HTMLButtonElement).disabled, + ).toBe(false); + fireEvent.click(within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + + fireEvent.contextMenu( + screen.getByAltText('画布图片:大鱼素材').closest('button')!, + { + clientX: 950, + clientY: 380, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '剪切' })); + expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); + + fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { + clientX: 420, + clientY: 260, + }); + fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' })); + expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); + }); + + it('handles layer context menu duplicate, ordering, hide, lock, flip, group, ungroup, and delete', async () => { + render(); + + const firstLayer = screen + .getByAltText('画布图片:拼图素材') + .closest('button')!; + fireEvent.contextMenu(firstLayer, { clientX: 510, clientY: 330 }); + fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + + const copiedLayer = screen + .getAllByAltText(/画布图片:拼图素材/u)[1]! + .closest('button')!; + fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); + fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' })); + expect( + (screen.getAllByAltText(/画布图片:拼图素材/u)[1] as HTMLElement).style + .transform, + ).toBe('scale(-1, 1)'); + + fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); + fireEvent.click(screen.getByRole('menuitem', { name: '锁定' })); + await waitFor(() => { + expect(copiedLayer.className).toContain( + 'image-canvas-editor__layer--locked', + ); + }); + + fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); + fireEvent.click(screen.getByRole('menuitem', { name: '隐藏' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: '打开图层' })); + expect(screen.getByText(/已隐藏/u)).toBeTruthy(); + fireEvent.contextMenu( + screen.getByText(/已隐藏/u).closest('.image-canvas-editor__layer-row')!, + { + clientX: 80, + clientY: 220, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '显示' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + + const bigFishLayer = screen + .getByAltText('画布图片:大鱼素材') + .closest('button')!; + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '置于顶层' })); + expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(2); + + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '下移一层' })); + expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(0); + + fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 181, + clientX: 520, + clientY: 380, + shiftKey: true, + }, + ); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 181, + clientX: 520, + clientY: 380, + }); + fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '创建组' })); + await waitFor(() => { + expect(screen.getAllByText(/已打组/u).length).toBeGreaterThan(0); + }); + + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '解除组' })); + await waitFor(() => { + expect(screen.queryByText(/已打组/u)).toBeNull(); + }); + + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); + expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); + }); + it('switches the shared sidebar between assets and layers', () => { render(); @@ -795,13 +1780,13 @@ describe('ImageCanvasEditorView', () => { render(); expect( - screen.getByRole('button', { name: '当前缩放比例 82%' }).className, + screen.getByRole('button', { name: '当前缩放比例 100%' }).className, ).toContain('platform-inline-option-button'); - fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' })); + fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); fireEvent.click(screen.getByRole('menuitem', { name: '放大' })); expect( - screen.getByRole('button', { name: '当前缩放比例 95%' }), + screen.getByRole('button', { name: '当前缩放比例 116%' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); @@ -852,12 +1837,19 @@ describe('ImageCanvasEditorView', () => { }), ]), ); + expect(lastLayout.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceAssetId: 'asset-data-heavy', + }), + ]), + ); }); it('offers Lovart-style zoom menu commands', async () => { render(); - fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' })); + fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy(); expect( @@ -874,33 +1866,70 @@ describe('ImageCanvasEditorView', () => { ).toBeTruthy(); }); - it('shows the Lovart-style minimap and canvas background controls', () => { + it('shows the Lovart-style minimap and canvas background settings panel', () => { render(); const viewport = screen.getByLabelText('画布工作区'); const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); + const backgroundButton = within(panelToolbar).getByRole('button', { + name: '画布背景色', + }); expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); - expect( - within(panelToolbar).getByRole('button', { name: '画布背景色' }) - .className, - ).toContain('platform-icon-button'); + expect(backgroundButton.className).toContain('platform-icon-button'); 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: '切换画布背景色为暖灰' }), - ); + fireEvent.click(backgroundButton); + const settingsPanel = screen.getByRole('dialog', { + name: '画布背景设置', + }); + expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); + + fireEvent.click(within(settingsPanel).getByRole('button', { name: '暖灰' })); expect((viewport as HTMLElement).style.backgroundColor).toBe( 'rgb(243, 240, 234)', ); + fireEvent.change( + within(settingsPanel).getByLabelText('自定义画布背景色'), + { + target: { value: '#ffffff' }, + }, + ); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(255, 255, 255)', + ); + + const hexInput = within(settingsPanel).getByLabelText( + '画布背景十六进制颜色', + ); + fireEvent.change(hexInput, { target: { value: '#abc' } }); + expect((hexInput as HTMLInputElement).value).toBe('#aabbcc'); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(170, 187, 204)', + ); + + fireEvent.change(hexInput, { target: { value: '#not-a-color' } }); + expect((hexInput as HTMLInputElement).value).toBe('#not-a-color'); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(170, 187, 204)', + ); + + fireEvent.click( + within(settingsPanel).getByRole('button', { name: '恢复默认' }), + ); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(248, 250, 252)', + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect( + screen.queryByRole('dialog', { name: '画布背景设置' }), + ).toBeNull(); + fireEvent.click( within(panelToolbar).getByRole('button', { name: '切换小地图' }), ); @@ -912,12 +1941,12 @@ describe('ImageCanvasEditorView', () => { const viewport = screen.getByLabelText('画布工作区'); expect( - screen.getByRole('button', { name: '当前缩放比例 82%' }), + screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 }); expect( - screen.getByRole('button', { name: '当前缩放比例 82%' }), + screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); fireEvent.wheel(viewport, { @@ -927,7 +1956,7 @@ describe('ImageCanvasEditorView', () => { clientY: 280, }); expect( - screen.getByRole('button', { name: '当前缩放比例 90%' }), + screen.getByRole('button', { name: '当前缩放比例 110%' }), ).toBeTruthy(); const ctrlWheelEvent = new WheelEvent('wheel', { @@ -3151,8 +4180,8 @@ describe('ImageCanvasEditorView', () => { }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 21, - clientX: 499, - clientY: 169, + clientX: -60, + clientY: 180, }); expect( @@ -3428,4 +4457,53 @@ describe('ImageCanvasEditorView', () => { '生成中', ); }); + + it('undoes and redoes canvas layer changes from the panel controls', () => { + render(); + + expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('button', { name: '重做' })).toHaveProperty( + 'disabled', + true, + ); + + fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + + expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); + expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( + 'disabled', + false, + ); + fireEvent.click(screen.getByRole('button', { name: '撤销' })); + + expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull(); + expect(screen.getByRole('button', { name: '重做' })).toHaveProperty( + 'disabled', + false, + ); + fireEvent.click(screen.getByRole('button', { name: '重做' })); + + expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); + }); + + it('supports undo and redo keyboard shortcuts inside the editor', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); + + fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); + expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull(); + + fireEvent.keyDown(window, { + key: 'Z', + code: 'KeyZ', + ctrlKey: true, + shiftKey: true, + }); + expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); + }); }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 6531a485..84ebe779 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -3,6 +3,7 @@ import { Check, CheckSquare, ChevronDown, + ChevronLeft, ChevronRight, ClipboardList, Copy, @@ -19,6 +20,7 @@ import { MousePointer2, Pencil, PencilLine, + Redo2, RotateCcw, Shapes, SlidersHorizontal, @@ -26,9 +28,11 @@ import { Square, Trash2, Type, + Undo2, WandSparkles, X, } from 'lucide-react'; +import JSZip from 'jszip'; import { type CSSProperties, type DragEvent as ReactDragEvent, @@ -67,6 +71,7 @@ import { loadEditorAssetLibrary, loadEditorProject, loadOrCreateRecentEditorProject, + renameEditorProject, saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, @@ -87,6 +92,7 @@ import { PlatformTextField, } from '../common/PlatformTextField'; import { UnifiedModal } from '../common/UnifiedModal'; +import { useAuthUi } from '../auth/AuthUiContext'; import { EditorIconButton, SidebarMediaItem, @@ -109,6 +115,9 @@ type EditorAsset = { taskId?: string; objectKey?: string; assetObjectId?: string; + uploadStatus?: 'uploading' | 'failed'; + uploadProgress?: number; + uploadMessage?: string; }; type CanvasGenerationInputField = { @@ -148,9 +157,14 @@ type CanvasLayer = { objectKey?: string | null; assetObjectId?: string | null; sourceResourceId?: string | null; + sourceAssetId?: string | null; groupId?: string | null; assetKind?: 'spec' | 'character' | 'icon' | 'icon-spec' | null; generationInputs?: CanvasGenerationInputs | null; + hidden?: boolean; + locked?: boolean; + flipX?: boolean; + flipY?: boolean; }; type CanvasViewport = { @@ -235,6 +249,85 @@ type ImageContextMenuState = { y: number; }; +type CanvasHistorySnapshot = { + layers: CanvasLayer[]; + viewport: CanvasViewport; + generateDialog: GenerateDialogState | null; + inactiveGenerateDialogs: CanvasGenerationDialogState[]; + selectedLayerId: string | null; + selectedLayerIds: string[]; +}; + +type CanvasClipboard = { + layers: CanvasLayer[]; + mode: 'copy' | 'cut'; +}; + +type CanvasContextMenuState = + | { + kind: 'blank'; + x: number; + y: number; + canvasPoint: { x: number; y: number }; + } + | { + kind: 'layer'; + x: number; + y: number; + layerId: string; + canvasPoint: { x: number; y: number }; + }; + +type CanvasAssetExportImage = { + key: string; + file: string; + layer: CanvasLayer; + blob?: Blob; + error?: string; +}; + +type CanvasAssetExportMetadata = { + projectId: string | null; + projectTitle: string; + exportedAt: string; + layers: Array<{ + layerId: string; + title: string; + file: string | null; + sourceType: CanvasLayer['sourceType']; + prompt?: string | null; + actualPrompt?: string | null; + model?: string | null; + provider?: string | null; + taskId?: string | null; + objectKey?: string | null; + assetObjectId?: string | null; + sourceResourceId?: string | null; + sourceAssetId?: string | null; + exportError?: string; + canvas: { + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + zIndex: number; + groupId?: string | null; + hidden?: boolean; + locked?: boolean; + flipX?: boolean; + flipY?: boolean; + }; + }>; + failedImages: Array<{ + key: string; + title: string; + src: string; + error: string; + }>; +}; + type QuickEditPanelState = { sourceLayerId: string; prompt: string; @@ -281,6 +374,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; @@ -329,63 +433,7 @@ type DragState = moved: boolean; }; -const EDITOR_ASSETS: EditorAsset[] = [ - { - id: 'puzzle', - label: '拼图素材', - src: '/creation-type-references/puzzle.webp', - width: 640, - height: 640, - folderId: 'project', - sourceKind: 'built-in', - sourceType: 'uploaded', - persisted: false, - }, - { - id: 'match3d', - label: '抓大鹅素材', - src: '/creation-type-references/match3d.webp', - width: 640, - height: 640, - folderId: 'project', - sourceKind: 'built-in', - sourceType: 'uploaded', - persisted: false, - }, - { - id: 'big-fish', - label: '大鱼素材', - src: '/creation-type-references/big-fish.webp', - width: 720, - height: 405, - folderId: 'project', - sourceKind: 'built-in', - sourceType: 'uploaded', - persisted: false, - }, - { - id: 'bark-battle', - label: '声浪素材', - src: '/creation-type-references/bark-battle.webp', - width: 640, - height: 900, - folderId: 'project', - sourceKind: 'built-in', - sourceType: 'uploaded', - persisted: false, - }, - { - id: 'visual-novel', - label: '视觉小说素材', - src: '/creation-type-references/visual-novel.webp', - width: 720, - height: 405, - folderId: 'project', - sourceKind: 'built-in', - sourceType: 'uploaded', - persisted: false, - }, -]; +const EDITOR_ASSETS: EditorAsset[] = []; const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { @@ -397,36 +445,7 @@ const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ }, ]; -const INITIAL_LAYERS: CanvasLayer[] = [ - { - id: 'layer-puzzle', - resourceId: 'resource-puzzle', - title: '拼图素材', - src: '/creation-type-references/puzzle.webp', - x: 470, - y: 300, - width: 640, - height: 640, - originalWidth: 640, - originalHeight: 640, - zIndex: 1, - sourceType: 'uploaded', - }, - { - id: 'layer-big-fish', - resourceId: 'resource-big-fish', - title: '大鱼素材', - src: '/creation-type-references/big-fish.webp', - x: 930, - y: 360, - width: 720, - height: 405, - originalWidth: 720, - originalHeight: 405, - zIndex: 2, - sourceType: 'uploaded', - }, -]; +const INITIAL_LAYERS: CanvasLayer[] = []; const CANVAS_WORLD_SIZE = 12000; const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; @@ -439,6 +458,13 @@ const FIT_VIEW_PADDING = 10; const MINIMAP_SIZE = { width: 132, height: 84 }; const MINIMAP_PADDING = 8; const MINIMAP_DRAG_SENSITIVITY = 0.3; +const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; +const MAX_HISTORY_STEPS = 60; +const CONTEXT_MENU_VIEWPORT_MARGIN = 8; +const CONTEXT_MENU_SIZE = { + blank: { width: 188, height: 176 }, + layer: { width: 188, height: 492 }, +} as const; const SPEC_GENERATION_COST = 5; const SPEC_GENERATION_SIZE = '2048x1152'; const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; @@ -502,6 +528,23 @@ const CANVAS_BACKGROUND_OPTIONS = [ { label: '暖灰', value: '#f3f0ea' }, { label: '冷蓝', value: '#eef6ff' }, ]; +const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc'; + +function normalizeCanvasBackgroundHex(value: string) { + const trimmedValue = value.trim().toLowerCase(); + const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(trimmedValue); + if (!match) { + return null; + } + const hexValue = match[1] ?? ''; + if (hexValue.length === 3) { + return `#${hexValue + .split('') + .map((part) => `${part}${part}`) + .join('')}`; + } + return `#${hexValue}`; +} const DEFAULT_SPEC_FORM_VALUES: Record = { character: { @@ -605,8 +648,13 @@ function createLayerFromAsset( asset.height, { width: 360, height: 360 }, ); - const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale; - const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale; + const safeScale = viewport.scale > 0 ? viewport.scale : 1; + const safeScreenCenter = { + x: Number.isFinite(screenCenter.x) ? screenCenter.x : 0, + y: Number.isFinite(screenCenter.y) ? screenCenter.y : 0, + }; + const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale; + const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale; const offset = index * 34; return { @@ -629,6 +677,7 @@ function createLayerFromAsset( taskId: asset.taskId, objectKey: asset.objectKey, assetObjectId: asset.assetObjectId, + sourceAssetId: asset.id, } satisfies CanvasLayer; } @@ -653,9 +702,14 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, sourceResourceId: layer.sourceResourceId, + sourceAssetId: layer.sourceAssetId, groupId: layer.groupId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, }; } @@ -705,9 +759,14 @@ function hydrateLayer( objectKey: stringOrNull(snapshot.objectKey), assetObjectId: stringOrNull(snapshot.assetObjectId), sourceResourceId: stringOrNull(snapshot.sourceResourceId), + sourceAssetId: stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), assetKind: canvasAssetKindOrNull(snapshot.assetKind), generationInputs: generationInputsOrNull(snapshot.generationInputs), + hidden: booleanFromSnapshot(snapshot.hidden), + locked: booleanFromSnapshot(snapshot.locked), + flipX: booleanFromSnapshot(snapshot.flipX), + flipY: booleanFromSnapshot(snapshot.flipY), }; } @@ -744,15 +803,28 @@ function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): { }; } -function mergeAssetLibraryWithBuiltIns(library: EditorAssetLibrarySnapshot) { +function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) { const mapped = mapAssetLibrarySnapshot(library); - const persistedFolderIds = new Set(mapped.folders.map((folder) => folder.id)); - const builtInFolders = EDITOR_ASSET_FOLDERS.filter( - (folder) => !persistedFolderIds.has(folder.id), + let hasDefaultFolder = false; + const normalizedFolders = mapped.folders.filter((folder) => { + if (!folder.systemDefault) { + return true; + } + if (hasDefaultFolder) { + return false; + } + hasDefaultFolder = true; + return true; + }); + const persistedFolderIds = new Set( + normalizedFolders.map((folder) => folder.id), ); + const fallbackFolders = hasDefaultFolder + ? [] + : EDITOR_ASSET_FOLDERS.filter((folder) => !persistedFolderIds.has(folder.id)); return { - folders: [...mapped.folders, ...builtInFolders], - assets: [...EDITOR_ASSETS, ...mapped.assets], + folders: [...normalizedFolders, ...fallbackFolders], + assets: mapped.assets, }; } @@ -764,6 +836,191 @@ function stringOrNull(value: unknown) { return typeof value === 'string' && value.trim() ? value : null; } +function booleanFromSnapshot(value: unknown) { + return value === true; +} + +function resolveContextMenuPosition( + clientX: number, + clientY: number, + kind: CanvasContextMenuState['kind'], +) { + if (typeof window === 'undefined') { + return { x: clientX, y: clientY }; + } + const menuSize = CONTEXT_MENU_SIZE[kind]; + return { + x: clamp( + clientX, + CONTEXT_MENU_VIEWPORT_MARGIN, + Math.max( + CONTEXT_MENU_VIEWPORT_MARGIN, + window.innerWidth - menuSize.width - CONTEXT_MENU_VIEWPORT_MARGIN, + ), + ), + y: clamp( + clientY, + CONTEXT_MENU_VIEWPORT_MARGIN, + Math.max( + CONTEXT_MENU_VIEWPORT_MARGIN, + window.innerHeight - menuSize.height - CONTEXT_MENU_VIEWPORT_MARGIN, + ), + ), + }; +} + +function sanitizeExportFilePart(value: string, fallback: string) { + const safeValue = value + .trim() + .replace(/[\\/:*?"<>|]+/gu, ' ') + .replace(/\s+/gu, ' ') + .trim(); + return safeValue || fallback; +} + +function formatExportDate(date: Date) { + const year = date.getFullYear(); + 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.sourceResourceId || + layer.src + ); +} + +function getImageExtensionFromTypeOrSrc(type: string, src: string) { + if (type.includes('jpeg') || /\.(jpe?g)(?:[?#].*)?$/iu.test(src)) { + return 'jpg'; + } + if (type.includes('webp') || /\.webp(?:[?#].*)?$/iu.test(src)) { + return 'webp'; + } + if (type.includes('gif') || /\.gif(?:[?#].*)?$/iu.test(src)) { + return 'gif'; + } + return 'png'; +} + +function dataUrlToBlob(dataUrl: string) { + const [header = '', payload = ''] = dataUrl.split(','); + const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header); + const type = mimeMatch?.[1] ?? 'application/octet-stream'; + const isBase64 = Boolean(mimeMatch?.[2]); + const binary = isBase64 + ? typeof atob === 'function' + ? atob(payload) + : Buffer.from(payload, 'base64').toString('binary') + : 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 }); +} + +async function readLayerImageBlob(layer: CanvasLayer) { + if (layer.src.startsWith('data:')) { + return dataUrlToBlob(layer.src); + } + const response = await fetch(layer.src); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.blob(); +} + +async function blobToUint8Array(blob: Blob) { + if (typeof blob.arrayBuffer === 'function') { + return new Uint8Array(await blob.arrayBuffer()); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (result instanceof ArrayBuffer) { + resolve(new Uint8Array(result)); + return; + } + reject(new Error('Blob 读取失败')); + }; + reader.onerror = () => reject(new Error('Blob 读取失败')); + reader.readAsArrayBuffer(blob); + }); +} + +function buildLayerExportMetadata( + layer: CanvasLayer, + file: string | null, + exportError?: string, +): CanvasAssetExportMetadata['layers'][number] { + return { + layerId: layer.id, + title: layer.title, + file, + sourceType: layer.sourceType, + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + sourceResourceId: layer.sourceResourceId, + sourceAssetId: layer.sourceAssetId, + exportError, + canvas: { + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + }, + }; +} + +function hasDataTransferType(dataTransfer: DataTransfer, type: string) { + return Array.from(dataTransfer.types).includes(type); +} + +function getDraggedAssetId(dataTransfer: DataTransfer) { + if (typeof dataTransfer.getData !== 'function') { + return ''; + } + if (!hasDataTransferType(dataTransfer, ASSET_DRAG_MIME_TYPE)) { + return ''; + } + return dataTransfer.getData(ASSET_DRAG_MIME_TYPE); +} + +function escapeCssIdentifier(value: string) { + return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(value) + : value.replace(/["\\]/gu, '\\$&'); +} + +function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { + return ( + layer.sourceAssetId === asset.id || + Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) || + Boolean(asset.objectKey && layer.objectKey === asset.objectKey) || + layer.src === asset.src + ); +} + function generationInputsOrNull(value: unknown): CanvasGenerationInputs | null { if (!value || typeof value !== 'object') { return null; @@ -1197,16 +1454,34 @@ function resolveImageGenerationErrorMessage(error: unknown) { : '生成图片失败'; } +function isEditorAuthError(error: unknown) { + return ( + error instanceof ApiClientError && + (error.status === 401 || error.status === 403) + ); +} + export function ImageCanvasEditorView() { + const authUi = useAuthUi(); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const uploadInputRef = useRef(null); const assetListRef = useRef(null); const dragStateRef = useRef(null); + const assetPointerDragRef = useRef(null); + const authUiRef = useRef(authUi); const isShiftPressedRef = useRef(false); - const layerCounterRef = useRef(INITIAL_LAYERS.length); + const layerCounterRef = useRef(0); const generationDialogCounterRef = useRef(0); const saveTimerRef = useRef(null); + const undoStackRef = useRef([]); + const redoStackRef = useRef([]); + const layersRef = useRef([]); + const viewportRef = useRef({ + x: -260, + y: 70, + scale: 0.82, + }); const projectIdRef = useRef(null); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); @@ -1218,13 +1493,27 @@ export function ImageCanvasEditorView() { }> >([]); const selectedLayerIdRef = useRef(null); + const selectedLayerIdsRef = useRef([]); const generateDialogRef = useRef(null); const inactiveGenerateDialogsRef = useRef([]); const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); + const suppressAssetClickRef = 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 [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({ @@ -1235,8 +1524,8 @@ export function ImageCanvasEditorView() { const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); const [assetFolders, setAssetFolders] = useState(EDITOR_ASSET_FOLDERS); - const [assets, setAssets] = useState(EDITOR_ASSETS); - const [layers, setLayers] = useState(INITIAL_LAYERS); + const [assets, setAssets] = useState([]); + const [layers, setLayers] = useState([]); const [renamingAsset, setRenamingAsset] = useState<{ assetId: string; value: string; @@ -1255,26 +1544,34 @@ export function ImageCanvasEditorView() { const [assetMarquee, setAssetMarquee] = useState( null, ); + const [assetPointerDrag, setAssetPointerDrag] = + useState(null); + const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState< + string | null + >(null); + const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState< + string | null + >(null); const [canvasMarquee, setCanvasMarquee] = useState( null, ); - const [selectedLayerId, setSelectedLayerId] = useState( - INITIAL_LAYERS[0]?.id ?? null, - ); - const [selectedLayerIds, setSelectedLayerIds] = useState( - INITIAL_LAYERS[0]?.id ? [INITIAL_LAYERS[0].id] : [], - ); + const [selectedLayerId, setSelectedLayerId] = useState(null); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [activeTool, setActiveTool] = useState('select'); const [isSpacePanning, setIsSpacePanning] = useState(false); const [isPanning, setIsPanning] = useState(false); const [snapGuide, setSnapGuide] = useState(null); const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); - const [isBackgroundMenuOpen, setIsBackgroundMenuOpen] = useState(false); + const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] = + useState(false); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isMinimapOpen, setIsMinimapOpen] = useState(true); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( - CANVAS_BACKGROUND_OPTIONS[1]?.value ?? '#f8fafc', + DEFAULT_CANVAS_BACKGROUND_COLOR, + ); + const [canvasBackgroundHexValue, setCanvasBackgroundHexValue] = useState( + DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [metadataLayer, setMetadataLayer] = useState(null); const [generateDialog, setGenerateDialog] = @@ -1293,14 +1590,52 @@ export function ImageCanvasEditorView() { useState(false); const [imageContextMenu, setImageContextMenu] = useState(null); + const [contextMenu, setContextMenu] = + useState(null); + const [canvasClipboard, setCanvasClipboard] = + useState(null); + const [historyVersion, setHistoryVersion] = useState(0); const [quickEditPanel, setQuickEditPanel] = useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); + const [uploadDropTarget, setUploadDropTarget] = useState< + 'canvas' | 'assets' | null + >(null); selectedLayerIdRef.current = selectedLayerId; + selectedLayerIdsRef.current = selectedLayerIds; + layersRef.current = layers; + viewportRef.current = viewport; generateDialogRef.current = generateDialog; inactiveGenerateDialogsRef.current = inactiveGenerateDialogs; + const assetsRef = useRef(assets); + const addAssetLayerRef = useRef< + (asset: EditorAsset, screenCenter?: { x: number; y: number }) => void + >(() => {}); + const moveAssetToFolderRef = useRef< + (assetId: string, folderId: string) => void + >(() => {}); + authUiRef.current = authUi; + const openEditorLoginModal = useCallback( + (postLoginAction?: (() => void) | null) => { + authUiRef.current?.openLoginModal(postLoginAction); + }, + [], + ); + const applyCanvasBackgroundColor = useCallback((color: string) => { + const normalizedColor = normalizeCanvasBackgroundHex(color); + if (!normalizedColor) { + return false; + } + setCanvasBackgroundColor(normalizedColor); + setCanvasBackgroundHexValue(normalizedColor); + return true; + }, []); + + useEffect(() => { + assetsRef.current = assets; + }, [assets]); const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; const activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog) @@ -1317,6 +1652,8 @@ export function ImageCanvasEditorView() { () => layers.find((layer) => layer.id === selectedLayerId) ?? null, [layers, selectedLayerId], ); + const selectedLayerCount = selectedLayerIds.length; + const hasMultipleSelectedLayers = selectedLayerCount > 1; const activeGenerationLayer = useMemo( () => activeCanvasGenerationDialog?.generatedLayerId @@ -1438,6 +1775,30 @@ export function ImageCanvasEditorView() { const imageContextMenuLayer = imageContextMenu ? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null) : null; + const getContextTargetLayerIds = useCallback( + (menu: CanvasContextMenuState | null = contextMenu) => { + if (menu?.kind !== 'layer') { + return []; + } + return selectedLayerIdsRef.current.includes(menu.layerId) + ? selectedLayerIdsRef.current + : [menu.layerId]; + }, + [contextMenu], + ); + const contextTargetIds = getContextTargetLayerIds(contextMenu); + const contextTargetLayers = layers.filter((layer) => + contextTargetIds.includes(layer.id), + ); + const contextShouldShowLayer = contextTargetLayers.some( + (layer) => layer.hidden, + ); + const contextShouldUnlockLayer = contextTargetLayers.some( + (layer) => layer.locked, + ); + const canUndo = undoStackRef.current.length > 0; + const canRedo = redoStackRef.current.length > 0; + void historyVersion; const groupedAssets = useMemo( () => assetFolders.map((folder) => ({ @@ -1556,6 +1917,106 @@ export function ImageCanvasEditorView() { ); }; + const getCanvasHistorySnapshot = useCallback( + (): CanvasHistorySnapshot => ({ + layers: layersRef.current.map((layer) => ({ ...layer })), + viewport: { ...viewportRef.current }, + generateDialog: generateDialogRef.current + ? { + ...generateDialogRef.current, + placeholder: generateDialogRef.current.placeholder + ? { ...generateDialogRef.current.placeholder } + : undefined, + } + : null, + inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map( + (dialog) => ({ + ...dialog, + placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + }), + ), + selectedLayerId: selectedLayerIdRef.current, + selectedLayerIds: [...selectedLayerIdsRef.current], + }), + [], + ); + + const restoreCanvasHistorySnapshot = useCallback( + (snapshot: CanvasHistorySnapshot) => { + setLayers(snapshot.layers.map((layer) => ({ ...layer }))); + setViewport({ ...snapshot.viewport }); + setGenerateDialog( + snapshot.generateDialog + ? { + ...snapshot.generateDialog, + placeholder: snapshot.generateDialog.placeholder + ? { ...snapshot.generateDialog.placeholder } + : undefined, + } + : null, + ); + setInactiveGenerateDialogs( + snapshot.inactiveGenerateDialogs.map((dialog) => ({ + ...dialog, + placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + })), + ); + setSelectedLayerId(snapshot.selectedLayerId); + setSelectedLayerIds([...snapshot.selectedLayerIds]); + setHoveredLayerId(null); + setMetadataLayer(null); + setCanvasMarquee(null); + setSnapGuide(null); + setImageContextMenu(null); + setContextMenu(null); + setIsPanning(false); + dragStateRef.current = null; + }, + [], + ); + + const captureCanvasHistory = useCallback( + (options: { clearRedo?: boolean } = {}) => { + undoStackRef.current = [ + ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + getCanvasHistorySnapshot(), + ]; + if (options.clearRedo !== false) { + redoStackRef.current = []; + } + setHistoryVersion((version) => version + 1); + }, + [getCanvasHistorySnapshot], + ); + + const undoCanvasChange = useCallback(() => { + const previousSnapshot = undoStackRef.current.at(-1); + if (!previousSnapshot) { + return; + } + undoStackRef.current = undoStackRef.current.slice(0, -1); + redoStackRef.current = [ + ...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + getCanvasHistorySnapshot(), + ]; + restoreCanvasHistorySnapshot(previousSnapshot); + setHistoryVersion((version) => version + 1); + }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + + const redoCanvasChange = useCallback(() => { + const nextSnapshot = redoStackRef.current.at(-1); + if (!nextSnapshot) { + return; + } + redoStackRef.current = redoStackRef.current.slice(0, -1); + undoStackRef.current = [ + ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + getCanvasHistorySnapshot(), + ]; + restoreCanvasHistorySnapshot(nextSnapshot); + setHistoryVersion((version) => version + 1); + }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + const selectSingleLayer = useCallback((layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); @@ -1593,6 +2054,7 @@ export function ImageCanvasEditorView() { selectSingleLayer(null); hideGeneratedLayerPanelAfterBlur(); setImageContextMenu(null); + setContextMenu(null); }, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]); const getGeneratingDialogPlaceholder = useCallback( @@ -1656,9 +2118,13 @@ export function ImageCanvasEditorView() { ), ); }) - .catch(() => {}); + .catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); }, - [], + [openEditorLoginModal], ); const minimapModel = useMemo(() => { @@ -1733,6 +2199,9 @@ export function ImageCanvasEditorView() { } projectIdRef.current = project.projectId; setProjectId(project.projectId); + const nextProjectTitle = project.title?.trim() || '未命名画布'; + setProjectTitle(nextProjectTitle); + setProjectRenameValue(nextProjectTitle); const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); pendingLayers.forEach(({ layer, options }) => { createProjectResourceForLayer(layer, options); @@ -1754,16 +2223,22 @@ export function ImageCanvasEditorView() { } setIsProjectReady(true); }) - .catch(() => { - if (!cancelled) { - setIsProjectReady(false); + .catch((error: unknown) => { + if (cancelled) { + return; + } + setIsProjectReady(false); + if (isEditorAuthError(error)) { + openEditorLoginModal(() => { + window.location.reload(); + }); } }); return () => { cancelled = true; }; - }, [createProjectResourceForLayer, selectSingleLayer]); + }, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]); useEffect(() => { let cancelled = false; @@ -1772,7 +2247,7 @@ export function ImageCanvasEditorView() { if (cancelled) { return; } - const nextLibrary = mergeAssetLibraryWithBuiltIns(library); + const nextLibrary = normalizeAssetLibrary(library); setAssetFolders(nextLibrary.folders); setAssets(nextLibrary.assets); const defaultFolder = nextLibrary.folders.find( @@ -1782,11 +2257,15 @@ export function ImageCanvasEditorView() { defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project', ); }) - .catch(() => {}); + .catch((error: unknown) => { + if (!cancelled && isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); return () => { cancelled = true; }; - }, []); + }, [openEditorLoginModal]); useEffect(() => { const viewportElement = canvasViewportRef.current; @@ -1813,11 +2292,24 @@ export function ImageCanvasEditorView() { return () => observer.disconnect(); }, []); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - isShiftPressedRef.current = true; + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + (event.ctrlKey || event.metaKey) && + event.code === 'KeyZ' && + !isEditableTarget(event) + ) { + event.preventDefault(); + if (event.shiftKey) { + redoCanvasChange(); + } else { + undoCanvasChange(); + } + return; } + if (event.key === 'Shift') { + isShiftPressedRef.current = true; + } if ( (event.key === 'Backspace' || event.key === 'Delete') && !event.repeat && @@ -1851,9 +2343,10 @@ export function ImageCanvasEditorView() { if (event.key === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); - setIsBackgroundMenuOpen(false); - setIsSpecMenuOpen(false); - setImageContextMenu(null); + setIsBackgroundSettingsOpen(false); + setIsSpecMenuOpen(false); + setImageContextMenu(null); + setContextMenu(null); setQuickEditPanel((currentPanel) => currentPanel?.status === 'generating' ? currentPanel : null, ); @@ -1907,7 +2400,7 @@ export function ImageCanvasEditorView() { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; - }, []); + }, [redoCanvasChange, undoCanvasChange]); useEffect(() => { const blockBrowserZoom = (event: WheelEvent) => { @@ -1930,6 +2423,74 @@ export function ImageCanvasEditorView() { }; }, []); + useEffect(() => { + 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 dropFolderId = resolveAssetFolderId(event.clientX, event.clientY); + const nextDrag: AssetPointerDragState = { + ...currentDrag, + currentClientX: event.clientX, + currentClientY: event.clientY, + active: currentDrag.active || distance > 4, + dropFolderId, + }; + assetPointerDragRef.current = nextDrag; + setAssetPointerDrag(nextDrag); + setUploadDropTarget( + resolveCanvasPoint(event.clientX, event.clientY) ? 'canvas' : null, + ); + updateAssetMoveDropFolder(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; + } + suppressAssetClickRef.current = true; + window.setTimeout(() => { + suppressAssetClickRef.current = false; + }, 0); + if (dropFolderId && dropFolderId !== draggedAsset.folderId) { + moveAssetToFolderRef.current(draggedAsset.id, dropFolderId); + return; + } + if (canvasPoint) { + 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(() => { if (!projectId || !isProjectReady) { return undefined; @@ -1942,7 +2503,11 @@ export function ImageCanvasEditorView() { saveEditorProjectLayout(projectId, { viewport, layers: layers.map(serializeLayer), - }).catch(() => {}); + }).catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); }, 450); return () => { @@ -1950,7 +2515,7 @@ export function ImageCanvasEditorView() { window.clearTimeout(saveTimerRef.current); } }; - }, [isProjectReady, layers, projectId, viewport]); + }, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]); const fitLayers = useCallback( (targetLayers: CanvasLayer[] = layers) => { @@ -1972,7 +2537,7 @@ export function ImageCanvasEditorView() { 1, canvasSize.height - FIT_VIEW_PADDING * 2, ); - const scale = clamp( + const scale = clamp( Math.min( 1, availableWidth / boundsWidth, @@ -1980,31 +2545,34 @@ export function ImageCanvasEditorView() { ), MIN_SCALE, MAX_SCALE, - ); + ); - setViewport({ + captureCanvasHistory(); + setViewport({ x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale, y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale, scale, }); - }, - [canvasSize.height, canvasSize.width, layers], - ); + }, + [captureCanvasHistory, canvasSize.height, canvasSize.width, layers], + ); - const updateScaleFromCenter = (nextScale: number) => { - const viewportElement = canvasViewportRef.current; - if (!viewportElement) { - setViewport((currentViewport) => ({ + const updateScaleFromCenter = (nextScale: number) => { + const viewportElement = canvasViewportRef.current; + if (!viewportElement) { + captureCanvasHistory(); + setViewport((currentViewport) => ({ ...currentViewport, scale: clamp(nextScale, MIN_SCALE, MAX_SCALE), })); return; } - const rect = viewportElement.getBoundingClientRect(); + const rect = viewportElement.getBoundingClientRect(); const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2; - const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; - setViewport((currentViewport) => { + const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; + captureCanvasHistory(); + setViewport((currentViewport) => { const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); const worldX = (centerX - currentViewport.x) / currentViewport.scale; const worldY = (centerY - currentViewport.y) / currentViewport.scale; @@ -2016,6 +2584,306 @@ export function ImageCanvasEditorView() { }); }; + const resolveCanvasPoint = (clientX: number, clientY: number) => { + const rect = canvasViewportRef.current?.getBoundingClientRect(); + if (!rect) { + return null; + } + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + const getCanvasDropPoint = (event: ReactDragEvent) => + resolveCanvasPoint(event.clientX, event.clientY) ?? { + x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0, + y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0, + }; + + const getCanvasPointFromClient = (clientX: number, clientY: number) => { + const rect = canvasViewportRef.current?.getBoundingClientRect(); + const screenX = clientX - (rect?.left ?? 0); + const screenY = clientY - (rect?.top ?? 0); + return { + x: (screenX - viewport.x) / viewport.scale, + y: (screenY - viewport.y) / viewport.scale, + }; + }; + + const duplicateLayersToPoint = ( + sourceLayers: CanvasLayer[], + canvasPoint?: { x: number; y: number }, + options: { renameCopies?: boolean } = {}, + ) => { + if (!sourceLayers.length) { + return []; + } + const minX = Math.min(...sourceLayers.map((layer) => layer.x)); + const minY = Math.min(...sourceLayers.map((layer) => layer.y)); + const maxZIndex = layersRef.current.reduce( + (maxZ, layer) => Math.max(maxZ, layer.zIndex), + 0, + ); + const stamp = Date.now(); + return sourceLayers.map((layer, index) => ({ + ...layer, + id: `layer-copy-${stamp}-${index}`, + resourceId: `local-resource-copy-${stamp}-${index}`, + title: options.renameCopies === false ? layer.title : `${layer.title} 副本`, + x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32, + y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32, + zIndex: maxZIndex + index + 1, + groupId: null, + })); + }; + + const pasteCanvasClipboard = (canvasPoint?: { x: number; y: number }) => { + if (!canvasClipboard?.layers.length) { + return; + } + const nextLayers = duplicateLayersToPoint(canvasClipboard.layers, canvasPoint, { + renameCopies: canvasClipboard.mode !== 'cut', + }); + if (!nextLayers.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + setSelectedLayerIds(nextLayers.map((layer) => layer.id)); + setSelectedLayerId(nextLayers[0]?.id ?? null); + setActiveTool('select'); + setContextMenu(null); + }; + + const copyContextLayers = (options: { cut?: boolean } = {}) => { + const targetIds = getContextTargetLayerIds(); + const targetLayers = layers.filter((layer) => targetIds.includes(layer.id)); + if (!targetLayers.length) { + return; + } + setCanvasClipboard({ + layers: targetLayers.map((layer) => ({ ...layer })), + mode: options.cut ? 'cut' : 'copy', + }); + if (options.cut) { + captureCanvasHistory(); + setLayers((currentLayers) => + currentLayers.filter((layer) => !targetIds.includes(layer.id)), + ); + selectSingleLayer(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) + ? null + : currentLayer, + ); + } + setContextMenu(null); + }; + + const duplicateContextLayers = () => { + const targetIds = getContextTargetLayerIds(); + const targetLayers = layers.filter((layer) => targetIds.includes(layer.id)); + const nextLayers = duplicateLayersToPoint(targetLayers); + if (!nextLayers.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + setSelectedLayerIds(nextLayers.map((layer) => layer.id)); + setSelectedLayerId(nextLayers[0]?.id ?? null); + setContextMenu(null); + }; + + const updateContextLayers = ( + updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer, + ) => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + currentLayers.map((layer) => + targetIds.includes(layer.id) ? updater(layer, targetIds) : layer, + ), + ); + setContextMenu(null); + }; + + const moveContextLayers = (mode: 'up' | 'down' | 'top' | 'bottom') => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + const maxZIndex = layers.reduce( + (maxZ, layer) => Math.max(maxZ, layer.zIndex), + 0, + ); + const minZIndex = layers.reduce( + (minZ, layer) => Math.min(minZ, layer.zIndex), + 0, + ); + let offsetIndex = 0; + updateContextLayers((layer) => { + if (mode === 'up') { + return { ...layer, zIndex: layer.zIndex + 1 }; + } + if (mode === 'down') { + return { ...layer, zIndex: layer.zIndex - 1 }; + } + offsetIndex += 1; + if (mode === 'top') { + return { ...layer, zIndex: maxZIndex + offsetIndex }; + } + return { ...layer, zIndex: minZIndex - (targetIds.length - offsetIndex + 1) }; + }); + }; + + const groupContextLayers = () => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + const groupId = `layer-group-${Date.now()}`; + updateContextLayers((layer) => ({ + ...layer, + groupId, + })); + }; + + const ungroupContextLayers = () => { + updateContextLayers((layer) => ({ + ...layer, + groupId: null, + })); + }; + + const toggleContextLayerVisibility = () => { + const targetIds = getContextTargetLayerIds(); + const shouldHide = layers + .filter((layer) => targetIds.includes(layer.id)) + .some((layer) => !layer.hidden); + updateContextLayers((layer) => ({ + ...layer, + hidden: shouldHide, + })); + }; + + const toggleContextLayerLock = () => { + const targetIds = getContextTargetLayerIds(); + const shouldLock = layers + .filter((layer) => targetIds.includes(layer.id)) + .some((layer) => !layer.locked); + updateContextLayers((layer) => ({ + ...layer, + locked: shouldLock, + })); + }; + + const flipContextLayers = (axis: 'x' | 'y') => { + updateContextLayers((layer) => + axis === 'x' + ? { + ...layer, + flipX: !layer.flipX, + } + : { + ...layer, + flipY: !layer.flipY, + }, + ); + }; + + const deleteContextLayers = () => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + currentLayers.filter((layer) => !targetIds.includes(layer.id)), + ); + selectSingleLayer(null); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, + ); + setContextMenu(null); + }; + + const exportContextLayer = () => { + const targetIds = getContextTargetLayerIds(); + const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); + if (!targetLayer) { + return; + } + const link = document.createElement('a'); + link.href = targetLayer.src; + link.download = `${sanitizeExportFilePart(targetLayer.title, 'canvas-layer')}.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + setContextMenu(null); + }; + + 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 updateAssetMoveDropFolder = (folderId: string | null) => { + setAssetMoveDropFolderId(folderId); + if (!folderId) { + setPinnedAssetMoveFolderId(null); + return; + } + const listElement = assetListRef.current; + const header = listElement?.querySelector( + `[data-asset-folder-header-id="${escapeCssIdentifier(folderId)}"]`, + ); + const listRect = listElement?.getBoundingClientRect(); + const headerRect = header?.getBoundingClientRect(); + setPinnedAssetMoveFolderId( + listRect && headerRect && + (headerRect.bottom < listRect.top || headerRect.top > listRect.bottom) + ? folderId + : null, + ); + }; + const addAssetLayer = ( asset: EditorAsset, position?: { x: number; y: number }, @@ -2031,11 +2899,204 @@ export function ImageCanvasEditorView() { y: position?.y ?? canvasSize.height / 2, }, ); + captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setHoveredLayerId(null); createProjectResourceForLayer(nextLayer); }; + addAssetLayerRef.current = addAssetLayer; + + const exportCanvasAssets = async () => { + if (isExportingAssets) { + return; + } + const exportableLayers = layers + .filter((layer) => layer.src.trim().length > 0) + .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 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) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + setProjectRenameError( + error instanceof Error ? error.message : '重命名项目失败', + ); + }) + .finally(() => setIsProjectRenameSaving(false)); + }; const startRenamingAsset = (asset: EditorAsset) => { setRenamingAsset({ @@ -2148,6 +3209,25 @@ export function ImageCanvasEditorView() { setAssets((currentAssets) => currentAssets.filter((currentAsset) => currentAsset.id !== asset.id), ); + setLayers((currentLayers) => + currentLayers.filter((layer) => !isLayerLinkedToAsset(layer, asset)), + ); + setSelectedLayerIds((currentIds) => + currentIds.filter((layerId) => + layers.every( + (layer) => layer.id !== layerId || !isLayerLinkedToAsset(layer, asset), + ), + ), + ); + setSelectedLayerId((currentId) => { + if (!currentId) { + return currentId; + } + const currentLayer = layers.find((layer) => layer.id === currentId); + return currentLayer && isLayerLinkedToAsset(currentLayer, asset) + ? null + : currentId; + }); setRenamingAsset((currentRename) => currentRename?.assetId === asset.id ? null : currentRename, ); @@ -2211,7 +3291,7 @@ export function ImageCanvasEditorView() { if (folder.persisted) { deleteEditorAssetFolder(folder.id) .then((library) => { - const nextLibrary = mergeAssetLibraryWithBuiltIns(library); + const nextLibrary = normalizeAssetLibrary(library); setAssetFolders(nextLibrary.folders); setAssets(nextLibrary.assets); }) @@ -2241,15 +3321,44 @@ export function ImageCanvasEditorView() { const deleteSelectedAssets = () => { const ids = [...selectedAssetIds]; + const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); setAssets((currentAssets) => currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), ); + setLayers((currentLayers) => + currentLayers.filter( + (layer) => + !deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), + ), + ); setSelectedAssetIds(new Set()); ids.forEach((assetId) => { void deleteEditorAsset(assetId); }); }; + const moveAssetToFolder = (assetId: string, folderId: string) => { + const asset = assets.find((currentAsset) => currentAsset.id === assetId); + if (!asset || asset.folderId === folderId) { + return; + } + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === assetId + ? { + ...currentAsset, + folderId, + } + : currentAsset, + ), + ); + if (asset.persisted) { + updateEditorAsset(asset.id, { folderId }).catch(() => {}); + } + }; + + moveAssetToFolderRef.current = moveAssetToFolder; + const closeAssetSelectionMode = () => { setIsAssetSelectionMode(false); setSelectedAssetIds(new Set()); @@ -2500,9 +3609,6 @@ export function ImageCanvasEditorView() { return; } - 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( @@ -2510,6 +3616,64 @@ export function ImageCanvasEditorView() { ) ? (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, @@ -2538,38 +3702,28 @@ export function ImageCanvasEditorView() { originalHeight: fallbackHeight, 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, + sourceAssetId: `upload-${uploadIndex}`, }; 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'); } + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.id === uploadedAsset.id + ? { + ...asset, + uploadProgress: 68, + uploadMessage: '上传中', + } + : asset, + ), + ); createEditorAsset({ folderId: uploadFolderId, label: uploadedAsset.label, @@ -2593,12 +3747,47 @@ 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) => + currentLayers.map((currentLayer) => + currentLayer.id === nextLayer.id + ? { + ...currentLayer, + sourceAssetId: asset.assetId, + objectKey: asset.objectKey ?? currentLayer.objectKey, + assetObjectId: + asset.assetObjectId ?? currentLayer.assetObjectId, + } + : currentLayer, + ), + ); + } }) - .catch(() => {}); + .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 (options.addToCanvas) { createProjectResourceForLayer(nextLayer); @@ -2655,7 +3844,15 @@ export function ImageCanvasEditorView() { addToCanvas?: boolean; } = {}, ) => { - Array.from(files).forEach((file, index) => { + 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, { @@ -2677,6 +3874,8 @@ export function ImageCanvasEditorView() { return; } setImageContextMenu(null); + setContextMenu(null); + captureCanvasHistory(); setLayers((currentLayers) => { const nextLayers = currentLayers.filter( (layer) => layer.id !== targetLayerId, @@ -2708,7 +3907,45 @@ export function ImageCanvasEditorView() { deleteLayerByIdRef.current = deleteLayerById; - const deleteSelectedLayer = () => deleteLayerById(selectedLayerId); + const deleteSelectedLayer = () => { + const targetIds = selectedLayerIds.length + ? selectedLayerIds + : selectedLayerId + ? [selectedLayerId] + : []; + if (targetIds.length <= 1) { + deleteLayerById(targetIds[0] ?? null); + return; + } + captureCanvasHistory(); + setImageContextMenu(null); + setContextMenu(null); + setLayers((currentLayers) => { + const nextLayers = currentLayers.filter( + (layer) => !targetIds.includes(layer.id), + ); + const nextSelectedLayer = nextLayers + .slice() + .sort((left, right) => right.zIndex - left.zIndex)[0]; + selectSingleLayer(nextSelectedLayer?.id ?? null); + return nextLayers; + }); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, + ); + setQuickEditPanel((currentPanel) => + currentPanel && targetIds.includes(currentPanel.sourceLayerId) + ? null + : currentPanel, + ); + setCharacterAnimationPanel((currentPanel) => + currentPanel && targetIds.includes(currentPanel.sourceLayerId) + ? null + : currentPanel, + ); + targetIds.forEach(removeCanvasGenerationDialogsByLayerId); + }; const openGenerateDialog = () => { const placeholderWidth = 420; @@ -3439,28 +4676,48 @@ 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'; } }; + const handleCanvasDragLeave = (event: ReactDragEvent) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setUploadDropTarget((currentTarget) => + currentTarget === 'canvas' ? null : currentTarget, + ); + } + }; + 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); + updateAssetMoveDropFolder(null); + addAssetLayer(draggedAsset, getCanvasDropPoint(event)); + return; + } const files = event.dataTransfer.files; if (!files.length) { return; } event.preventDefault(); - 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, - }; + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + const canvasPoint = getCanvasDropPoint(event); const defaultFolder = assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; addUploadedFiles(files, { @@ -3875,6 +5132,7 @@ export function ImageCanvasEditorView() { return; } const groupId = `layer-group-${Date.now()}`; + captureCanvasHistory(); setLayers((currentLayers) => currentLayers.map((layer) => targetIds.includes(layer.id) @@ -4047,6 +5305,23 @@ export function ImageCanvasEditorView() { event.currentTarget.value = ''; }} /> + {assetPointerDrag?.active ? ( + + ) : null} {activeSidebarPanel ? (