diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 16b53de8a..e60169b1f 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -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]); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts new file mode 100644 index 000000000..90657f5b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts @@ -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(); + } +} diff --git a/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts new file mode 100644 index 000000000..51ccc3514 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts @@ -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); + }); +});