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();