修复工作区撤销重做快捷键

- 抽出 keyboardShortcuts helper,统一识别 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z 和 Ctrl/Cmd+Y

- 快捷键监听改为 window capture 阶段,减少 Canvas/Konva 焦点或冒泡拦截导致的失效

- 兼容 event.code=KeyZ/KeyY,覆盖输入法或键盘布局下 event.key 不可靠的情况

- 保持输入框、文本域、下拉框和可编辑文本聚焦时不拦截撤销/重做

- 增加快捷键 helper 与 VideoWorkspace 回归测试,并更新需求、设计、测试计划和 AGENTS 文档
This commit is contained in:
2026-05-03 21:10:38 +08:00
parent a22af5f7c8
commit 275be62db5
8 changed files with 82 additions and 23 deletions

View File

@@ -141,6 +141,18 @@ describe('VideoWorkspace', () => {
fireEvent.keyDown(window, { key: 'z', ctrlKey: true, shiftKey: true });
expect(useStore.getState().masks).toEqual([mask]);
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
expect(useStore.getState().masks).toEqual([]);
fireEvent.keyDown(window, { key: 'y', ctrlKey: true });
expect(useStore.getState().masks).toEqual([mask]);
fireEvent.keyDown(window, { key: 'Process', code: 'KeyZ', ctrlKey: true });
expect(useStore.getState().masks).toEqual([]);
fireEvent.keyDown(window, { key: 'Process', code: 'KeyY', ctrlKey: true });
expect(useStore.getState().masks).toEqual([mask]);
fireEvent.keyDown(screen.getByLabelText('传播起始帧'), { key: 'z', ctrlKey: true });
expect(useStore.getState().masks).toEqual([mask]);
});

View File

@@ -25,6 +25,7 @@ import { ModelStatusBadge } from './ModelStatusBadge';
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type Template, type TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { normalizeClassMaskIds } from '../lib/maskIds';
import { getUndoRedoShortcut } from '../lib/keyboardShortcuts';
type PropagationDirection = 'forward' | 'backward';
type PropagationProgress = {
@@ -521,26 +522,15 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
);
useEffect(() => {
const handleWorkspaceShortcuts = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const tagName = target?.tagName?.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || target?.isContentEditable) return;
if (!event.metaKey && !event.ctrlKey) return;
const key = event.key.toLowerCase();
if (key === 'z') {
event.preventDefault();
if (event.shiftKey) redoMasks();
else undoMasks();
return;
}
if (key === 'y') {
event.preventDefault();
redoMasks();
}
const shortcut = getUndoRedoShortcut(event);
if (!shortcut) return;
event.preventDefault();
if (shortcut === 'undo') undoMasks();
else redoMasks();
};
window.addEventListener('keydown', handleWorkspaceShortcuts);
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts);
window.addEventListener('keydown', handleWorkspaceShortcuts, true);
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts, true);
}, [redoMasks, undoMasks]);
const templates = useStore((state) => state.templates);

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getUndoRedoShortcut, isEditableShortcutTarget } from './keyboardShortcuts';
function keyboardEvent(init: KeyboardEventInit) {
return new KeyboardEvent('keydown', init);
}
describe('keyboardShortcuts', () => {
it('maps common undo and redo shortcuts', () => {
expect(getUndoRedoShortcut(keyboardEvent({ key: 'z', ctrlKey: true }))).toBe('undo');
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Z', metaKey: true }))).toBe('undo');
expect(getUndoRedoShortcut(keyboardEvent({ key: 'z', ctrlKey: true, shiftKey: true }))).toBe('redo');
expect(getUndoRedoShortcut(keyboardEvent({ key: 'y', ctrlKey: true }))).toBe('redo');
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Y', metaKey: true }))).toBe('redo');
});
it('falls back to physical key codes when key labels are unavailable', () => {
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Process', code: 'KeyZ', ctrlKey: true }))).toBe('undo');
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Process', code: 'KeyZ', ctrlKey: true, shiftKey: true }))).toBe('redo');
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Process', code: 'KeyY', ctrlKey: true }))).toBe('redo');
});
it('ignores editable targets and alt-modified shortcuts', () => {
const input = document.createElement('input');
input.dispatchEvent(new Event('focus'));
expect(isEditableShortcutTarget(input)).toBe(true);
const event = keyboardEvent({ key: 'z', ctrlKey: true });
Object.defineProperty(event, 'target', { value: input });
expect(getUndoRedoShortcut(event)).toBeNull();
expect(getUndoRedoShortcut(keyboardEvent({ key: 'z', ctrlKey: true, altKey: true }))).toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
export type UndoRedoShortcut = 'undo' | 'redo';
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
function isKey(event: KeyboardEvent, key: 'z' | 'y'): boolean {
return event.key.toLowerCase() === key || event.code === `Key${key.toUpperCase()}`;
}
export function getUndoRedoShortcut(event: KeyboardEvent): UndoRedoShortcut | null {
if (isEditableShortcutTarget(event.target)) return null;
if (!event.metaKey && !event.ctrlKey) return null;
if (event.altKey) return null;
if (isKey(event, 'z')) {
return event.shiftKey ? 'redo' : 'undo';
}
if (isKey(event, 'y')) {
return 'redo';
}
return null;
}