diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts new file mode 100644 index 000000000..85941ca1e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts @@ -0,0 +1,100 @@ +export type PreviewZoomShortcut = + | 'fit' + | 'actual-size' + | 'zoom-in' + | 'zoom-out'; + +export type PreviewZoomKeyboardContext = { + hasZoomableViewport: boolean; + isFocused: boolean; + isHovered: boolean; + usesMetaModifier: boolean; +}; + +export type PreviewZoomKeyboardActions = { + fit: () => void; + resetToActualSize: () => void; + zoomIn: () => void; + zoomOut: () => void; +}; + +const INTERACTIVE_TARGET_SELECTOR = [ + 'button', + 'a[href]', + 'input', + 'textarea', + 'select', + 'summary', + '[contenteditable]:not([contenteditable="false"])', + '[role="button"]', + '[role="link"]', + '[role="textbox"]', + '[role="combobox"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="menuitem"]', + '[role="option"]', + '[role="dialog"]', + '[aria-modal="true"]', +].join(', '); + +export function previewZoomUsesMetaModifier(platform: string) { + return /Mac|iPhone|iPad|iPod/i.test(platform); +} + +export function isPreviewZoomInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean(element?.closest(INTERACTIVE_TARGET_SELECTOR)); +} + +function hasPlatformModifier(event: KeyboardEvent, usesMetaModifier: boolean) { + return usesMetaModifier + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function resolvePreviewZoomShortcut( + event: KeyboardEvent, +): PreviewZoomShortcut | null { + if (event.altKey) return null; + if (event.key === '0') return 'fit'; + if (event.key === '1') return 'actual-size'; + if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') { + return 'zoom-in'; + } + if (event.code === 'NumpadSubtract' || event.key === '-') { + return 'zoom-out'; + } + return null; +} + +export function handlePreviewZoomKeyDown( + event: KeyboardEvent, + context: PreviewZoomKeyboardContext, + actions: PreviewZoomKeyboardActions, +) { + if ( + event.defaultPrevented || + !context.hasZoomableViewport || + (!context.isHovered && !context.isFocused) || + !hasPlatformModifier(event, context.usesMetaModifier) || + isPreviewZoomInteractiveTarget(event.target) + ) { + return false; + } + + const shortcut = resolvePreviewZoomShortcut(event); + if (!shortcut) return false; + + event.preventDefault(); + event.stopPropagation(); + if (shortcut === 'fit') actions.fit(); + else if (shortcut === 'actual-size') actions.resetToActualSize(); + else if (shortcut === 'zoom-in') actions.zoomIn(); + else actions.zoomOut(); + return true; +} diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts new file mode 100644 index 000000000..5ef72b7db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handlePreviewZoomKeyDown, + type PreviewZoomKeyboardActions, + type PreviewZoomKeyboardContext, + previewZoomUsesMetaModifier, +} from '../src/view/ui-editor/components/preview/previewZoomKeyboard'; + +function createActions(): PreviewZoomKeyboardActions { + return { + fit: vi.fn(), + resetToActualSize: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; +} + +function dispatchShortcut({ + actions = createActions(), + context = {}, + event, + target, +}: { + actions?: PreviewZoomKeyboardActions; + context?: Partial; + event: KeyboardEventInit; + target?: HTMLElement; +}) { + const resolvedTarget = target ?? document.createElement('div'); + document.body.append(resolvedTarget); + const handler = vi.fn((keyboardEvent: KeyboardEvent) => + handlePreviewZoomKeyDown( + keyboardEvent, + { + hasZoomableViewport: true, + isFocused: false, + isHovered: true, + usesMetaModifier: false, + ...context, + }, + actions, + ), + ); + resolvedTarget.addEventListener('keydown', handler); + fireEvent.keyDown(resolvedTarget, event); + const keyboardEvent = handler.mock.calls[0]?.[0]; + return { actions, handler, keyboardEvent }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('preview zoom keyboard shortcuts', () => { + it.each([ + { key: '+', code: 'Equal' }, + { key: '=', code: 'Equal' }, + { key: '+', code: 'NumpadAdd' }, + ])('zooms in for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true, repeat: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it.each([ + { key: '-', code: 'Minus' }, + { key: '-', code: 'NumpadSubtract' }, + ])('zooms out for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomOut).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it('does not treat underscore as zoom out', () => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { + key: '_', + code: 'Minus', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }, + }); + + expect(actions.zoomOut).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it.each([ + { key: '0', action: 'fit' as const }, + { key: '1', action: 'resetToActualSize' as const }, + ])('keeps the existing $key shortcut', ({ key, action }) => { + const { actions } = dispatchShortcut({ + event: { key, ctrlKey: true, cancelable: true }, + }); + + expect(actions[action]).toHaveBeenCalledTimes(1); + }); + + it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { + expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true); + expect(previewZoomUsesMetaModifier('iPad')).toBe(true); + expect(previewZoomUsesMetaModifier('Win32')).toBe(false); + + const appleActions = createActions(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).not.toHaveBeenCalled(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', metaKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each([ + { isHovered: false, isFocused: false, hasZoomableViewport: true }, + { isHovered: true, isFocused: false, hasZoomableViewport: false }, + ])('leaves inactive preview shortcuts to the host (%o)', (context) => { + const { actions, keyboardEvent } = dispatchShortcut({ + context, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('works while the preview is focused without being hovered', () => { + const { actions } = dispatchShortcut({ + context: { isFocused: true, isHovered: false }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => { + const target = document.createElement(tag); + if (target instanceof HTMLAnchorElement) target.href = '#preview'; + const { actions, keyboardEvent } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('does not override an event already handled by another control', () => { + const target = document.createElement('div'); + target.addEventListener('keydown', (event) => event.preventDefault(), { + once: true, + }); + const { actions } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + }); +});