- 常规参考图入口改为先弹出来源菜单,支持从画布选择和上传图片。 - 角色规范、图标规范和常规参考图来源菜单统一向上弹出。 - 画布参考图选择拦截普通图层选中逻辑,保持生成面板不隐藏。 - 补充图片编辑器交互测试与技术文档说明。
334 lines
11 KiB
TypeScript
334 lines
11 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 {
|
|
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<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 [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<GenerateDialogState | null>(generateDialog);
|
|
const selectedLayerIdRef = useRef<string | null>(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 (
|
|
<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="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);
|
|
});
|
|
|
|
it('deletes the selected layer with Backspace outside editable inputs', () => {
|
|
const deleteLayerById = vi.fn();
|
|
render(
|
|
<KeyboardShortcutsHarness
|
|
selectedLayerId="layer-selected"
|
|
deleteLayerById={deleteLayerById}
|
|
/>,
|
|
);
|
|
|
|
act(() => {
|
|
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
|
|
});
|
|
|
|
expect(deleteLayerById).toHaveBeenCalledWith('layer-selected');
|
|
});
|
|
|
|
it('removes editable generation placeholders with Backspace', () => {
|
|
render(
|
|
<KeyboardShortcutsHarness
|
|
initialTool="character"
|
|
initialGenerateDialog={createPlaceholderDialog('character')}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<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('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(
|
|
<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');
|
|
});
|
|
});
|