拆分图片画布键盘快捷键

新增图片画布键盘快捷键 hook,承接撤销重做、删除、Escape 和临时抓手逻辑

保留主视图状态编排,只把 window 键盘监听移出巨型组件

补充键盘快捷键 hook 测试并更新拆分文档和 TRACKING 记录
This commit is contained in:
2026-06-17 11:50:44 +08:00
parent f34556d33d
commit 5d6be7fd66
5 changed files with 556 additions and 124 deletions

View File

@@ -54,6 +54,7 @@ import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWo
import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
@@ -63,18 +64,6 @@ import {
useImageCanvasViewportControls,
} from './useImageCanvasViewportControls';
function isEditableTarget(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (!target) {
return false;
}
return (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
);
}
export function ImageCanvasEditorView() {
const authUi = useAuthUi();
const editorRootRef = useRef<HTMLElement | null>(null);
@@ -687,120 +676,31 @@ export function ImageCanvasEditorView() {
onCloseImageContextMenu: () => setImageContextMenu(null),
});
resetCanvasInteractionStateRef.current = clearActiveInteraction;
const deleteLayerByIdFromShortcut = useCallback(
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
[],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
(event.ctrlKey || event.metaKey) &&
event.code === 'KeyZ' &&
!isEditableTarget(event)
) {
event.preventDefault();
if (event.shiftKey) {
redoCanvasChange();
} else {
undoCanvasChange();
}
return;
}
if (event.key === 'Shift') {
setShiftPressed(true);
}
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
!event.repeat &&
!isEditableTarget(event)
) {
const currentDialog = generateDialogRef.current;
const currentSelectedLayerId = selectedLayerIdRef.current;
if (currentSelectedLayerId) {
event.preventDefault();
deleteLayerByIdRef.current(currentSelectedLayerId);
return;
}
if (
currentDialog?.placeholder &&
currentDialog.status !== 'generating' &&
(currentDialog.mode === 'generate' ||
currentDialog.mode === 'spec' ||
currentDialog.mode === 'character' ||
currentDialog.mode === 'icon')
) {
event.preventDefault();
setGenerateDialog(null);
setActiveTool('select');
setIsCharacterSpecMenuOpen(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setIsPickingIconSpecFromCanvas(false);
return;
}
}
if (event.key === 'Escape') {
closeEditorChromePanels();
setIsSpecMenuOpen(false);
setImageContextMenu(null);
setContextMenu(null);
setQuickEditPanel((currentPanel) =>
currentPanel?.status === 'generating' ? currentPanel : null,
);
setIsCharacterSpecMenuOpen(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setIsPickingIconSpecFromCanvas(false);
setGenerateDialog((currentDialog) => {
if (!currentDialog || currentDialog.status === 'generating') {
return currentDialog;
}
if (
currentDialog.mode === 'generate' ||
currentDialog.mode === 'spec'
) {
return {
...currentDialog,
composerOpen: false,
};
}
if (currentDialog.mode === 'character') {
return currentDialog;
}
if (currentDialog.mode === 'icon') {
return currentDialog;
}
return null;
});
return;
}
if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) {
return;
}
event.preventDefault();
setIsSpacePanning(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
setShiftPressed(false);
}
if (event.code !== 'Space') {
return;
}
event.preventDefault();
setIsSpacePanning(false);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [
closeEditorChromePanels,
useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
setContextMenu,
setQuickEditPanel,
closeEditorChromePanels,
setIsSpecMenuOpen,
setIsCharacterSpecMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsIconSpecMenuOpen,
setIsPickingIconSpecFromCanvas,
setIsSpacePanning,
setShiftPressed,
undoCanvasChange,
]);
});
useEffect(() => {
const blockBrowserZoom = (event: WheelEvent) => {

View File

@@ -0,0 +1,319 @@
/* @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 [isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas] =
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,
setIsPickingCharacterSpecFromCanvas,
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-picking">
{String(isPickingCharacterSpecFromCanvas)}
</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');
});
});

View File

@@ -0,0 +1,205 @@
import { type RefObject, useEffect } from 'react';
import type {
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
type UseImageCanvasKeyboardShortcutsOptions = {
generateDialogRef: RefObject<GenerateDialogState | null>;
selectedLayerIdRef: RefObject<string | null>;
redoCanvasChange: () => void;
undoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
setActiveTool: (tool: 'select') => void;
setGenerateDialog: (
updater:
| GenerateDialogState
| null
| ((dialog: GenerateDialogState | null) => GenerateDialogState | null),
) => void;
setImageContextMenu: (menu: null) => void;
setContextMenu: (menu: null) => void;
setQuickEditPanel: (
updater:
| QuickEditPanelState
| null
| ((
panel: QuickEditPanelState | null,
) => QuickEditPanelState | null),
) => void;
closeEditorChromePanels: () => void;
setIsSpecMenuOpen: (open: boolean) => void;
setIsCharacterSpecMenuOpen: (open: boolean) => void;
setIsPickingCharacterSpecFromCanvas: (picking: boolean) => void;
setIsIconSpecMenuOpen: (open: boolean) => void;
setIsPickingIconSpecFromCanvas: (picking: boolean) => void;
setIsSpacePanning: (panning: boolean) => void;
setShiftPressed: (pressed: boolean) => void;
};
function isEditableTarget(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (!target) {
return false;
}
return (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
);
}
function isCanvasGenerationPlaceholderDialog(
dialog: GenerateDialogState | null,
) {
return (
Boolean(dialog?.placeholder) &&
dialog?.status !== 'generating' &&
(dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon')
);
}
export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
setContextMenu,
setQuickEditPanel,
closeEditorChromePanels,
setIsSpecMenuOpen,
setIsCharacterSpecMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsIconSpecMenuOpen,
setIsPickingIconSpecFromCanvas,
setIsSpacePanning,
setShiftPressed,
}: UseImageCanvasKeyboardShortcutsOptions) {
useEffect(() => {
const closeTransientEditorPanels = () => {
closeEditorChromePanels();
setIsSpecMenuOpen(false);
setImageContextMenu(null);
setContextMenu(null);
setQuickEditPanel((currentPanel) =>
currentPanel?.status === 'generating' ? currentPanel : null,
);
setIsCharacterSpecMenuOpen(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setIsPickingIconSpecFromCanvas(false);
setGenerateDialog((currentDialog) => {
if (!currentDialog || currentDialog.status === 'generating') {
return currentDialog;
}
if (currentDialog.mode === 'generate' || currentDialog.mode === 'spec') {
return {
...currentDialog,
composerOpen: false,
};
}
if (currentDialog.mode === 'character') {
return currentDialog;
}
if (currentDialog.mode === 'icon') {
return currentDialog;
}
return null;
});
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
(event.ctrlKey || event.metaKey) &&
event.code === 'KeyZ' &&
!isEditableTarget(event)
) {
event.preventDefault();
if (event.shiftKey) {
redoCanvasChange();
} else {
undoCanvasChange();
}
return;
}
if (event.key === 'Shift') {
setShiftPressed(true);
}
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
!event.repeat &&
!isEditableTarget(event)
) {
const currentSelectedLayerId = selectedLayerIdRef.current;
if (currentSelectedLayerId) {
event.preventDefault();
deleteLayerById(currentSelectedLayerId);
return;
}
if (isCanvasGenerationPlaceholderDialog(generateDialogRef.current)) {
event.preventDefault();
setGenerateDialog(null);
setActiveTool('select');
setIsCharacterSpecMenuOpen(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setIsPickingIconSpecFromCanvas(false);
return;
}
}
if (event.key === 'Escape') {
closeTransientEditorPanels();
return;
}
if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) {
return;
}
event.preventDefault();
setIsSpacePanning(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
setShiftPressed(false);
}
if (event.code !== 'Space') {
return;
}
event.preventDefault();
setIsSpacePanning(false);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [
closeEditorChromePanels,
deleteLayerById,
generateDialogRef,
redoCanvasChange,
selectedLayerIdRef,
setActiveTool,
setContextMenu,
setGenerateDialog,
setImageContextMenu,
setIsCharacterSpecMenuOpen,
setIsIconSpecMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsSpacePanning,
setIsSpecMenuOpen,
setQuickEditPanel,
setShiftPressed,
undoCanvasChange,
]);
}