/* @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 { 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, initialGenerateDialog = null, initialQuickEditPanel = null, initialTool = 'select', undoCanvasChange = vi.fn(), redoCanvasChange = vi.fn(), deleteLayerById = vi.fn(), closeEditorChromePanels = vi.fn(), }: { selectedLayerId?: string | null; initialGenerateDialog?: GenerateDialogState | null; initialQuickEditPanel?: QuickEditPanelState | null; initialTool?: CanvasTool; undoCanvasChange?: () => void; redoCanvasChange?: () => void; deleteLayerById?: (layerId: string | null) => 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 [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 [isSpacePanning, setIsSpacePanning] = useState(false); const [shiftPressed, setShiftPressed] = useState(false); const generateDialogRef = useRef(generateDialog); const selectedLayerIdRef = useRef(selectedLayerId); generateDialogRef.current = generateDialog; selectedLayerIdRef.current = selectedLayerId; useImageCanvasKeyboardShortcuts({ generateDialogRef, selectedLayerIdRef, redoCanvasChange, undoCanvasChange, deleteLayerById, setActiveTool: (tool) => setActiveTool(tool), setGenerateDialog: (updater) => setGenerateDialogState((currentDialog) => applyGenerateDialogUpdater(updater, currentDialog), ), setImageContextMenu: () => setImageContextMenuOpen(false), setContextMenu: () => setContextMenuOpen(false), setQuickEditPanel: (updater) => setQuickEditPanelState((currentPanel) => applyQuickEditPanelUpdater(updater, currentPanel), ), closeEditorChromePanels, setIsSpecMenuOpen, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, setIsSpacePanning, setShiftPressed, }); return (
{activeTool} {generateDialog ? `${generateDialog.mode}:${generateDialog.status}:${String( generateDialog.composerOpen, )}` : 'none'} {quickEditPanel ? quickEditPanel.status : 'none'} {String(imageContextMenuOpen)} {String(contextMenuOpen)} {String(isSpecMenuOpen)} {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); }); 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('removes editable generation placeholders with Backspace', () => { render( , ); act(() => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(screen.getByTestId('generate-dialog').textContent).toBe('none'); 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('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('character-menu').textContent).toBe('false'); expect(screen.getByTestId('icon-menu').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'); }); });