支持画布粘贴剪贴板图片
画布 paste 事件支持读取剪贴板图片并复用上传流程添加图层 保留画布内部复制粘贴优先级 补充剪贴板图片粘贴与内部粘贴优先级回归测试
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user