/* @vitest-environment jsdom */ import { act, fireEvent, render, screen } from '@testing-library/react'; import { useRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, CanvasTool, GenerateDialogState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts'; function createPlaceholderDialog( mode: GenerateDialogState['mode'] = 'generate', ): GenerateDialogState { return { id: 'dialog-1', mode, prompt: '生成一张图片', status: 'idle', composerOpen: true, placeholder: { x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }; } function applyGenerateDialogUpdater( updater: | GenerateDialogState | null | ((dialog: GenerateDialogState | null) => GenerateDialogState | null), currentDialog: GenerateDialogState | null, ) { return typeof updater === 'function' ? updater(currentDialog) : updater; } function applyQuickEditPanelUpdater( updater: | QuickEditPanelState | null | ((panel: QuickEditPanelState | null) => QuickEditPanelState | null), currentPanel: QuickEditPanelState | null, ) { return typeof updater === 'function' ? updater(currentPanel) : updater; } function KeyboardShortcutsHarness({ selectedLayerId = null, selectedLayerIds = selectedLayerId ? [selectedLayerId] : [], initialGenerateDialog = null, initialQuickEditPanel = null, initialTool = 'select', undoCanvasChange = vi.fn(), redoCanvasChange = vi.fn(), deleteLayerById = vi.fn(), deleteSelectedCanvasObjects = vi.fn(), selectAllCanvasObjects = vi.fn(), clearCanvasSelection = vi.fn(), copySelectedCanvasLayers = vi.fn(), pasteCanvasClipboard = vi.fn(), duplicateSelectedCanvasLayers = vi.fn(), moveSelectedCanvasLayers = vi.fn(), nudgeSelectedCanvasObjects = vi.fn(), requestRemoveCanvasGenerationDialog = vi.fn(), switchTool, updateScaleFromCenter = vi.fn(), zoomViewportBy = vi.fn(), fitLayers = vi.fn(), toggleSidebarPanel = vi.fn(), toggleMinimap = vi.fn(), startProjectRename = vi.fn(), exportCanvasAssets = vi.fn(), closeEditorChromePanels = vi.fn(), }: { selectedLayerId?: string | null; selectedLayerIds?: string[]; initialGenerateDialog?: GenerateDialogState | null; initialQuickEditPanel?: QuickEditPanelState | null; initialTool?: CanvasTool; undoCanvasChange?: () => void; redoCanvasChange?: () => void; deleteLayerById?: (layerId: string | null) => void; deleteSelectedCanvasObjects?: () => void; 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; requestRemoveCanvasGenerationDialog?: ( dialog: CanvasGenerationDialogState, ) => void; switchTool?: (tool: CanvasTool) => void; updateScaleFromCenter?: (nextScale: number) => void; zoomViewportBy?: (scaleFactor: number) => void; fitLayers?: () => void; toggleSidebarPanel?: (panel: 'assets' | 'layers') => void; toggleMinimap?: () => void; startProjectRename?: () => void; exportCanvasAssets?: () => void; closeEditorChromePanels?: () => void; }) { const [activeTool, setActiveTool] = useState(initialTool); const [generateDialog, setGenerateDialogState] = useState(initialGenerateDialog); const [quickEditPanel, setQuickEditPanelState] = useState(initialQuickEditPanel); const [imageContextMenuOpen, setImageContextMenuOpen] = useState(true); const [contextMenuOpen, setContextMenuOpen] = useState(true); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(true); const [, setIsGenerationReferenceMenuOpen] = useState(true); const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] = useState(true); const [, setIsPickingGenerationReferenceFromCanvas] = useState(true); const [, setIsPickingQuickEditReferenceFromCanvas] = useState(true); const [ isPickingPublicationReferenceFromCanvas, setIsPickingPublicationReferenceFromCanvas, ] = useState(true); const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(true); const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] = useState(true); const [isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas] = useState(true); const [ isPickingCharacterReferenceFromCanvas, setIsPickingCharacterReferenceFromCanvas, ] = useState(true); const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true); const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = useState(true); const [, setIsUiDesignSpecMenuOpen] = useState(true); const [, setIsPickingUiDesignSpecFromCanvas] = useState(true); const [isSpacePanning, setIsSpacePanning] = useState(false); const [shiftPressed, setShiftPressed] = useState(false); const generateDialogRef = useRef(generateDialog); const selectedLayerIdRef = useRef(selectedLayerId); const selectedLayerIdsRef = useRef(selectedLayerIds); generateDialogRef.current = generateDialog; selectedLayerIdRef.current = selectedLayerId; selectedLayerIdsRef.current = selectedLayerIds; useImageCanvasKeyboardShortcuts({ generateDialogRef, selectedLayerIdRef, selectedLayerIdsRef, redoCanvasChange, undoCanvasChange, deleteLayerById, deleteSelectedCanvasObjects, selectAllCanvasObjects, clearCanvasSelection, copySelectedCanvasLayers, pasteCanvasClipboard, duplicateSelectedCanvasLayers, moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, requestRemoveCanvasGenerationDialog, setActiveTool: (tool) => setActiveTool(tool), switchTool: switchTool ?? ((tool) => setActiveTool(tool)), updateScaleFromCenter, zoomViewportBy, fitLayers, toggleSidebarPanel, toggleMinimap, startProjectRename, exportCanvasAssets, setGenerateDialog: (updater) => setGenerateDialogState((currentDialog) => applyGenerateDialogUpdater(updater, currentDialog), ), setImageContextMenu: () => setImageContextMenuOpen(false), setContextMenu: () => setContextMenuOpen(false), setQuickEditPanel: (updater) => setQuickEditPanelState((currentPanel) => applyQuickEditPanelUpdater(updater, currentPanel), ), closeEditorChromePanels, setIsSpecMenuOpen, setIsGenerationReferenceMenuOpen, setIsPublicationReferenceMenuOpen, setIsPickingGenerationReferenceFromCanvas, setIsPickingQuickEditReferenceFromCanvas, setIsPickingPublicationReferenceFromCanvas, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, setIsUiDesignSpecMenuOpen, setIsPickingUiDesignSpecFromCanvas, setIsSpacePanning, setShiftPressed, }); return (
{activeTool} {generateDialog ? `${generateDialog.mode}:${generateDialog.status}:${String( generateDialog.composerOpen, )}` : 'none'} {quickEditPanel ? quickEditPanel.status : 'none'} {String(imageContextMenuOpen)} {String(contextMenuOpen)} {String(isSpecMenuOpen)} {String(isPublicationReferenceMenuOpen)} {String(isPickingPublicationReferenceFromCanvas)} {String(isCharacterSpecMenuOpen)} {String(isCharacterReferenceMenuOpen)} {String(isPickingCharacterSpecFromCanvas)} {String(isPickingCharacterReferenceFromCanvas)} {String(isIconSpecMenuOpen)} {String(isPickingIconSpecFromCanvas)} {String(isSpacePanning)} {String(shiftPressed)}
); } describe('useImageCanvasKeyboardShortcuts', () => { it('routes undo and redo shortcuts while ignoring editable inputs', () => { const undoCanvasChange = vi.fn(); const redoCanvasChange = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); }); expect(undoCanvasChange).toHaveBeenCalledTimes(1); act(() => { fireEvent.keyDown(window, { key: 'Z', code: 'KeyZ', ctrlKey: true, shiftKey: true, }); }); expect(redoCanvasChange).toHaveBeenCalledTimes(1); act(() => { fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), { key: 'z', code: 'KeyZ', ctrlKey: true, }); }); expect(undoCanvasChange).toHaveBeenCalledTimes(1); act(() => { fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', metaKey: true }); }); expect(undoCanvasChange).toHaveBeenCalledTimes(1); }); 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( , ); act(() => { fireEvent.keyDown(window, { key: 'a', code: 'KeyA', ctrlKey: true }); fireEvent.keyDown(window, { key: 'c', code: 'KeyC', ctrlKey: true }); fireEvent.keyDown(window, { key: 'v', code: 'KeyV', ctrlKey: true }); fireEvent.keyDown(window, { key: 'x', code: 'KeyX', ctrlKey: true }); fireEvent.keyDown(window, { key: 'd', code: 'KeyD', ctrlKey: true }); fireEvent.keyDown(window, { key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true, }); }); 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('leaves native copy available when text is selected', () => { const copySelectedCanvasLayers = vi.fn(); const removeAllRanges = vi.fn(); const selectedText = vi.fn(() => '清爽游戏按钮'); vi.spyOn(window, 'getSelection').mockReturnValue({ toString: selectedText, removeAllRanges, } as unknown as Selection); render( , ); const copyEvent = new KeyboardEvent('keydown', { key: 'c', code: 'KeyC', ctrlKey: true, bubbles: true, cancelable: true, }); window.dispatchEvent(copyEvent); expect(copySelectedCanvasLayers).not.toHaveBeenCalled(); expect(copyEvent.defaultPrevented).toBe(false); }); it('routes Windows view and layer ordering shortcuts', () => { const fitLayers = vi.fn(); const updateScaleFromCenter = vi.fn(); const zoomViewportBy = vi.fn(); const moveSelectedCanvasLayers = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: '0', code: 'Digit0', ctrlKey: true }); fireEvent.keyDown(window, { key: '1', code: 'Digit1', ctrlKey: true }); fireEvent.keyDown(window, { key: '=', code: 'Equal', ctrlKey: true }); fireEvent.keyDown(window, { key: '-', code: 'Minus', ctrlKey: true }); fireEvent.keyDown(window, { key: ']', code: 'BracketRight', ctrlKey: true, }); fireEvent.keyDown(window, { key: '}', code: 'BracketRight', ctrlKey: true, shiftKey: true, }); fireEvent.keyDown(window, { key: '[', code: 'BracketLeft', ctrlKey: true, }); fireEvent.keyDown(window, { key: '{', code: 'BracketLeft', ctrlKey: true, shiftKey: true, }); }); expect(fitLayers).toHaveBeenCalledTimes(1); expect(updateScaleFromCenter).toHaveBeenCalledWith(1); expect(zoomViewportBy).toHaveBeenCalledWith(1.16); expect(zoomViewportBy).toHaveBeenCalledWith(0.86); expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(1, 'up'); expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(2, 'top'); expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(3, 'down'); expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(4, 'bottom'); }); it('routes tool, panel and project shortcuts', () => { const switchTool = vi.fn(); const toggleSidebarPanel = vi.fn(); const toggleMinimap = vi.fn(); const startProjectRename = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: 'v', code: 'KeyV' }); fireEvent.keyDown(window, { key: 'h', code: 'KeyH' }); fireEvent.keyDown(window, { key: 'u', code: 'KeyU' }); fireEvent.keyDown(window, { key: 'g', code: 'KeyG' }); fireEvent.keyDown(window, { key: 'V', code: 'KeyV', shiftKey: true }); fireEvent.keyDown(window, { key: 'm', code: 'KeyM' }); fireEvent.keyDown(window, { key: '1', code: 'Digit1', altKey: true }); fireEvent.keyDown(window, { key: '2', code: 'Digit2', altKey: true }); fireEvent.keyDown(window, { key: 'm', code: 'KeyM', altKey: true }); fireEvent.keyDown(window, { key: 'F2', code: 'F2' }); }); expect(switchTool).toHaveBeenNthCalledWith(1, 'select'); expect(switchTool).toHaveBeenNthCalledWith(2, 'hand'); expect(switchTool).toHaveBeenNthCalledWith(3, 'upload'); expect(switchTool).toHaveBeenNthCalledWith(4, 'generate'); expect(switchTool).toHaveBeenNthCalledWith(5, 'video'); expect(switchTool).toHaveBeenNthCalledWith(6, 'music'); expect(toggleSidebarPanel).toHaveBeenNthCalledWith(1, 'assets'); expect(toggleSidebarPanel).toHaveBeenNthCalledWith(2, 'layers'); expect(toggleMinimap).toHaveBeenCalledTimes(1); expect(startProjectRename).toHaveBeenCalledTimes(1); }); it('releases held modifier interaction state when the window loses focus', () => { render(); act(() => { fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); fireEvent.keyDown(window, { key: ' ', code: 'Space' }); }); expect(screen.getByTestId('shift-pressed').textContent).toBe('true'); expect(screen.getByTestId('space-panning').textContent).toBe('true'); act(() => { fireEvent.blur(window); }); expect(screen.getByTestId('shift-pressed').textContent).toBe('false'); expect(screen.getByTestId('space-panning').textContent).toBe('false'); }); it('nudges selected canvas objects with arrow keys', () => { const nudgeSelectedCanvasObjects = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: 'ArrowUp', code: 'ArrowUp' }); fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight', shiftKey: true, }); }); expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(1, { x: 0, y: -1, }); expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(2, { x: 10, y: 0, }); }); it('deletes the selected layer with Backspace outside editable inputs', () => { const deleteLayerById = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(deleteLayerById).toHaveBeenCalledWith('layer-selected'); }); it('deletes complete canvas selection before falling back to active layer', () => { const deleteLayerById = vi.fn(); const deleteSelectedCanvasObjects = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); }); expect(deleteSelectedCanvasObjects).toHaveBeenCalledTimes(1); expect(deleteLayerById).not.toHaveBeenCalled(); }); it('requests deleting editable generation placeholders with Backspace', () => { const requestRemoveCanvasGenerationDialog = vi.fn(); const dialog = createPlaceholderDialog('character'); render( , ); act(() => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith(dialog); expect(screen.getByTestId('generate-dialog').textContent).toBe( 'character:idle:true', ); expect(screen.getByTestId('active-tool').textContent).toBe('select'); expect(screen.getByTestId('character-menu').textContent).toBe('false'); expect(screen.getByTestId('character-picking').textContent).toBe('false'); expect(screen.getByTestId('icon-menu').textContent).toBe('false'); expect(screen.getByTestId('icon-picking').textContent).toBe('false'); }); it('requests deleting generating placeholders with Delete', () => { const requestRemoveCanvasGenerationDialog = vi.fn(); const generatingDialog = createPlaceholderDialog('generate'); generatingDialog.status = 'generating'; render( , ); act(() => { fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); }); expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith( generatingDialog, ); expect(screen.getByTestId('active-tool').textContent).toBe('select'); }); it('removes audio generation placeholders with Backspace', () => { const requestRemoveCanvasGenerationDialog = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith( expect.objectContaining({ id: 'dialog-1' }), ); expect(screen.getByTestId('active-tool').textContent).toBe('select'); }); it('removes publication generation placeholders with Backspace', () => { const requestRemoveCanvasGenerationDialog = vi.fn(); const dialog = createPlaceholderDialog('publication'); render( , ); act(() => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith(dialog); expect(screen.getByTestId('active-tool').textContent).toBe('select'); expect(screen.getByTestId('publication-reference-menu').textContent).toBe( 'false', ); expect( screen.getByTestId('publication-reference-picking').textContent, ).toBe('false'); }); it('closes transient editor panels with Escape and collapses idle composers', () => { const closeEditorChromePanels = vi.fn(); render( , ); act(() => { fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); }); expect(closeEditorChromePanels).toHaveBeenCalledTimes(1); expect(screen.getByTestId('generate-dialog').textContent).toBe( 'generate:idle:false', ); expect(screen.getByTestId('quick-edit').textContent).toBe('none'); expect(screen.getByTestId('image-context').textContent).toBe('false'); expect(screen.getByTestId('context-menu').textContent).toBe('false'); expect(screen.getByTestId('spec-menu').textContent).toBe('false'); expect(screen.getByTestId('publication-reference-menu').textContent).toBe( 'false', ); expect( screen.getByTestId('publication-reference-picking').textContent, ).toBe('false'); expect(screen.getByTestId('character-menu').textContent).toBe('false'); expect(screen.getByTestId('icon-menu').textContent).toBe('false'); }); it('collapses idle publication composer with Escape', () => { render( , ); act(() => { fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); }); expect(screen.getByTestId('generate-dialog').textContent).toBe( 'publication:idle:false', ); expect(screen.getByTestId('publication-reference-menu').textContent).toBe( 'false', ); expect( screen.getByTestId('publication-reference-picking').textContent, ).toBe('false'); }); it('keeps generating panels open when Escape closes transient chrome', () => { const generatingDialog = createPlaceholderDialog('generate'); generatingDialog.status = 'generating'; render( , ); act(() => { fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); }); expect(screen.getByTestId('generate-dialog').textContent).toBe( 'generate:generating:true', ); expect(screen.getByTestId('quick-edit').textContent).toBe('generating'); }); it('toggles temporary panning with Space outside editable inputs', () => { render(); act(() => { fireEvent.keyDown(window, { key: ' ', code: 'Space' }); }); expect(screen.getByTestId('space-panning').textContent).toBe('true'); act(() => { fireEvent.keyUp(window, { key: ' ', code: 'Space' }); }); expect(screen.getByTestId('space-panning').textContent).toBe('false'); act(() => { fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), { key: ' ', code: 'Space', }); }); expect(screen.getByTestId('space-panning').textContent).toBe('false'); }); it('tracks Shift key state', () => { render(); act(() => { fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); }); expect(screen.getByTestId('shift-pressed').textContent).toBe('true'); act(() => { fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); }); expect(screen.getByTestId('shift-pressed').textContent).toBe('false'); }); });