9b49085869
同步生成器 active/inactive ref 与 state 激活已有生成器时清理选参考图和菜单临时状态 窗口失焦时释放 Shift 与空格按住态 补充生成器状态与快捷键回归测试 记录图片画布生成器排障经验
765 lines
26 KiB
TypeScript
765 lines
26 KiB
TypeScript
/* @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<CanvasTool>(initialTool);
|
|
const [generateDialog, setGenerateDialogState] =
|
|
useState<GenerateDialogState | null>(initialGenerateDialog);
|
|
const [quickEditPanel, setQuickEditPanelState] =
|
|
useState<QuickEditPanelState | null>(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<GenerateDialogState | null>(generateDialog);
|
|
const selectedLayerIdRef = useRef<string | null>(selectedLayerId);
|
|
const selectedLayerIdsRef = useRef<string[]>(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 (
|
|
<div>
|
|
<input aria-label="快捷键输入框" />
|
|
<span data-testid="active-tool">{activeTool}</span>
|
|
<span data-testid="generate-dialog">
|
|
{generateDialog
|
|
? `${generateDialog.mode}:${generateDialog.status}:${String(
|
|
generateDialog.composerOpen,
|
|
)}`
|
|
: 'none'}
|
|
</span>
|
|
<span data-testid="quick-edit">
|
|
{quickEditPanel ? quickEditPanel.status : 'none'}
|
|
</span>
|
|
<span data-testid="image-context">{String(imageContextMenuOpen)}</span>
|
|
<span data-testid="context-menu">{String(contextMenuOpen)}</span>
|
|
<span data-testid="spec-menu">{String(isSpecMenuOpen)}</span>
|
|
<span data-testid="publication-reference-menu">
|
|
{String(isPublicationReferenceMenuOpen)}
|
|
</span>
|
|
<span data-testid="publication-reference-picking">
|
|
{String(isPickingPublicationReferenceFromCanvas)}
|
|
</span>
|
|
<span data-testid="character-menu">
|
|
{String(isCharacterSpecMenuOpen)}
|
|
</span>
|
|
<span data-testid="character-reference-menu">
|
|
{String(isCharacterReferenceMenuOpen)}
|
|
</span>
|
|
<span data-testid="character-picking">
|
|
{String(isPickingCharacterSpecFromCanvas)}
|
|
</span>
|
|
<span data-testid="character-reference-picking">
|
|
{String(isPickingCharacterReferenceFromCanvas)}
|
|
</span>
|
|
<span data-testid="icon-menu">{String(isIconSpecMenuOpen)}</span>
|
|
<span data-testid="icon-picking">
|
|
{String(isPickingIconSpecFromCanvas)}
|
|
</span>
|
|
<span data-testid="space-panning">{String(isSpacePanning)}</span>
|
|
<span data-testid="shift-pressed">{String(shiftPressed)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe('useImageCanvasKeyboardShortcuts', () => {
|
|
it('routes undo and redo shortcuts while ignoring editable inputs', () => {
|
|
const undoCanvasChange = vi.fn();
|
|
const redoCanvasChange = vi.fn();
|
|
render(
|
|
<KeyboardShortcutsHarness
|
|
undoCanvasChange={undoCanvasChange}
|
|
redoCanvasChange={redoCanvasChange}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
selectAllCanvasObjects={selectAllCanvasObjects}
|
|
copySelectedCanvasLayers={copySelectedCanvasLayers}
|
|
pasteCanvasClipboard={pasteCanvasClipboard}
|
|
duplicateSelectedCanvasLayers={duplicateSelectedCanvasLayers}
|
|
exportCanvasAssets={exportCanvasAssets}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness pasteCanvasClipboard={pasteCanvasClipboard} />,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
copySelectedCanvasLayers={copySelectedCanvasLayers}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
fitLayers={fitLayers}
|
|
updateScaleFromCenter={updateScaleFromCenter}
|
|
zoomViewportBy={zoomViewportBy}
|
|
moveSelectedCanvasLayers={moveSelectedCanvasLayers}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
switchTool={switchTool}
|
|
toggleSidebarPanel={toggleSidebarPanel}
|
|
toggleMinimap={toggleMinimap}
|
|
startProjectRename={startProjectRename}
|
|
/>,
|
|
);
|
|
|
|
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(<KeyboardShortcutsHarness />);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
nudgeSelectedCanvasObjects={nudgeSelectedCanvasObjects}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
selectedLayerId="layer-selected"
|
|
selectedLayerIds={[]}
|
|
deleteLayerById={deleteLayerById}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
selectedLayerId="layer-selected"
|
|
selectedLayerIds={['layer-selected', 'generation-dialog:dialog-1']}
|
|
deleteLayerById={deleteLayerById}
|
|
deleteSelectedCanvasObjects={deleteSelectedCanvasObjects}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
initialTool="character"
|
|
initialGenerateDialog={dialog}
|
|
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
initialTool="generate"
|
|
initialGenerateDialog={generatingDialog}
|
|
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
initialTool="music"
|
|
initialGenerateDialog={createPlaceholderDialog('audio-sound-effect')}
|
|
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
initialTool="publication"
|
|
initialGenerateDialog={dialog}
|
|
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
closeEditorChromePanels={closeEditorChromePanels}
|
|
initialGenerateDialog={createPlaceholderDialog('generate')}
|
|
initialQuickEditPanel={{
|
|
sourceLayerId: 'layer-source',
|
|
prompt: '局部修改',
|
|
size: '1024x1024',
|
|
model: 'mock',
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
initialGenerateDialog={createPlaceholderDialog('publication')}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<KeyboardShortcutsHarness
|
|
initialGenerateDialog={generatingDialog}
|
|
initialQuickEditPanel={{
|
|
sourceLayerId: 'layer-source',
|
|
prompt: '继续修改',
|
|
size: '1024x1024',
|
|
model: 'mock',
|
|
status: 'generating',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
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(<KeyboardShortcutsHarness />);
|
|
|
|
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(<KeyboardShortcutsHarness />);
|
|
|
|
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');
|
|
});
|
|
});
|