修复弹窗内编辑器快捷键误触发
抽取UI编辑器键盘快捷键处理模块 阻止对话框内撤销重做并保留普通按钮快捷键行为
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user