修复弹窗内编辑器快捷键误触发

抽取UI编辑器键盘快捷键处理模块

阻止对话框内撤销重做并保留普通按钮快捷键行为
This commit is contained in:
2026-09-05 13:54:26 +08:00
parent 897683e63f
commit 5b75792d6a
3 changed files with 153 additions and 54 deletions
@@ -18,6 +18,7 @@ import { ToolNavigation } from './components/ToolNavigation';
import { WorkflowActionCard } from './components/WorkflowActionCard';
import { WorkflowCompletionModal } from './components/WorkflowCompletionModal';
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
import { handleUiEditorKeyDown } from './uiEditorKeyboardShortcuts';
import {
type UiEditorWorkflowProjection,
useUiEditorSession,
@@ -89,60 +90,14 @@ export default function UiEditorPage({
}, [session.save.isDirty]);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest('input, textarea, select, [contenteditable="true"]'),
);
};
const isInteractiveTarget = (target: EventTarget | null) => {
const element = target instanceof Element ? target : null;
return Boolean(
(target instanceof HTMLElement && target.isContentEditable) ||
element?.closest(
'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]',
),
);
};
const onKeyDown = (event: KeyboardEvent) => {
if (
(event.key === 'Delete' || event.key === 'Backspace') &&
!event.repeat &&
!event.defaultPrevented &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
!isInteractiveTarget(event.target)
) {
if (selectedNodeId && activeImageId) {
const result = deleteNode(selectedNodeId, activeImageId);
if (result?.ok) {
event.preventDefault();
event.stopPropagation();
return;
}
}
}
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
}
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
const isRedo =
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
(event.ctrlKey && event.key.toLowerCase() === 'y');
if (isUndo && historyUndo()) {
event.preventDefault();
} else if (isRedo && historyRedo()) {
event.preventDefault();
}
};
const onKeyDown = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId,
activeImageId,
deleteNode,
historyUndo,
historyRedo,
});
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
@@ -0,0 +1,82 @@
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
type DeleteResult = { ok: boolean } | undefined;
export type UiEditorKeyboardActions = {
selectedNodeId: NodeId | null;
activeImageId: UIDesignImageId | null;
deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult;
historyUndo: () => boolean;
historyRedo: () => boolean;
};
function isEditableTarget(target: EventTarget | null) {
const element = target instanceof HTMLElement ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest('input, textarea, select, [contenteditable="true"]'),
);
}
function isInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(
(target instanceof HTMLElement && target.isContentEditable) ||
element?.closest(
'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]',
),
);
}
function isModalTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]'));
}
export function handleUiEditorKeyDown(
event: KeyboardEvent,
actions: UiEditorKeyboardActions,
) {
if (isModalTarget(event.target)) return;
if (
(event.key === 'Delete' || event.key === 'Backspace') &&
!event.repeat &&
!event.defaultPrevented &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
!isInteractiveTarget(event.target)
) {
if (actions.selectedNodeId && actions.activeImageId) {
const result = actions.deleteNode(
actions.selectedNodeId,
actions.activeImageId,
);
if (result?.ok) {
event.preventDefault();
event.stopPropagation();
return;
}
}
}
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
}
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
const isRedo =
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
(event.ctrlKey && event.key.toLowerCase() === 'y');
if (isUndo && actions.historyUndo()) {
event.preventDefault();
} else if (isRedo && actions.historyRedo()) {
event.preventDefault();
}
}
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { fireEvent } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts';
describe('ui editor keyboard shortcuts', () => {
afterEach(() => {
document.body.replaceChildren();
});
it.each([
{ key: 'z', ctrlKey: true },
{ key: 'z', ctrlKey: true, shiftKey: true },
{ key: 'y', ctrlKey: true },
])('does not change history from inside a modal (%o)', (shortcut) => {
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
const button = document.createElement('button');
dialog.append(button);
document.body.append(dialog);
const historyUndo = vi.fn(() => true);
const historyRedo = vi.fn(() => true);
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: null,
activeImageId: null,
deleteNode: vi.fn(),
historyUndo,
historyRedo,
});
window.addEventListener('keydown', listener);
fireEvent.keyDown(button, shortcut);
expect(historyUndo).not.toHaveBeenCalled();
expect(historyRedo).not.toHaveBeenCalled();
window.removeEventListener('keydown', listener);
});
it('keeps undo available from a non-modal button', () => {
const button = document.createElement('button');
document.body.append(button);
const historyUndo = vi.fn(() => true);
const historyRedo = vi.fn(() => true);
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: null,
activeImageId: null,
deleteNode: vi.fn(),
historyUndo,
historyRedo,
});
window.addEventListener('keydown', listener);
fireEvent.keyDown(button, { key: 'z', ctrlKey: true });
expect(historyUndo).toHaveBeenCalledTimes(1);
window.removeEventListener('keydown', listener);
});
});