diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx index 3819db426..4b05224f9 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -120,6 +120,7 @@ describe('ImageCanvasContextMenusView', () => { fireEvent.click( screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }), ); + fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' })); fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' })); fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); @@ -138,9 +139,10 @@ describe('ImageCanvasContextMenusView', () => { 2, expect.objectContaining({ mode: 'spine-json' }), ); + expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer); expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); - expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1); - expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); + expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2); expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1); }); @@ -170,7 +172,7 @@ describe('ImageCanvasContextMenusView', () => { expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer); expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer); - expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2); expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id); }); }); diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index 99c3c5182..57d70cdd1 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -286,7 +286,11 @@ export function ImageCanvasContextMenusView({ @@ -342,7 +346,10 @@ export function ImageCanvasContextMenusView({ onOpenQuickEditPanel(imageContextMenuLayer)} + onClick={() => { + onOpenQuickEditPanel(imageContextMenuLayer); + onCloseImageContextMenu(); + }} > 快速编辑 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 6854f3cd7..32e6fa65a 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1225,6 +1225,7 @@ export function ImageCanvasEditorView() { selectAllCanvasObjects, clearCanvasSelection, copySelectedCanvasLayers: copySelectedLayers, + pasteCanvasClipboard, duplicateSelectedCanvasLayers: duplicateSelectedLayers, moveSelectedCanvasLayers: moveSelectedLayers, nudgeSelectedCanvasObjects, diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index e6a3fbc88..22e74f157 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -65,6 +65,7 @@ function KeyboardShortcutsHarness({ selectAllCanvasObjects = vi.fn(), clearCanvasSelection = vi.fn(), copySelectedCanvasLayers = vi.fn(), + pasteCanvasClipboard = vi.fn(), duplicateSelectedCanvasLayers = vi.fn(), moveSelectedCanvasLayers = vi.fn(), nudgeSelectedCanvasObjects = vi.fn(), @@ -91,6 +92,7 @@ function KeyboardShortcutsHarness({ selectAllCanvasObjects?: () => void; clearCanvasSelection?: () => void; copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void; + pasteCanvasClipboard?: () => boolean; duplicateSelectedCanvasLayers?: () => void; moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void; nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void; @@ -159,6 +161,7 @@ function KeyboardShortcutsHarness({ selectAllCanvasObjects, clearCanvasSelection, copySelectedCanvasLayers, + pasteCanvasClipboard, duplicateSelectedCanvasLayers, moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, @@ -287,15 +290,17 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(undoCanvasChange).toHaveBeenCalledTimes(1); }); - it('routes Windows edit shortcuts to selected layer commands except paste', () => { + it('routes Windows edit shortcuts to selected layer commands and canvas paste', () => { const selectAllCanvasObjects = vi.fn(); const copySelectedCanvasLayers = vi.fn(); + const pasteCanvasClipboard = vi.fn(() => true); const duplicateSelectedCanvasLayers = vi.fn(); const exportCanvasAssets = vi.fn(); render( , @@ -318,10 +323,30 @@ 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); }); + it('leaves system paste available when the canvas clipboard is empty', () => { + const pasteCanvasClipboard = vi.fn(() => false); + render( + , + ); + + const pasteEvent = new KeyboardEvent('keydown', { + key: 'v', + code: 'KeyV', + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(pasteEvent); + + expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1); + expect(pasteEvent.defaultPrevented).toBe(false); + }); + it('routes Windows view and layer ordering shortcuts', () => { const fitLayers = vi.fn(); const updateScaleFromCenter = vi.fn(); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index ba06f08ea..1a728bc2e 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -19,6 +19,7 @@ type UseImageCanvasKeyboardShortcutsOptions = { selectAllCanvasObjects?: () => void; clearCanvasSelection?: () => void; copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void; + pasteCanvasClipboard?: () => boolean; duplicateSelectedCanvasLayers?: () => void; moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void; nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void; @@ -160,6 +161,7 @@ export function useImageCanvasKeyboardShortcuts({ selectAllCanvasObjects, clearCanvasSelection, copySelectedCanvasLayers, + pasteCanvasClipboard, duplicateSelectedCanvasLayers, moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, @@ -275,6 +277,12 @@ export function useImageCanvasKeyboardShortcuts({ copySelectedCanvasLayers?.({ cut: true }); return; } + if (event.code === 'KeyV') { + if (pasteCanvasClipboard?.()) { + event.preventDefault(); + } + return; + } if (event.code === 'KeyD') { event.preventDefault(); duplicateSelectedCanvasLayers?.(); @@ -465,6 +473,7 @@ export function useImageCanvasKeyboardShortcuts({ closeEditorChromePanels, clearCanvasSelection, copySelectedCanvasLayers, + pasteCanvasClipboard, duplicateSelectedCanvasLayers, deleteSelectedCanvasObjects, deleteLayerById, diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index b78863f17..c54d722e0 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -251,6 +251,7 @@ describe('useImageCanvasLayerCommands', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); expect(screen.getByTestId('context').textContent).toBe('closed'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); fireEvent.click(screen.getByRole('button', { name: '粘贴' })); expect(screen.getByTestId('layers').textContent).toContain( @@ -263,6 +264,7 @@ describe('useImageCanvasLayerCommands', () => { 'layer-copy-', ); expect(screen.getByTestId('tool').textContent).toBe('select'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('2'); fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); fireEvent.click(screen.getByRole('button', { name: '只选第三层' })); @@ -271,6 +273,7 @@ describe('useImageCanvasLayerCommands', () => { expect(screen.getByTestId('clipboard').textContent).toBe('cut:1'); expect(screen.getByTestId('layers').textContent).not.toContain('third'); expect(screen.getByTestId('selection').textContent).toBe('-:'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('3'); expect(Number(screen.getByTestId('history').textContent)).toBeGreaterThan( 1, ); @@ -336,7 +339,9 @@ describe('useImageCanvasLayerCommands', () => { expect.any(Object), ); expect(screen.getByTestId('context').textContent).toBe('closed'); - expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); + expect(Number(screen.getByTestId('image-context-closed').textContent)).toBe( + 5, + ); }); it('deletes selected and direct layers while running delete side effects', () => { diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index f34f4c49b..fc97dcb67 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -85,6 +85,10 @@ export function useImageCanvasLayerCommands({ const [canvasClipboard, setCanvasClipboard] = useState(null); const canvasClipboardRef = useRef(null); + const closeContextMenus = useCallback(() => { + setContextMenu(null); + setImageContextMenu(null); + }, [setContextMenu, setImageContextMenu]); const getContextTargetLayerIds = useCallback( (menu: CanvasContextMenuState | null = contextMenu) => @@ -121,13 +125,9 @@ export function useImageCanvasLayerCommands({ if (!clipboard?.layers.length) { return false; } - const nextLayers = duplicateLayersToPoint( - clipboard.layers, - canvasPoint, - { - renameCopies: clipboard.mode !== 'cut', - }, - ); + const nextLayers = duplicateLayersToPoint(clipboard.layers, canvasPoint, { + renameCopies: clipboard.mode !== 'cut', + }); if (!nextLayers.length) { return false; } @@ -136,14 +136,14 @@ export function useImageCanvasLayerCommands({ setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setActiveTool('select'); - setContextMenu(null); + closeContextMenus(); return true; }, [ captureCanvasHistory, + closeContextMenus, duplicateLayersToPoint, setActiveTool, - setContextMenu, setLayers, setSelectedLayerId, setSelectedLayerIds, @@ -175,14 +175,14 @@ export function useImageCanvasLayerCommands({ : currentLayer, ); } - setContextMenu(null); + closeContextMenus(); }, [ captureCanvasHistory, + closeContextMenus, getContextTargetLayerIds, layers, selectSingleLayer, - setContextMenu, setLayers, setMetadataLayer, ], @@ -241,13 +241,13 @@ export function useImageCanvasLayerCommands({ setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); - setContextMenu(null); + closeContextMenus(); }, [ captureCanvasHistory, + closeContextMenus, duplicateLayersToPoint, getContextTargetLayerIds, layers, - setContextMenu, setLayers, setSelectedLayerId, setSelectedLayerIds, @@ -288,9 +288,14 @@ export function useImageCanvasLayerCommands({ setLayers((currentLayers) => updateCanvasLayersByIds(currentLayers, targetIds, updater), ); - setContextMenu(null); + closeContextMenus(); }, - [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], + [ + captureCanvasHistory, + closeContextMenus, + getContextTargetLayerIds, + setLayers, + ], ); const moveContextLayers = useCallback( @@ -303,9 +308,14 @@ export function useImageCanvasLayerCommands({ setLayers((currentLayers) => moveCanvasLayers(currentLayers, targetIds, mode), ); - setContextMenu(null); + closeContextMenus(); }, - [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], + [ + captureCanvasHistory, + closeContextMenus, + getContextTargetLayerIds, + setLayers, + ], ); const moveSelectedLayers = useCallback( @@ -339,8 +349,13 @@ export function useImageCanvasLayerCommands({ setLayers((currentLayers) => groupCanvasLayers(currentLayers, targetIds, createGroupId()), ); - setContextMenu(null); - }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + closeContextMenus(); + }, [ + captureCanvasHistory, + closeContextMenus, + getContextTargetLayerIds, + setLayers, + ]); const ungroupContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); @@ -349,8 +364,13 @@ export function useImageCanvasLayerCommands({ } captureCanvasHistory(); setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds)); - setContextMenu(null); - }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + closeContextMenus(); + }, [ + captureCanvasHistory, + closeContextMenus, + getContextTargetLayerIds, + setLayers, + ]); const toggleContextLayerVisibility = useCallback(() => { const targetIds = getContextTargetLayerIds(); @@ -361,8 +381,13 @@ export function useImageCanvasLayerCommands({ setLayers((currentLayers) => toggleCanvasLayersVisibility(currentLayers, targetIds), ); - setContextMenu(null); - }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + closeContextMenus(); + }, [ + captureCanvasHistory, + closeContextMenus, + getContextTargetLayerIds, + setLayers, + ]); const toggleContextLayerLock = useCallback(() => { const targetIds = getContextTargetLayerIds(); @@ -373,8 +398,13 @@ export function useImageCanvasLayerCommands({ setLayers((currentLayers) => toggleCanvasLayersLock(currentLayers, targetIds), ); - setContextMenu(null); - }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + closeContextMenus(); + }, [ + captureCanvasHistory, + closeContextMenus, + getContextTargetLayerIds, + setLayers, + ]); const flipContextLayers = useCallback( (axis: CanvasLayerFlipAxis) => { @@ -386,9 +416,14 @@ export function useImageCanvasLayerCommands({ setLayers((currentLayers) => flipCanvasLayers(currentLayers, targetIds, axis), ); - setContextMenu(null); + closeContextMenus(); }, - [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], + [ + captureCanvasHistory, + closeContextMenus, + getContextTargetLayerIds, + setLayers, + ], ); const deleteContextLayers = useCallback(() => { @@ -403,12 +438,12 @@ export function useImageCanvasLayerCommands({ setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); - setContextMenu(null); + closeContextMenus(); }, [ captureCanvasHistory, + closeContextMenus, getContextTargetLayerIds, selectSingleLayer, - setContextMenu, setHoveredLayerId, setLayers, setMetadataLayer,