修复工作区撤销重做快捷键
- 抽出 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:
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
src/lib/keyboardShortcuts.test.ts
Normal file
32
src/lib/keyboardShortcuts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
25
src/lib/keyboardShortcuts.ts
Normal file
25
src/lib/keyboardShortcuts.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user