支持画布粘贴剪贴板图片

画布 paste 事件支持读取剪贴板图片并复用上传流程添加图层

保留画布内部复制粘贴优先级

补充剪贴板图片粘贴与内部粘贴优先级回归测试
This commit is contained in:
2026-06-26 17:42:35 +08:00
parent a8c3f8cf0f
commit 1924b86b92
5 changed files with 154 additions and 23 deletions
@@ -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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
@@ -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;
@@ -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(
<KeyboardShortcutsHarness
selectAllCanvasObjects={selectAllCanvasObjects}
copySelectedCanvasLayers={copySelectedCanvasLayers}
pasteCanvasClipboard={pasteCanvasClipboard}
duplicateSelectedCanvasLayers={duplicateSelectedCanvasLayers}
exportCanvasAssets={exportCanvasAssets}
/>,
@@ -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);
});
@@ -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,
@@ -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<CanvasClipboard | null>(null);
const canvasClipboardRef = useRef<CanvasClipboard | null>(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();