diff --git a/TRACKING.md b/TRACKING.md index de01d5e2..135297ca 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -120,3 +120,4 @@ - 2026-06-17 生成面板拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空浏览器数据后未登录刷新弹出 `账号入口`;关闭登录后 `画布背景色` 打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`;点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 跟随对话框,`AI画布工具栏` 保持可见;使用临时开发账号密码登录后上传素材成功,点击素材可添加到画布,切换 `图层` 面板可看到对应图层。 - 2026-06-17 前端拆分第五阶段:新增 `ImageCanvasLayerCommandModel`,把右键图层目标解析、复制 / 粘贴 / 创建副本、层级移动、分组 / 解组、显隐、锁定、翻转和删除的数据规则从主视图抽出;主视图只保留历史、选中态、菜单关闭、元数据清理和导出下载副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasLayerCommandModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景入口打开完整 `画布背景设置` 面板;登录后上传素材成功,点击素材可加入画布,图片右键打开 `图片功能面板`,创建副本、水平翻转、锁定和隐藏均生效,`AI画布工具栏` 保持可见。 - 2026-06-17 前端拆分第六阶段:新增 `ImageCanvasInteractionModel`,把适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动的纯规则从主视图抽出;主视图保留事件、pointer capture、history、生成对象回写、选中态和状态更新。验证命令:`npm run test -- src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材、画布图层、小地图和 `AI画布工具栏` 保持可见,Ctrl 滚轮从 110% 缩放到 121%,普通滚轮不改变缩放,浏览器控制台无 passive wheel 错误。 +- 2026-06-17 新增素材持久化修正:素材库图片、上传到画布、生成图、修改图和图标素材加入画布时会先用当前图层快照更新本地画布,再在资源创建完成后立刻保存带真实 `resourceId` 的 layout,避免资源创建异步返回时把空 `layers` 写回工程。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录弹出 `账号入口`,登录后上传素材、点击素材加入画布并刷新,画布图片和 `AI画布工具栏` 均保持可见。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 8ad86288..62195d3f 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -75,6 +75,7 @@ - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 - 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。 +- 资源持久化稳定性:新增图层时先使用当前画布图层快照更新本地状态,再等待工程资源创建并即时保存带真实 `resourceId` 的 layout。后续如果继续拆上传或生成状态机,必须保留这一时序,避免 React 状态刷新和异步资源返回交错时写回空图层。 ## 验证计划 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 106b37cf..145051f0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -242,26 +242,26 @@ describe('ImageCanvasEditorView', () => { beforeEach(() => { loadOrCreateRecentEditorProjectMock.mockImplementation(() => immediateAsync({ - projectId: 'editor-project-default', - title: '默认项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: defaultEditorProjectLayers, - resources: defaultEditorProjectResources, - updatedAt: '2026-06-12T00:00:00.000Z', + projectId: 'editor-project-default', + title: '默认项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: defaultEditorProjectLayers, + resources: defaultEditorProjectResources, + updatedAt: '2026-06-12T00:00:00.000Z', }), ); loadEditorAssetLibraryMock.mockImplementation(() => immediateAsync({ - folders: [ - { - folderId: 'project', - label: '项目素材', - sortOrder: 0, - collapsed: false, - systemDefault: true, - }, - ], - assets: defaultEditorAssetLibraryAssets, + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: defaultEditorAssetLibraryAssets, }), ); createEditorAssetMock.mockImplementation(async (input) => ({ @@ -378,7 +378,9 @@ describe('ImageCanvasEditorView', () => { it('shows the loaded project title and a topbar entry back to projects', async () => { render(); - expect(await screen.findByRole('heading', { name: '默认项目' })).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: '默认项目' }), + ).toBeTruthy(); const projectLink = screen.getByRole('link', { name: '返回项目页面' }); expect(projectLink.getAttribute('href')).toBe('/project'); @@ -422,7 +424,9 @@ describe('ImageCanvasEditorView', () => { '新画布项目', ); }); - expect(await screen.findByRole('heading', { name: '新画布项目' })).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: '新画布项目' }), + ).toBeTruthy(); }); it('does not inject built-in mock assets when the persisted library is empty', async () => { @@ -974,7 +978,9 @@ describe('ImageCanvasEditorView', () => { expect(openLoginModal).toHaveBeenCalledTimes(1); expect(createEditorAssetMock).not.toHaveBeenCalled(); - expect(screen.queryByRole('button', { name: '上传失败登录后上传.png' })).toBeNull(); + expect( + screen.queryByRole('button', { name: '上传失败登录后上传.png' }), + ).toBeNull(); const resumeUpload = openLoginModal.mock.calls[0]?.[0]; expect(typeof resumeUpload).toBe('function'); @@ -1027,7 +1033,9 @@ describe('ImageCanvasEditorView', () => { expect( await screen.findByLabelText('素材素材上传进度.png上传进度'), ).toBeTruthy(); - expect(screen.getByRole('button', { name: '上传中素材上传进度.png' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '上传中素材上传进度.png' }), + ).toBeTruthy(); deferredAsset.resolve({ assetId: 'asset-upload-progress', @@ -1044,9 +1052,7 @@ describe('ImageCanvasEditorView', () => { screen.getByRole('button', { name: '添加素材上传进度.png' }), ).toBeTruthy(); }); - expect( - screen.queryByLabelText('素材素材上传进度.png上传进度'), - ).toBeNull(); + expect(screen.queryByLabelText('素材素材上传进度.png上传进度')).toBeNull(); }); it('opens login when asset creation returns unauthorized during upload', async () => { @@ -1212,6 +1218,69 @@ describe('ImageCanvasEditorView', () => { expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b'); }); + it('saves a library asset layer right after creating its canvas resource', async () => { + const user = userEvent.setup(); + createEditorProjectResourceMock.mockResolvedValueOnce({ + resourceId: 'resource-added-asset-a', + projectId: 'editor-project-default', + imageSrc: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + sourceType: 'uploaded', + }); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + 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: [ + { + assetId: 'asset-a', + folderId: 'project', + label: '账号素材A', + imageSrc: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + ], + }); + render(); + + await user.click( + await screen.findByRole('button', { name: '添加账号素材A' }), + ); + + expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy(); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: '账号素材A', + resourceId: 'resource-added-asset-a', + sourceAssetId: 'asset-a', + }), + ]), + }), + ); + }); + }); + it('selects multiple assets with a marquee in asset selection mode', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ @@ -1601,8 +1670,11 @@ describe('ImageCanvasEditorView', () => { const menu = screen.getByRole('menu', { name: '画布右键菜单' }); expect( - (within(menu).getByRole('menuitem', { name: '粘贴' }) as HTMLButtonElement) - .disabled, + ( + within(menu).getByRole('menuitem', { + name: '粘贴', + }) as HTMLButtonElement + ).disabled, ).toBe(true); expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy(); expect( @@ -1656,11 +1728,15 @@ describe('ImageCanvasEditorView', () => { }); const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' }); expect( - (within(copyPasteMenu).getByRole('menuitem', { - name: '粘贴', - }) as HTMLButtonElement).disabled, + ( + within(copyPasteMenu).getByRole('menuitem', { + name: '粘贴', + }) as HTMLButtonElement + ).disabled, ).toBe(false); - fireEvent.click(within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' })); + fireEvent.click( + within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }), + ); expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); fireEvent.contextMenu( @@ -1885,7 +1961,9 @@ describe('ImageCanvasEditorView', () => { ).toBeTruthy(); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' })); - expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy(); + expect( + screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), + ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' })); @@ -1915,25 +1993,23 @@ describe('ImageCanvasEditorView', () => { }); expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); - fireEvent.click(within(settingsPanel).getByRole('button', { name: '暖灰' })); + 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' }, - }, - ); + fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), { + target: { value: '#ffffff' }, + }); expect((viewport as HTMLElement).style.backgroundColor).toBe( 'rgb(255, 255, 255)', ); - const hexInput = within(settingsPanel).getByLabelText( - '画布背景十六进制颜色', - ); + 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( @@ -1954,9 +2030,7 @@ describe('ImageCanvasEditorView', () => { ); fireEvent.keyDown(window, { key: 'Escape' }); - expect( - screen.queryByRole('dialog', { name: '画布背景设置' }), - ).toBeNull(); + expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull(); fireEvent.click( within(panelToolbar).getByRole('button', { name: '切换小地图' }), @@ -2927,7 +3001,9 @@ describe('ImageCanvasEditorView', () => { fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { target: { value: '高个子游侠' }, }); - fireEvent.click(within(characterPanel).getByRole('button', { name: '生成' })); + fireEvent.click( + within(characterPanel).getByRole('button', { name: '生成' }), + ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( @@ -2937,9 +3013,7 @@ describe('ImageCanvasEditorView', () => { }), ); }); - expect( - generateEditorImageMock.mock.calls[0]?.[0], - ).not.toEqual( + expect(generateEditorImageMock.mock.calls[0]?.[0]).not.toEqual( expect.objectContaining({ aspectRatio: expect.any(String), imageSize: expect.any(String), @@ -3019,12 +3093,16 @@ describe('ImageCanvasEditorView', () => { const characterFrame = screen.getByLabelText('角色生成占位图'); expect(characterFrame).toBeTruthy(); - dispatchPointerEvent(screen.getByLabelText('图像生成占位图'), 'pointerdown', { - button: 0, - pointerId: 1702, - clientX: 500, - clientY: 260, - }); + dispatchPointerEvent( + screen.getByLabelText('图像生成占位图'), + 'pointerdown', + { + button: 0, + pointerId: 1702, + clientX: 500, + clientY: 260, + }, + ); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 1702, clientX: 650, @@ -3036,9 +3114,7 @@ describe('ImageCanvasEditorView', () => { clientY: 390, }); const movedFrame = screen.getByLabelText('图像生成占位图'); - const movedLeft = Number.parseFloat( - (movedFrame as HTMLElement).style.left, - ); + const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left); const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top); expect(movedLeft).toBeGreaterThan(originalLeft); expect(movedTop).toBeGreaterThan(originalTop); @@ -3077,9 +3153,13 @@ describe('ImageCanvasEditorView', () => { .getByAltText(/画布图片:生成图片/) .closest('button') as HTMLElement; const expectedLayerLeft = - movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512; + movedLeft + + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - + 512; const expectedLayerTop = - movedTop + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - 512; + movedTop + + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - + 512; expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo( expectedLayerLeft, 1, @@ -3160,7 +3240,9 @@ describe('ImageCanvasEditorView', () => { iconPanel.querySelector('.image-canvas-editor__icon-spec-card'), ).toBeTruthy(); - fireEvent.click(within(iconPanel).getByRole('button', { name: '添加素材描述' })); + fireEvent.click( + within(iconPanel).getByRole('button', { name: '添加素材描述' }), + ); expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1); expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7); @@ -4358,12 +4440,16 @@ describe('ImageCanvasEditorView', () => { }); expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy(); - expect(within(editedMetadataDialog).getByText('把画面改成黄昏光线')).toBeTruthy(); + expect( + within(editedMetadataDialog).getByText('把画面改成黄昏光线'), + ).toBeTruthy(); expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); expect( within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), ).toBeTruthy(); - expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy(); + expect( + screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), + ).toBeTruthy(); }); it('hides the edit image panel after generation starts while keeping the source preview visible', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ff56cc3a..339af6fb 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,10 +1,4 @@ -import { - Check, - ChevronLeft, - Download, - Pencil, - X, -} from 'lucide-react'; +import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react'; import JSZip from 'jszip'; import { type CSSProperties, @@ -272,7 +266,10 @@ export function ImageCanvasEditorView() { const pendingProjectResourceLayersRef = useRef< Array<{ layer: CanvasLayer; - options: { onCreated?: (resourceId: string) => void }; + options: { + onCreated?: (resourceId: string) => void; + snapshotLayers?: CanvasLayer[]; + }; }> >([]); const selectedLayerIdRef = useRef(null); @@ -373,8 +370,9 @@ export function ImageCanvasEditorView() { useState(false); const [imageContextMenu, setImageContextMenu] = useState(null); - const [contextMenu, setContextMenu] = - useState(null); + const [contextMenu, setContextMenu] = useState( + null, + ); const [canvasClipboard, setCanvasClipboard] = useState(null); const [historyVersion, setHistoryVersion] = useState(0); @@ -447,12 +445,11 @@ export function ImageCanvasEditorView() { : null, [activeCanvasGenerationDialog, layers], ); - const generationAnchor = - activeCanvasGenerationDialog - ? (activeGenerationLayer ?? - activeCanvasGenerationDialog.placeholder ?? - null) - : null; + const generationAnchor = activeCanvasGenerationDialog + ? (activeGenerationLayer ?? + activeCanvasGenerationDialog.placeholder ?? + null) + : null; const generationComposerStyle = activeCanvasGenerationDialog?.status !== 'generating' && activeCanvasGenerationDialog?.composerOpen !== false && @@ -630,8 +627,7 @@ export function ImageCanvasEditorView() { ) => CanvasGenerationDialogState | null, ) => { setGenerateDialog((currentDialog) => - isCanvasGenerationDialog(currentDialog) && - currentDialog.id === dialogId + isCanvasGenerationDialog(currentDialog) && currentDialog.id === dialogId ? updater(currentDialog) : currentDialog, ); @@ -707,7 +703,9 @@ export function ImageCanvasEditorView() { inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map( (dialog) => ({ ...dialog, - placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + placeholder: dialog.placeholder + ? { ...dialog.placeholder } + : undefined, }), ), selectedLayerId: selectedLayerIdRef.current, @@ -733,7 +731,9 @@ export function ImageCanvasEditorView() { setInactiveGenerateDialogs( snapshot.inactiveGenerateDialogs.map((dialog) => ({ ...dialog, - placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + placeholder: dialog.placeholder + ? { ...dialog.placeholder } + : undefined, })), ); setSelectedLayerId(snapshot.selectedLayerId); @@ -859,7 +859,10 @@ export function ImageCanvasEditorView() { const createProjectResourceForLayer = useCallback( ( layer: CanvasLayer, - options: { onCreated?: (resourceId: string) => void } = {}, + options: { + onCreated?: (resourceId: string) => void; + snapshotLayers?: CanvasLayer[]; + } = {}, ) => { const readyProjectId = projectIdRef.current; if (!readyProjectId) { @@ -882,16 +885,40 @@ export function ImageCanvasEditorView() { }) .then((resource) => { options.onCreated?.(resource.resourceId); - setLayers((currentLayers) => - currentLayers.map((currentLayer) => - currentLayer.id === layer.id - ? { - ...currentLayer, - resourceId: resource.resourceId, - } - : currentLayer, - ), - ); + const layerWithResourceId = { + ...layer, + resourceId: resource.resourceId, + }; + const currentLayers = layersRef.current; + const nextLayers = currentLayers.some( + (currentLayer) => currentLayer.id === layer.id, + ) + ? currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? layerWithResourceId + : currentLayer, + ) + : options.snapshotLayers?.some( + (snapshotLayer) => snapshotLayer.id === layer.id, + ) + ? options.snapshotLayers.map((snapshotLayer) => + snapshotLayer.id === layer.id + ? layerWithResourceId + : snapshotLayer, + ) + : currentLayers; + layersRef.current = nextLayers; + setLayers(nextLayers); + if (nextLayers.length) { + void saveEditorProjectLayout(readyProjectId, { + viewport: viewportRef.current, + layers: nextLayers.map(serializeLayer), + }).catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); + } }) .catch((error: unknown) => { if (isEditorAuthError(error)) { @@ -1019,8 +1046,8 @@ export function ImageCanvasEditorView() { return () => observer.disconnect(); }, []); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { if ( (event.ctrlKey || event.metaKey) && event.code === 'KeyZ' && @@ -1034,9 +1061,9 @@ export function ImageCanvasEditorView() { } return; } - if (event.key === 'Shift') { - isShiftPressedRef.current = true; - } + if (event.key === 'Shift') { + isShiftPressedRef.current = true; + } if ( (event.key === 'Backspace' || event.key === 'Delete') && !event.repeat && @@ -1070,9 +1097,9 @@ export function ImageCanvasEditorView() { if (event.key === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); - setIsBackgroundSettingsOpen(false); - setIsSpecMenuOpen(false); - setImageContextMenu(null); + setIsBackgroundSettingsOpen(false); + setIsSpecMenuOpen(false); + setImageContextMenu(null); setContextMenu(null); setQuickEditPanel((currentPanel) => currentPanel?.status === 'generating' ? currentPanel : null, @@ -1325,9 +1352,13 @@ export function ImageCanvasEditorView() { if (!canvasClipboard?.layers.length) { return; } - const nextLayers = duplicateLayersToPoint(canvasClipboard.layers, canvasPoint, { - renameCopies: canvasClipboard.mode !== 'cut', - }); + const nextLayers = duplicateLayersToPoint( + canvasClipboard.layers, + canvasPoint, + { + renameCopies: canvasClipboard.mode !== 'cut', + }, + ); if (!nextLayers.length) { return; } @@ -1352,7 +1383,9 @@ export function ImageCanvasEditorView() { setCanvasClipboard(clipboard); if (options.cut) { captureCanvasHistory(); - setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds)); + setLayers((currentLayers) => + removeCanvasLayers(currentLayers, targetIds), + ); selectSingleLayer(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) @@ -1534,13 +1567,29 @@ export function ImageCanvasEditorView() { const listRect = listElement?.getBoundingClientRect(); const headerRect = header?.getBoundingClientRect(); setPinnedAssetMoveFolderId( - listRect && headerRect && + listRect && + headerRect && (headerRect.bottom < listRect.top || headerRect.top > listRect.bottom) ? folderId : null, ); }; + const appendCanvasLayersWithResources = useCallback( + (nextLayers: CanvasLayer[]) => { + if (!nextLayers.length) { + return; + } + const snapshotLayers = [...layersRef.current, ...nextLayers]; + layersRef.current = snapshotLayers; + setLayers(snapshotLayers); + nextLayers.forEach((layer) => + createProjectResourceForLayer(layer, { snapshotLayers }), + ); + }, + [createProjectResourceForLayer], + ); + const addAssetLayer = ( asset: EditorAsset, position?: { x: number; y: number }, @@ -1557,10 +1606,9 @@ export function ImageCanvasEditorView() { }, ); captureCanvasHistory(); - setLayers((currentLayers) => [...currentLayers, nextLayer]); + appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); setHoveredLayerId(null); - createProjectResourceForLayer(nextLayer); }; addAssetLayerRef.current = addAssetLayer; @@ -1609,7 +1657,10 @@ export function ImageCanvasEditorView() { try { const blob = await readLayerImageBlob(layer); - const extension = getImageExtensionFromTypeOrSrc(blob.type, layer.src); + const extension = getImageExtensionFromTypeOrSrc( + blob.type, + layer.src, + ); const file = `images/${indexedFileName}.${extension}`; imageByKey.set(key, { key, @@ -1872,7 +1923,8 @@ export function ImageCanvasEditorView() { setSelectedLayerIds((currentIds) => currentIds.filter((layerId) => layers.every( - (layer) => layer.id !== layerId || !isLayerLinkedToAsset(layer, asset), + (layer) => + layer.id !== layerId || !isLayerLinkedToAsset(layer, asset), ), ), ); @@ -1978,7 +2030,9 @@ export function ImageCanvasEditorView() { const deleteSelectedAssets = () => { const ids = [...selectedAssetIds]; - const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); + const deletedAssets = assets.filter((asset) => + selectedAssetIds.has(asset.id), + ); setAssets((currentAssets) => currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), ); @@ -2363,7 +2417,7 @@ export function ImageCanvasEditorView() { }; if (options.addToCanvas) { - setLayers((currentLayers) => [...currentLayers, nextLayer]); + appendCanvasLayersWithResources([nextLayer]); } if (options.addToCanvas) { selectSingleLayer(nextLayer.id); @@ -2446,10 +2500,6 @@ export function ImageCanvasEditorView() { ); }); - if (options.addToCanvas) { - createProjectResourceForLayer(nextLayer); - } - if (imageSrc) { const uploadedImage = new Image(); uploadedImage.onload = () => { @@ -2825,7 +2875,7 @@ export function ImageCanvasEditorView() { generationInputs: options.generationInputs, }; - setLayers((currentLayers) => [...currentLayers, nextLayer]); + appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (options.sourceLayer) { @@ -2848,7 +2898,6 @@ export function ImageCanvasEditorView() { if (options.sourceLayer) { fitLayers([options.sourceLayer, nextLayer]); } - createProjectResourceForLayer(nextLayer); }; const addQuickEditResultLayer = ( @@ -2859,7 +2908,8 @@ export function ImageCanvasEditorView() { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const originalWidth = generated.width || sourceLayer.originalWidth || 1024; - const originalHeight = generated.height || sourceLayer.originalHeight || 1024; + const originalHeight = + generated.height || sourceLayer.originalHeight || 1024; const { width, height } = resolveLayerResolutionSize( originalWidth, originalHeight, @@ -2894,13 +2944,12 @@ export function ImageCanvasEditorView() { generationInputs, }; - setLayers((currentLayers) => [...currentLayers, nextLayer]); + appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); setQuickEditPanel(null); setActiveTool('select'); fitLayers([sourceLayer, nextLayer]); - createProjectResourceForLayer(nextLayer); }; const addIconSpritesheetResultLayers = ( @@ -2970,14 +3019,13 @@ export function ImageCanvasEditorView() { if (!nextLayers.length) { return; } - setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + appendCanvasLayersWithResources(nextLayers); selectSingleLayer(nextLayers[0]?.id ?? null); setActiveSidebarPanel('layers'); if (dialogId) { removeCanvasGenerationDialogById(dialogId); } setActiveTool('select'); - nextLayers.forEach((layer) => createProjectResourceForLayer(layer)); }; const updateIconDescription = (index: number, value: string) => { @@ -3101,8 +3149,9 @@ export function ImageCanvasEditorView() { }); try { - const referenceImageSrc = - await resolveEditorImageReferenceDataUrl(quickEditSourceLayer.src); + const referenceImageSrc = await resolveEditorImageReferenceDataUrl( + quickEditSourceLayer.src, + ); const generated = await generateEditorImage({ prompt: normalizedPrompt, size: quickEditPanel.size, @@ -3388,9 +3437,7 @@ export function ImageCanvasEditorView() { }); }; - const handleCanvasContextMenu = ( - event: ReactMouseEvent, - ) => { + const handleCanvasContextMenu = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); const position = resolveContextMenuPosition( @@ -4241,14 +4288,18 @@ export function ImageCanvasEditorView() { setCharacterAnimationPanel={setCharacterAnimationPanel} setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen} setIsIconSpecMenuOpen={setIsIconSpecMenuOpen} - setIsPickingCharacterSpecFromCanvas={setIsPickingCharacterSpecFromCanvas} + setIsPickingCharacterSpecFromCanvas={ + setIsPickingCharacterSpecFromCanvas + } setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas} onOpenSpecDialog={openSpecDialog} onRequestUpload={(target) => { setUploadTarget(target); uploadInputRef.current?.click(); }} - onSubmitImageGeneration={(dialog) => void submitImageGeneration(dialog)} + onSubmitImageGeneration={(dialog) => + void submitImageGeneration(dialog) + } onSubmitIconSpritesheetGeneration={(dialog) => void submitIconSpritesheetGeneration(dialog) } @@ -4268,9 +4319,10 @@ export function ImageCanvasEditorView() { onUpdateSpecFormValue={updateSpecFormValue} onUpdateIconDescription={updateIconDescription} onAddIconDescription={addIconDescription} - onUpdateCharacterAnimationDuration={updateCharacterAnimationDuration} + onUpdateCharacterAnimationDuration={ + updateCharacterAnimationDuration + } /> - @@ -4311,7 +4363,11 @@ export function ImageCanvasEditorView() { key={`${reference.title}-${reference.label}-${reference.src}`} className="image-canvas-editor__metadata-reference-card" > - + {reference.title}