From 1924b86b92803fef4658c71bc30c31ca8e26b520 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 26 Jun 2026 17:42:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=BB=E5=B8=83=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E5=89=AA=E8=B4=B4=E6=9D=BF=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画布 paste 事件支持读取剪贴板图片并复用上传流程添加图层 保留画布内部复制粘贴优先级 补充剪贴板图片粘贴与内部粘贴优先级回归测试 --- ...mageCanvasEditorAssetsIntegration.test.tsx | 91 +++++++++++++++++++ .../image-editor/ImageCanvasEditorView.tsx | 53 ++++++++++- .../useImageCanvasKeyboardShortcuts.test.tsx | 8 +- .../useImageCanvasKeyboardShortcuts.ts | 8 -- .../useImageCanvasLayerCommands.ts | 17 ++-- 5 files changed, 154 insertions(+), 23 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 8aae3250f..86e82cc4d 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -1095,6 +1095,97 @@ describe('ImageCanvasEditorView asset library integration', () => { ).toBe(true); }); + it('pastes a clipboard image onto the canvas through the upload workflow', async () => { + render(); + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + const imageFile = new File(['image'], '剪贴板素材.png', { + type: 'image/png', + }); + const pasteEvent = new Event('paste', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + files: [imageFile], + items: [], + }, + }); + + window.dispatchEvent(pasteEvent); + + await waitFor(() => { + expect(screen.getByAltText('画布图片:剪贴板素材.png')).toBeTruthy(); + }); + expect(pasteEvent.defaultPrevented).toBe(true); + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + imageFile, + 'image', + ); + expect(createEditorAssetMock).toHaveBeenCalledWith( + expect.objectContaining({ + label: '剪贴板素材.png', + imageSrc: + '/generated-character-drafts/editor/asset-library/image/剪贴板素材.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image/剪贴板素材.png', + assetObjectId: 'assetobj-editor-image', + }), + ); + expect( + screen + .getByRole('button', { name: '选择剪贴板素材.png' }) + .className.includes('image-canvas-editor__layer--selected'), + ).toBe(true); + }); + + it('keeps canvas clipboard paste ahead of system clipboard images', async () => { + render(); + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + fireEvent.contextMenu( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + clientX: 510, + clientY: 330, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); + + const systemImageFile = new File(['image'], '系统剪贴板素材.png', { + type: 'image/png', + }); + const pasteEvent = new Event('paste', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + files: [systemImageFile], + items: [], + }, + }); + + act(() => { + window.dispatchEvent(pasteEvent); + }); + + expect(pasteEvent.defaultPrevented).toBe(true); + await waitFor(() => { + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + }); + expect(screen.queryByAltText('画布图片:系统剪贴板素材.png')).toBeNull(); + expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalledWith( + systemImageFile, + 'image', + ); + }); + it('drops files into the asset panel only once without creating canvas layers', async () => { render(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ce0961810..6854f3cd7 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -46,7 +46,7 @@ import type { ImageContextMenuState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; -import { getEditorUploadAccept } from './ImageCanvasFileModel'; +import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel'; import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel'; import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; import { @@ -87,6 +87,32 @@ function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) { return layer.src; } +function isEditablePasteTarget(target: EventTarget | null) { + const element = target instanceof HTMLElement ? target : null; + if (!element) { + return false; + } + return ( + element.tagName === 'INPUT' || + element.tagName === 'TEXTAREA' || + element.isContentEditable + ); +} + +function getClipboardImageFiles(clipboardData: DataTransfer | null) { + if (!clipboardData) { + return []; + } + const files = Array.from(clipboardData.files ?? []).filter(isImageFile); + if (files.length) { + return files; + } + return Array.from(clipboardData.items ?? []) + .filter((item) => item.kind === 'file' && item.type.startsWith('image/')) + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null && isImageFile(file)); +} + export function ImageCanvasEditorView() { const authUi = useAuthUi(); const [, setGenerationPricingVersion] = useState(0); @@ -1199,7 +1225,6 @@ export function ImageCanvasEditorView() { selectAllCanvasObjects, clearCanvasSelection, copySelectedCanvasLayers: copySelectedLayers, - pasteCanvasClipboard, duplicateSelectedCanvasLayers: duplicateSelectedLayers, moveSelectedCanvasLayers: moveSelectedLayers, nudgeSelectedCanvasObjects, @@ -1237,6 +1262,30 @@ export function ImageCanvasEditorView() { setShiftPressed, }); + useEffect(() => { + const handleClipboardPaste = (event: ClipboardEvent) => { + if (isEditablePasteTarget(event.target)) { + return; + } + const didPasteCanvasClipboard = pasteCanvasClipboard(); + if (didPasteCanvasClipboard) { + event.preventDefault(); + return; + } + const imageFiles = getClipboardImageFiles(event.clipboardData); + if (imageFiles.length) { + event.preventDefault(); + addUploadedFiles(imageFiles, { addToCanvas: true }); + return; + } + }; + + window.addEventListener('paste', handleClipboardPaste); + return () => { + window.removeEventListener('paste', handleClipboardPaste); + }; + }, [addUploadedFiles, pasteCanvasClipboard]); + useEffect(() => { const blockBrowserZoom = (event: WheelEvent) => { const editorElement = editorRootRef.current; diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 4a846c9ec..e6a3fbc88 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -65,7 +65,6 @@ function KeyboardShortcutsHarness({ selectAllCanvasObjects = vi.fn(), clearCanvasSelection = vi.fn(), copySelectedCanvasLayers = vi.fn(), - pasteCanvasClipboard = vi.fn(), duplicateSelectedCanvasLayers = vi.fn(), moveSelectedCanvasLayers = vi.fn(), nudgeSelectedCanvasObjects = vi.fn(), @@ -92,7 +91,6 @@ function KeyboardShortcutsHarness({ selectAllCanvasObjects?: () => void; clearCanvasSelection?: () => void; copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void; - pasteCanvasClipboard?: () => void; duplicateSelectedCanvasLayers?: () => void; moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void; nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void; @@ -161,7 +159,6 @@ function KeyboardShortcutsHarness({ selectAllCanvasObjects, clearCanvasSelection, copySelectedCanvasLayers, - pasteCanvasClipboard, duplicateSelectedCanvasLayers, moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, @@ -290,17 +287,15 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(undoCanvasChange).toHaveBeenCalledTimes(1); }); - it('routes Windows edit shortcuts to selected layer commands', () => { + it('routes Windows edit shortcuts to selected layer commands except paste', () => { const selectAllCanvasObjects = vi.fn(); const copySelectedCanvasLayers = vi.fn(); - const pasteCanvasClipboard = vi.fn(); const duplicateSelectedCanvasLayers = vi.fn(); const exportCanvasAssets = vi.fn(); render( , @@ -323,7 +318,6 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(selectAllCanvasObjects).toHaveBeenCalledTimes(1); expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(1); expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(2, { cut: true }); - expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1); expect(duplicateSelectedCanvasLayers).toHaveBeenCalledTimes(1); expect(exportCanvasAssets).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index 6d5abf70f..ba06f08ea 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -19,7 +19,6 @@ type UseImageCanvasKeyboardShortcutsOptions = { selectAllCanvasObjects?: () => void; clearCanvasSelection?: () => void; copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void; - pasteCanvasClipboard?: () => void; duplicateSelectedCanvasLayers?: () => void; moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void; nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void; @@ -161,7 +160,6 @@ export function useImageCanvasKeyboardShortcuts({ selectAllCanvasObjects, clearCanvasSelection, copySelectedCanvasLayers, - pasteCanvasClipboard, duplicateSelectedCanvasLayers, moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, @@ -272,11 +270,6 @@ export function useImageCanvasKeyboardShortcuts({ copySelectedCanvasLayers?.(); return; } - if (event.code === 'KeyV') { - event.preventDefault(); - pasteCanvasClipboard?.(); - return; - } if (event.code === 'KeyX') { event.preventDefault(); copySelectedCanvasLayers?.({ cut: true }); @@ -480,7 +473,6 @@ export function useImageCanvasKeyboardShortcuts({ generateDialogRef, moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, - pasteCanvasClipboard, requestRemoveCanvasGenerationDialog, redoCanvasChange, selectAllCanvasObjects, diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 467d648c6..f34f4c49b 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -2,6 +2,7 @@ import { type Dispatch, type SetStateAction, useCallback, + useRef, useState, } from 'react'; @@ -83,6 +84,7 @@ export function useImageCanvasLayerCommands({ }: LayerCommandsOptions) { const [canvasClipboard, setCanvasClipboard] = useState(null); + const canvasClipboardRef = useRef(null); const getContextTargetLayerIds = useCallback( (menu: CanvasContextMenuState | null = contextMenu) => @@ -115,18 +117,19 @@ export function useImageCanvasLayerCommands({ const pasteCanvasClipboard = useCallback( (canvasPoint?: { x: number; y: number }) => { - if (!canvasClipboard?.layers.length) { - return; + const clipboard = canvasClipboardRef.current; + if (!clipboard?.layers.length) { + return false; } const nextLayers = duplicateLayersToPoint( - canvasClipboard.layers, + clipboard.layers, canvasPoint, { - renameCopies: canvasClipboard.mode !== 'cut', + renameCopies: clipboard.mode !== 'cut', }, ); if (!nextLayers.length) { - return; + return false; } captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); @@ -134,9 +137,9 @@ export function useImageCanvasLayerCommands({ setSelectedLayerId(nextLayers[0]?.id ?? null); setActiveTool('select'); setContextMenu(null); + return true; }, [ - canvasClipboard, captureCanvasHistory, duplicateLayersToPoint, setActiveTool, @@ -158,6 +161,7 @@ export function useImageCanvasLayerCommands({ if (!clipboard) { return; } + canvasClipboardRef.current = clipboard; setCanvasClipboard(clipboard); if (options.cut) { captureCanvasHistory(); @@ -195,6 +199,7 @@ export function useImageCanvasLayerCommands({ if (!clipboard) { return; } + canvasClipboardRef.current = clipboard; setCanvasClipboard(clipboard); if (options.cut) { captureCanvasHistory();