From 07a5e0d3e6f910fb016c67f381a91733e1260f30 Mon Sep 17 00:00:00 2001 From: BassemHalim Date: Sat, 13 Dec 2025 16:11:56 -0800 Subject: [PATCH 1/3] reactor: extract keyboard shortcuts logic from core.ts - extracted handleKeyboardShortcuts into it's own module - added tests to verify functionality --- src/livecodes/core.ts | 207 +----- .../__tests__/keyboard-shortcuts.test.ts | 624 ++++++++++++++++++ src/livecodes/handlers/index.ts | 2 + src/livecodes/handlers/keyboard-shortcuts.ts | 342 ++++++++++ 4 files changed, 981 insertions(+), 194 deletions(-) create mode 100644 src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts create mode 100644 src/livecodes/handlers/index.ts create mode 100644 src/livecodes/handlers/keyboard-shortcuts.ts diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index 2641773a5..79cf01695 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -45,6 +45,7 @@ import { customEvents } from './events/custom-events'; import { exportJSON } from './export/export-json'; import { getFormatter } from './formatter'; import type { Formatter } from './formatter/models'; +import { setupKeyboardShortcuts } from './handlers/keyboard-shortcuts'; import { aboutScreen, customSettingsScreen, @@ -2566,199 +2567,6 @@ const handleChangeContent = () => { }); }; -const handleKeyboardShortcuts = () => { - let lastkeys = ''; - - const hotKeys = async (e: KeyboardEvent) => { - // Ctrl + P opens the command palette - const activeEditor = getActiveEditor(); - if (ctrl(e) && e.code === 'KeyP' && activeEditor.monaco) { - e.preventDefault(); - activeEditor.monaco.trigger('anyString', 'editor.action.quickCommand'); - lastkeys = 'Ctrl + P'; - return; - } - - // Ctrl + D prevents browser bookmark dialog - if (ctrl(e) && e.code === 'KeyD') { - e.preventDefault(); - lastkeys = 'Ctrl + D'; - return; - } - - // Ctrl + Alt + C: toggle console - if (ctrl(e) && e.altKey && e.code === 'KeyC') { - e.preventDefault(); - lastkeys = 'Ctrl + Alt + C'; - UI.getConsoleButton()?.dispatchEvent(new Event('touchstart')); - return; - } - - // Ctrl + Alt + C, F: maximize console - if (ctrl(e) && e.altKey && e.code === 'KeyF' && lastkeys === 'Ctrl + Alt + C') { - e.preventDefault(); - lastkeys = 'Ctrl + Alt + C, F'; - UI.getConsoleButton()?.dispatchEvent(new Event('dblclick')); - return; - } - - // Ctrl + Alt + T runs tests - if (ctrl(e) && e.altKey && e.code === 'KeyT') { - e.preventDefault(); - UI.getRunTestsButton()?.click(); - lastkeys = 'Ctrl + Alt + T'; - return; - } - - // Shift + Enter triggers run - if (e.shiftKey && e.key === 'Enter') { - e.preventDefault(); - UI.getRunButton()?.click(); - lastkeys = 'Shift + Enter'; - return; - } - - // Ctrl + Alt + R toggles result page - if (ctrl(e) && e.altKey && e.code === 'KeyR') { - e.preventDefault(); - UI.getResultButton()?.click(); - lastkeys = 'Ctrl + Alt + R'; - return; - } - - // Ctrl + Alt + Z toggles result zoom - if (ctrl(e) && e.altKey && e.code === 'KeyZ') { - e.preventDefault(); - UI.getZoomButton()?.click(); - lastkeys = 'Ctrl + Alt + Z'; - return; - } - - // Ctrl + Alt + E focuses active editor - if (ctrl(e) && e.altKey && e.code === 'KeyE') { - e.preventDefault(); - getActiveEditor().focus(); - lastkeys = 'Ctrl + Alt + E'; - return; - } - - // Esc closes dropdown menus - // Esc + Esc moves focus out of editor - // Esc + Esc + Esc moves focus to logo - if (e.code === 'Escape') { - document.querySelectorAll('.menu-scroller').forEach((el) => el.classList.add('hidden')); - if (lastkeys === 'Esc') { - e.preventDefault(); - if ( - (toolsPane?.getStatus() === 'open' || toolsPane?.getStatus() === 'full') && - toolsPane.getActiveTool() === 'console' - ) { - UI.getConsoleButton()?.focus(); - } else { - UI.getFocusButton()?.focus(); - } - lastkeys = 'Esc + Esc'; - return; - } - if (lastkeys === 'Esc + Esc') { - e.preventDefault(); - UI.getLogoLink()?.focus(); - lastkeys = 'Esc + Esc + Esc'; - return; - } - lastkeys = 'Esc'; - return; - } - - // Ctrl + Alt + (1-3) activates editor 1-3 - // Ctrl + Alt + (ArrowLeft/ArrowRight) activates previous/next editor - const editorIds = (['markup', 'style', 'script'] as EditorId[]).filter( - (id) => getConfig()[id].hideTitle !== true, - ); - if (ctrl(e) && e.altKey && ['1', '2', '3', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { - e.preventDefault(); - split?.show('code'); - const index = ['1', '2', '3'].includes(e.key) - ? Number(e.key) - 1 - : e.key === 'ArrowLeft' - ? editorIds.findIndex((id) => id === getConfig().activeEditor) - 1 || 0 - : e.key === 'ArrowRight' - ? editorIds.findIndex((id) => id === getConfig().activeEditor) + 1 || 0 - : 0; - const editorIndex = - index === editorIds.length ? 0 : index === -1 ? editorIds.length - 1 : index; - showEditor(editorIds[editorIndex] as EditorId); - lastkeys = 'Ctrl + Alt + ' + e.key; - return; - } - - if (isEmbed) return; - - // Ctrl + Alt + N: new project - if (ctrl(e) && e.altKey && e.code === 'KeyN') { - e.preventDefault(); - UI.getNewLink()?.click(); - lastkeys = 'Ctrl + Alt + N'; - return; - } - - // Ctrl + O: open project - if (ctrl(e) && e.code === 'KeyO') { - e.preventDefault(); - UI.getOpenLink()?.click(); - lastkeys = 'Ctrl + O'; - return; - } - - // Ctrl + Alt + I: import - if (ctrl(e) && e.altKey && e.code === 'KeyI') { - e.preventDefault(); - UI.getImportLink()?.click(); - lastkeys = 'Ctrl + Alt + I'; - return; - } - - // Ctrl + Alt + S: share - if (ctrl(e) && e.altKey && e.code === 'KeyS') { - e.preventDefault(); - UI.getShareLink()?.click(); - lastkeys = 'Ctrl + Alt + S'; - return; - } - - // Ctrl + Shift + S forks the project (save as...) - if (ctrl(e) && e.shiftKey && e.code === 'KeyS') { - e.preventDefault(); - UI.getForkLink()?.click(); - lastkeys = 'Ctrl + Shift + S'; - return; - } - - // Ctrl + S saves the project - if (ctrl(e) && e.code === 'KeyS') { - e.preventDefault(); - UI.getSaveLink()?.click(); - lastkeys = 'Ctrl + S'; - return; - } - - // Ctrl + Alt + F toggles focus mode - if (ctrl(e) && e.altKey && e.code === 'KeyF') { - e.preventDefault(); - UI.getFocusButton()?.click(); - lastkeys = 'Ctrl + Alt + F'; - return; - } - - if (!ctrl(e) && !e.altKey && !e.shiftKey) { - lastkeys = e.key; - return; - } - }; - - eventsManager.addEventListener(window, 'keydown', hotKeys, true); -}; - const handleKeyboardShortcutsScreen = () => { if (isEmbed) return; @@ -5151,7 +4959,18 @@ const basicHandlers = () => { handleSelectEditor(); handleChangeLanguage(); handleChangeContent(); - handleKeyboardShortcuts(); + // Setup keyboard shortcuts with dependency injection + setupKeyboardShortcuts({ + eventsManager, + getActiveEditor, + getConfig, + showEditor, + run, + showScreen, + toolsPane, + split, + isEmbed, + }); handleRunButton(); handleResultButton(); handleShareButton(); diff --git a/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts b/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts new file mode 100644 index 000000000..007d383f0 --- /dev/null +++ b/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts @@ -0,0 +1,624 @@ +import * as UI from '../../UI/selectors'; +import { ctrl } from '../../utils'; +import { setupKeyboardShortcuts, type KeyboardShortcutDeps } from '../keyboard-shortcuts'; + +// Mock the UI selectors module +jest.mock('../../UI/selectors', () => ({ + getConsoleButton: jest.fn(), + getRunTestsButton: jest.fn(), + getRunButton: jest.fn(), + getResultButton: jest.fn(), + getZoomButton: jest.fn(), + getFocusButton: jest.fn(), + getLogoLink: jest.fn(), + getNewLink: jest.fn(), + getOpenLink: jest.fn(), + getImportLink: jest.fn(), + getShareLink: jest.fn(), + getForkLink: jest.fn(), + getSaveLink: jest.fn(), +})); + +// Mock the utils module +jest.mock('../../utils', () => ({ + ctrl: jest.fn(), +})); + +describe('Keyboard Shortcuts Handler', () => { + let mockDeps: KeyboardShortcutDeps; + let mockEventsManager: any; + let mockActiveEditor: any; + let mockConfig: any; + let mockToolsPane: any; + let mockSplit: any; + let keydownHandler: (e: KeyboardEvent) => void; + + const simulateKeydown = (eventProps: Partial) => { + const mockEvent = { + preventDefault: jest.fn(), + ctrlKey: false, + altKey: false, + shiftKey: false, + ...eventProps, + } as any; + keydownHandler(mockEvent); + return mockEvent; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockActiveEditor = { + monaco: { + trigger: jest.fn(), + }, + focus: jest.fn(), + }; + + mockConfig = { + activeEditor: 'script', + markup: { hideTitle: false }, + style: { hideTitle: false }, + script: { hideTitle: false }, + }; + + mockToolsPane = { + getStatus: jest.fn().mockReturnValue('closed'), + getActiveTool: jest.fn().mockReturnValue('console'), + }; + + mockSplit = { + show: jest.fn(), + }; + + mockEventsManager = { + addEventListener: jest.fn((_, __, handler) => { + keydownHandler = handler; + }), + removeEventListener: jest.fn(), + }; + + mockDeps = { + eventsManager: mockEventsManager, + getActiveEditor: jest.fn().mockReturnValue(mockActiveEditor), + getConfig: jest.fn().mockReturnValue(mockConfig), + showEditor: jest.fn(), + run: jest.fn(), + showScreen: jest.fn(), + toolsPane: mockToolsPane, + split: mockSplit, + isEmbed: false, + }; + + // Mock ctrl function + (ctrl as jest.Mock).mockImplementation((e: KeyboardEvent) => e?.ctrlKey || e?.metaKey || false); + + // Mock document.querySelectorAll for menu elements + document.querySelectorAll = jest.fn().mockReturnValue([{ classList: { add: jest.fn() } }]); + }); + + describe('setupKeyboardShortcuts', () => { + it('should add event listener to window', () => { + setupKeyboardShortcuts(mockDeps); + + expect(mockEventsManager.addEventListener).toHaveBeenCalledWith( + window, + 'keydown', + expect.any(Function), + true, + ); + }); + + it('should handle keyboard events and update lastkeys', () => { + setupKeyboardShortcuts(mockDeps); + simulateKeydown({ ctrlKey: true, code: 'KeyP' }); + expect(mockActiveEditor.monaco.trigger).toHaveBeenCalled(); + }); + + it('should handle non-modifier keys by updating lastkeys', () => { + setupKeyboardShortcuts(mockDeps); + // Should not throw + expect(() => { + simulateKeydown({ key: 'a' }); + }).not.toThrow(); + }); + }); + + describe('Command Palette (Ctrl+P)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should trigger Monaco command palette on Ctrl+P', () => { + const event = simulateKeydown({ ctrlKey: true, code: 'KeyP' }); + + expect(mockActiveEditor.monaco.trigger).toHaveBeenCalledWith( + 'anyString', + 'editor.action.quickCommand', + ); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should return false when Monaco is not available', () => { + mockActiveEditor.monaco = null; + const event = simulateKeydown({ ctrlKey: true, code: 'KeyP' }); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should return false for non-Ctrl+P combinations', () => { + simulateKeydown({ ctrlKey: false, code: 'KeyP' }); + expect(mockActiveEditor.monaco.trigger).not.toHaveBeenCalled(); + }); + }); + + describe('Prevent Bookmark (Ctrl+D)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should prevent default on Ctrl+D', () => { + const event = simulateKeydown({ ctrlKey: true, code: 'KeyD' }); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should return false for non-Ctrl+D combinations', () => { + const event = simulateKeydown({ ctrlKey: false, code: 'KeyD' }); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('Console Toggle (Ctrl+Alt+C)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should toggle console on Ctrl+Alt+C', () => { + const mockConsoleButton = { dispatchEvent: jest.fn() }; + (UI.getConsoleButton as jest.Mock).mockReturnValue(mockConsoleButton); + + const event = simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyC' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockConsoleButton.dispatchEvent).toHaveBeenCalled(); + }); + + it('should handle missing console button gracefully', () => { + (UI.getConsoleButton as jest.Mock).mockReturnValue(null); + + expect(() => { + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyC' }); + }).not.toThrow(); + }); + }); + + describe('Console Maximize (Ctrl+Alt+F after Ctrl+Alt+C)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should maximize console on Ctrl+Alt+F after Ctrl+Alt+C', () => { + const mockConsoleButton = { dispatchEvent: jest.fn() }; + (UI.getConsoleButton as jest.Mock).mockReturnValue(mockConsoleButton); + + // First press Ctrl+Alt+C + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyC' }); + mockConsoleButton.dispatchEvent.mockClear(); + + // Then press Ctrl+Alt+F + const event = simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyF' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockConsoleButton.dispatchEvent).toHaveBeenCalled(); + }); + + it('should return false when lastkeys is not Ctrl+Alt+C', () => { + const mockConsoleButton = { dispatchEvent: jest.fn() }; + (UI.getConsoleButton as jest.Mock).mockReturnValue(mockConsoleButton); + const mockFocusButton = { click: jest.fn() }; + (UI.getFocusButton as jest.Mock).mockReturnValue(mockFocusButton); + + // Press Ctrl+Alt+F without prior Ctrl+Alt+C (in non-embed mode, this triggers focus mode) + mockDeps.isEmbed = false; + setupKeyboardShortcuts(mockDeps); + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyF' }); + + // Console maximize should not be triggered (dblclick event) + const dblclickCalls = mockConsoleButton.dispatchEvent.mock.calls.filter( + (call: any) => call[0]?.type === 'dblclick', + ); + expect(dblclickCalls.length).toBe(0); + }); + }); + + describe('Run Tests (Ctrl+Alt+T)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should run tests on Ctrl+Alt+T', () => { + const mockRunTestsButton = { click: jest.fn() }; + (UI.getRunTestsButton as jest.Mock).mockReturnValue(mockRunTestsButton); + + const event = simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyT' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockRunTestsButton.click).toHaveBeenCalled(); + }); + + it('should handle missing run tests button gracefully', () => { + (UI.getRunTestsButton as jest.Mock).mockReturnValue(null); + + expect(() => { + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyT' }); + }).not.toThrow(); + }); + }); + + describe('Run Code (Shift+Enter)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should run code on Shift+Enter', () => { + const mockRunButton = { click: jest.fn() }; + (UI.getRunButton as jest.Mock).mockReturnValue(mockRunButton); + + const event = simulateKeydown({ shiftKey: true, key: 'Enter' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockRunButton.click).toHaveBeenCalled(); + }); + + it('should return false for non-Shift+Enter combinations', () => { + const mockRunButton = { click: jest.fn() }; + (UI.getRunButton as jest.Mock).mockReturnValue(mockRunButton); + + simulateKeydown({ shiftKey: false, key: 'Enter' }); + expect(mockRunButton.click).not.toHaveBeenCalled(); + }); + }); + + describe('Result Toggle (Ctrl+Alt+R)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should toggle result on Ctrl+Alt+R', () => { + const mockResultButton = { click: jest.fn() }; + (UI.getResultButton as jest.Mock).mockReturnValue(mockResultButton); + + const event = simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyR' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockResultButton.click).toHaveBeenCalled(); + }); + }); + + describe('Zoom Toggle (Ctrl+Alt+Z)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should toggle zoom on Ctrl+Alt+Z', () => { + const mockZoomButton = { click: jest.fn() }; + (UI.getZoomButton as jest.Mock).mockReturnValue(mockZoomButton); + + const event = simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyZ' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockZoomButton.click).toHaveBeenCalled(); + }); + }); + + describe('Focus Editor (Ctrl+Alt+E)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should focus editor on Ctrl+Alt+E', () => { + const event = simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyE' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockActiveEditor.focus).toHaveBeenCalled(); + }); + }); + + describe('Escape Key Handling', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should hide menus and return "Esc" on first escape', () => { + simulateKeydown({ code: 'Escape' }); + expect(document.querySelectorAll).toHaveBeenCalledWith('.menu-scroller'); + }); + + it('should focus console button on second escape when tools pane is open', () => { + mockToolsPane.getStatus.mockReturnValue('open'); + const mockConsoleButton = { focus: jest.fn() }; + (UI.getConsoleButton as jest.Mock).mockReturnValue(mockConsoleButton); + + // First escape + simulateKeydown({ code: 'Escape' }); + // Second escape + const event = simulateKeydown({ code: 'Escape' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockConsoleButton.focus).toHaveBeenCalled(); + }); + + it('should focus focus button on second escape when tools pane is closed', () => { + mockToolsPane.getStatus.mockReturnValue('closed'); + const mockFocusButton = { focus: jest.fn() }; + (UI.getFocusButton as jest.Mock).mockReturnValue(mockFocusButton); + + // First escape + simulateKeydown({ code: 'Escape' }); + // Second escape + const event = simulateKeydown({ code: 'Escape' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockFocusButton.focus).toHaveBeenCalled(); + }); + + it('should focus logo link on third escape', () => { + const mockLogoLink = { focus: jest.fn() }; + (UI.getLogoLink as jest.Mock).mockReturnValue(mockLogoLink); + + // First, second, third escape + simulateKeydown({ code: 'Escape' }); + simulateKeydown({ code: 'Escape' }); + const event = simulateKeydown({ code: 'Escape' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockLogoLink.focus).toHaveBeenCalled(); + }); + + it('should return false for non-escape keys', () => { + const mockFocusButton = { focus: jest.fn() }; + (UI.getFocusButton as jest.Mock).mockReturnValue(mockFocusButton); + + simulateKeydown({ code: 'KeyA' }); + expect(mockFocusButton.focus).not.toHaveBeenCalled(); + }); + }); + + describe('Editor Switching (Ctrl+Alt+1/2/3/Arrow)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should switch to editor 1 on Ctrl+Alt+1', () => { + const event = simulateKeydown({ ctrlKey: true, altKey: true, key: '1' }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockSplit.show).toHaveBeenCalledWith('code'); + expect(mockDeps.showEditor).toHaveBeenCalledWith('markup'); + }); + + it('should switch to editor 2 on Ctrl+Alt+2', () => { + simulateKeydown({ ctrlKey: true, altKey: true, key: '2' }); + expect(mockDeps.showEditor).toHaveBeenCalledWith('style'); + }); + + it('should switch to editor 3 on Ctrl+Alt+3', () => { + simulateKeydown({ ctrlKey: true, altKey: true, key: '3' }); + expect(mockDeps.showEditor).toHaveBeenCalledWith('script'); + }); + + it('should switch to previous editor on Ctrl+Alt+ArrowLeft', () => { + mockConfig.activeEditor = 'style'; + simulateKeydown({ ctrlKey: true, altKey: true, key: 'ArrowLeft' }); + expect(mockDeps.showEditor).toHaveBeenCalledWith('markup'); + }); + + it('should switch to next editor on Ctrl+Alt+ArrowRight', () => { + mockConfig.activeEditor = 'markup'; + simulateKeydown({ ctrlKey: true, altKey: true, key: 'ArrowRight' }); + expect(mockDeps.showEditor).toHaveBeenCalledWith('style'); + }); + + it('should wrap around when switching beyond available editors', () => { + mockConfig.activeEditor = 'script'; + simulateKeydown({ ctrlKey: true, altKey: true, key: 'ArrowRight' }); + expect(mockDeps.showEditor).toHaveBeenCalledWith('markup'); + }); + + it('should handle hidden editors correctly', () => { + mockConfig.style.hideTitle = true; + simulateKeydown({ ctrlKey: true, altKey: true, key: '2' }); + // With style hidden, editor 2 should be script + expect(mockDeps.showEditor).toHaveBeenCalledWith('script'); + }); + + it('should return false for non-editor switch combinations', () => { + simulateKeydown({ ctrlKey: true, altKey: true, key: '5' }); + expect(mockDeps.showEditor).not.toHaveBeenCalled(); + }); + }); + + describe('Project Management Shortcuts (non-embed only)', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should create new project on Ctrl+Alt+N when not embed', () => { + const mockNewLink = { click: jest.fn() }; + (UI.getNewLink as jest.Mock).mockReturnValue(mockNewLink); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyN' }); + expect(mockNewLink.click).toHaveBeenCalled(); + }); + + it('should open project on Ctrl+O when not embed', () => { + const mockOpenLink = { click: jest.fn() }; + (UI.getOpenLink as jest.Mock).mockReturnValue(mockOpenLink); + + simulateKeydown({ ctrlKey: true, code: 'KeyO' }); + expect(mockOpenLink.click).toHaveBeenCalled(); + }); + + it('should import on Ctrl+Alt+I when not embed', () => { + const mockImportLink = { click: jest.fn() }; + (UI.getImportLink as jest.Mock).mockReturnValue(mockImportLink); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyI' }); + expect(mockImportLink.click).toHaveBeenCalled(); + }); + + it('should share on Ctrl+Alt+S when not embed', () => { + const mockShareLink = { click: jest.fn() }; + (UI.getShareLink as jest.Mock).mockReturnValue(mockShareLink); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyS' }); + expect(mockShareLink.click).toHaveBeenCalled(); + }); + + it('should fork on Ctrl+Shift+S when not embed', () => { + const mockForkLink = { click: jest.fn() }; + (UI.getForkLink as jest.Mock).mockReturnValue(mockForkLink); + + simulateKeydown({ ctrlKey: true, shiftKey: true, code: 'KeyS' }); + expect(mockForkLink.click).toHaveBeenCalled(); + }); + + it('should save on Ctrl+S when not embed', () => { + const mockSaveLink = { click: jest.fn() }; + (UI.getSaveLink as jest.Mock).mockReturnValue(mockSaveLink); + + simulateKeydown({ ctrlKey: true, code: 'KeyS' }); + expect(mockSaveLink.click).toHaveBeenCalled(); + }); + + it('should toggle focus mode on Ctrl+Alt+F when not embed', () => { + const mockFocusButton = { click: jest.fn() }; + (UI.getFocusButton as jest.Mock).mockReturnValue(mockFocusButton); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyF' }); + expect(mockFocusButton.click).toHaveBeenCalled(); + }); + }); + + describe('Embed Mode Restrictions', () => { + beforeEach(() => { + mockDeps.isEmbed = true; + setupKeyboardShortcuts(mockDeps); + }); + + it('should return false for new project when in embed mode', () => { + const mockNewLink = { click: jest.fn() }; + (UI.getNewLink as jest.Mock).mockReturnValue(mockNewLink); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyN' }); + expect(mockNewLink.click).not.toHaveBeenCalled(); + }); + + it('should return false for open project when in embed mode', () => { + const mockOpenLink = { click: jest.fn() }; + (UI.getOpenLink as jest.Mock).mockReturnValue(mockOpenLink); + + simulateKeydown({ ctrlKey: true, code: 'KeyO' }); + expect(mockOpenLink.click).not.toHaveBeenCalled(); + }); + + it('should return false for save when in embed mode', () => { + const mockSaveLink = { click: jest.fn() }; + (UI.getSaveLink as jest.Mock).mockReturnValue(mockSaveLink); + + simulateKeydown({ ctrlKey: true, code: 'KeyS' }); + expect(mockSaveLink.click).not.toHaveBeenCalled(); + }); + + it('should return false for import when in embed mode', () => { + const mockImportLink = { click: jest.fn() }; + (UI.getImportLink as jest.Mock).mockReturnValue(mockImportLink); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyI' }); + expect(mockImportLink.click).not.toHaveBeenCalled(); + }); + + it('should return false for share when in embed mode', () => { + const mockShareLink = { click: jest.fn() }; + (UI.getShareLink as jest.Mock).mockReturnValue(mockShareLink); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyS' }); + expect(mockShareLink.click).not.toHaveBeenCalled(); + }); + + it('should return false for fork when in embed mode', () => { + const mockForkLink = { click: jest.fn() }; + (UI.getForkLink as jest.Mock).mockReturnValue(mockForkLink); + + simulateKeydown({ ctrlKey: true, shiftKey: true, code: 'KeyS' }); + expect(mockForkLink.click).not.toHaveBeenCalled(); + }); + + it('should return false for focus mode when in embed mode', () => { + const mockFocusButton = { click: jest.fn() }; + (UI.getFocusButton as jest.Mock).mockReturnValue(mockFocusButton); + + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyF' }); + expect(mockFocusButton.click).not.toHaveBeenCalled(); + }); + }); + + describe('Edge Cases and Error Handling', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should handle missing UI elements gracefully', () => { + (UI.getConsoleButton as jest.Mock).mockReturnValue(null); + (UI.getRunButton as jest.Mock).mockReturnValue(null); + (UI.getFocusButton as jest.Mock).mockReturnValue(null); + + expect(() => { + simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyC' }); + simulateKeydown({ shiftKey: true, key: 'Enter' }); + }).not.toThrow(); + }); + + it('should handle missing split gracefully', () => { + mockDeps.split = undefined; + setupKeyboardShortcuts(mockDeps); + + expect(() => { + simulateKeydown({ ctrlKey: true, altKey: true, key: '1' }); + }).not.toThrow(); + }); + + it('should handle missing dependencies gracefully', () => { + const incompleteDeps = { + ...mockDeps, + getActiveEditor: jest.fn().mockReturnValue({}), + toolsPane: undefined, + }; + setupKeyboardShortcuts(incompleteDeps); + + // Should not throw even with missing toolsPane + expect(() => { + simulateKeydown({ code: 'Escape' }); + simulateKeydown({ code: 'Escape' }); + }).not.toThrow(); + }); + }); + + describe('Key Combination Validation', () => { + beforeEach(() => setupKeyboardShortcuts(mockDeps)); + + it('should correctly identify Ctrl key combinations', () => { + const event1 = simulateKeydown({ ctrlKey: true, code: 'KeyD' }); + expect(event1.preventDefault).toHaveBeenCalled(); + + jest.clearAllMocks(); + (ctrl as jest.Mock).mockReturnValue(false); + const event2 = simulateKeydown({ ctrlKey: false, code: 'KeyD' }); + expect(event2.preventDefault).not.toHaveBeenCalled(); + }); + + it('should handle complex key combinations correctly', () => { + const validKeys = ['1', '2', '3', 'ArrowLeft', 'ArrowRight']; + + validKeys.forEach((key) => { + jest.clearAllMocks(); + (ctrl as jest.Mock).mockImplementation((e) => e?.ctrlKey || e?.metaKey || false); + simulateKeydown({ ctrlKey: true, altKey: true, key }); + expect(mockDeps.showEditor).toHaveBeenCalled(); + }); + }); + + it('should differentiate between similar key combinations', () => { + const mockSaveLink = { click: jest.fn() }; + const mockForkLink = { click: jest.fn() }; + (UI.getSaveLink as jest.Mock).mockReturnValue(mockSaveLink); + (UI.getForkLink as jest.Mock).mockReturnValue(mockForkLink); + + // Ctrl+S (save) + simulateKeydown({ ctrlKey: true, shiftKey: false, code: 'KeyS' }); + expect(mockSaveLink.click).toHaveBeenCalled(); + + jest.clearAllMocks(); + (UI.getSaveLink as jest.Mock).mockReturnValue(mockSaveLink); + (UI.getForkLink as jest.Mock).mockReturnValue(mockForkLink); + + // Ctrl+Shift+S (fork) - fork is checked before save + simulateKeydown({ ctrlKey: true, shiftKey: true, code: 'KeyS' }); + expect(mockForkLink.click).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/livecodes/handlers/index.ts b/src/livecodes/handlers/index.ts new file mode 100644 index 000000000..50e31ea76 --- /dev/null +++ b/src/livecodes/handlers/index.ts @@ -0,0 +1,2 @@ +// Barrel export for all handler modules +export * from './keyboard-shortcuts'; diff --git a/src/livecodes/handlers/keyboard-shortcuts.ts b/src/livecodes/handlers/keyboard-shortcuts.ts new file mode 100644 index 000000000..2a39a3ac8 --- /dev/null +++ b/src/livecodes/handlers/keyboard-shortcuts.ts @@ -0,0 +1,342 @@ +import * as UI from '../UI/selectors'; +import type { CodeEditor, Config, EditorId, EventsManager, ToolsPane } from '../models'; +import { ctrl } from '../utils'; + +/** + * Keyboard shortcut handler dependencies + */ +export interface KeyboardShortcutDeps { + eventsManager: EventsManager; + getActiveEditor: () => CodeEditor; + getConfig: () => Config; + showEditor: (editorId: EditorId) => void; + run: () => Promise; + showScreen: any; + toolsPane?: ToolsPane; + split?: any; + isEmbed: boolean; +} + +/** + * Creates individual shortcut handlers as pure functions + */ +const createCommandPaletteHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + const activeEditor = deps.getActiveEditor(); + if (ctrl(e) && e.code === 'KeyP' && activeEditor.monaco) { + e.preventDefault(); + activeEditor.monaco.trigger('anyString', 'editor.action.quickCommand'); + return true; + } + return false; +}; + +const createPreventBookmarkHandler = () => (e: KeyboardEvent) => { + if (ctrl(e) && e.code === 'KeyD') { + e.preventDefault?.(); + return true; + } + return false; +}; + +const createConsoleToggleHandler = () => (e: KeyboardEvent) => { + if (ctrl(e) && e.altKey && e.code === 'KeyC') { + e.preventDefault(); + UI.getConsoleButton()?.dispatchEvent(new Event('touchstart')); + return true; + } + return false; +}; + +const createConsoleMaximizeHandler = (lastkeys: string) => (e: KeyboardEvent) => { + if (ctrl(e) && e.altKey && e.code === 'KeyF' && lastkeys === 'Ctrl + Alt + C') { + e.preventDefault(); + UI.getConsoleButton()?.dispatchEvent(new Event('dblclick')); + return true; + } + return false; +}; + +const createRunTestsHandler = () => (e: KeyboardEvent) => { + if (ctrl(e) && e.altKey && e.code === 'KeyT') { + e.preventDefault(); + UI.getRunTestsButton()?.click(); + return true; + } + return false; +}; + +const createRunHandler = () => (e: KeyboardEvent) => { + if (e.shiftKey && e.key === 'Enter') { + e.preventDefault(); + UI.getRunButton()?.click(); + return true; + } + return false; +}; + +const createResultToggleHandler = () => (e: KeyboardEvent) => { + if (ctrl(e) && e.altKey && e.code === 'KeyR') { + e.preventDefault(); + UI.getResultButton()?.click(); + return true; + } + return false; +}; + +const createZoomToggleHandler = () => (e: KeyboardEvent) => { + if (ctrl(e) && e.altKey && e.code === 'KeyZ') { + e.preventDefault(); + UI.getZoomButton()?.click(); + return true; + } + return false; +}; + +const createFocusEditorHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (ctrl(e) && e.altKey && e.code === 'KeyE') { + e.preventDefault(); + deps.getActiveEditor().focus(); + return true; + } + return false; +}; + +// Esc closes dropdown menus +// Esc + Esc moves focus out of editor +// Esc + Esc + Esc moves focus to logo +const createEscapeHandler = + (deps: KeyboardShortcutDeps, lastkeys: string) => (e: KeyboardEvent) => { + if (e.code === 'Escape') { + document.querySelectorAll('.menu-scroller').forEach((el) => el.classList.add('hidden')); + if (lastkeys === 'Esc') { + e.preventDefault(); + if ( + (deps.toolsPane?.getStatus() === 'open' || deps.toolsPane?.getStatus() === 'full') && + deps.toolsPane.getActiveTool() === 'console' + ) { + UI.getConsoleButton()?.focus(); + } else { + UI.getFocusButton()?.focus(); + } + return 'Esc + Esc'; + } + if (lastkeys === 'Esc + Esc') { + e.preventDefault(); + UI.getLogoLink()?.focus(); + return 'Esc + Esc + Esc'; + } + return 'Esc'; + } + return false; + }; + +// Ctrl + Alt + (1-3) activates editor 1-3 +// Ctrl + Alt + (ArrowLeft/ArrowRight) activates previous/next editor +const createEditorSwitchHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (ctrl(e) && e.altKey && ['1', '2', '3', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + const editorIds = (['markup', 'style', 'script'] as const).filter( + (id) => deps.getConfig()[id].hideTitle !== true, + ); + + e.preventDefault(); + deps.split?.show('code'); + + const index = ['1', '2', '3'].includes(e.key) + ? Number(e.key) - 1 + : e.key === 'ArrowLeft' + ? editorIds.findIndex((id) => id === deps.getConfig().activeEditor) - 1 || 0 + : e.key === 'ArrowRight' + ? editorIds.findIndex((id) => id === deps.getConfig().activeEditor) + 1 || 0 + : 0; + + const editorIndex = + index === editorIds.length ? 0 : index === -1 ? editorIds.length - 1 : index; + + deps.showEditor(editorIds[editorIndex]); + return true; + } + return false; +}; + +const createNewProjectHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyN') { + e.preventDefault(); + UI.getNewLink()?.click(); + return true; + } + return false; +}; + +const createOpenProjectHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (!deps.isEmbed && ctrl(e) && e.code === 'KeyO') { + e.preventDefault(); + UI.getOpenLink()?.click(); + return true; + } + return false; +}; + +const createImportHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyI') { + e.preventDefault(); + UI.getImportLink()?.click(); + return true; + } + return false; +}; + +const createShareHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyS') { + e.preventDefault(); + UI.getShareLink()?.click(); + return true; + } + return false; +}; + +const createForkHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (!deps.isEmbed && ctrl(e) && e.shiftKey && e.code === 'KeyS') { + e.preventDefault(); + UI.getForkLink()?.click(); + return true; + } + return false; +}; + +const createSaveHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (!deps.isEmbed && ctrl(e) && e.code === 'KeyS') { + e.preventDefault(); + UI.getSaveLink()?.click(); + return true; + } + return false; +}; + +const createFocusModeHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyF') { + e.preventDefault(); + UI.getFocusButton()?.click(); + return true; + } + return false; +}; + +/** + * Sets up all keyboard shortcuts and event listeners + */ +export const setupKeyboardShortcuts = (deps: KeyboardShortcutDeps): void => { + let lastkeys = ''; + + const hotKeys = async (e: KeyboardEvent) => { + // Command palette handler + if (createCommandPaletteHandler(deps)(e)) { + lastkeys = 'Ctrl + P'; + return; + } + + // Prevent bookmark dialog + if (createPreventBookmarkHandler()(e)) { + lastkeys = 'Ctrl + D'; + return; + } + + // Console toggle + if (createConsoleToggleHandler()(e)) { + lastkeys = 'Ctrl + Alt + C'; + return; + } + + // Console maximize (depends on previous key) + if (createConsoleMaximizeHandler(lastkeys)(e)) { + lastkeys = 'Ctrl + Alt + C, F'; + return; + } + + // Run tests + if (createRunTestsHandler()(e)) { + lastkeys = 'Ctrl + Alt + T'; + return; + } + + // Run code + if (createRunHandler()(e)) { + lastkeys = 'Shift + Enter'; + return; + } + + // Result toggle + if (createResultToggleHandler()(e)) { + lastkeys = 'Ctrl + Alt + R'; + return; + } + + // Zoom toggle + if (createZoomToggleHandler()(e)) { + lastkeys = 'Ctrl + Alt + Z'; + return; + } + + // Focus editor + if (createFocusEditorHandler(deps)(e)) { + lastkeys = 'Ctrl + Alt + E'; + return; + } + + // Escape handling + const escapeResult = createEscapeHandler(deps, lastkeys)(e); + if (escapeResult) { + lastkeys = typeof escapeResult === 'string' ? escapeResult : 'Esc'; + return; + } + + // Editor switching + if (createEditorSwitchHandler(deps)(e)) { + lastkeys = 'Ctrl + Alt + ' + e.key; + return; + } + + // Project management shortcuts (only if not embed) + if (createNewProjectHandler(deps)(e)) { + lastkeys = 'Ctrl + Alt + N'; + return; + } + + if (createOpenProjectHandler(deps)(e)) { + lastkeys = 'Ctrl + O'; + return; + } + + if (createImportHandler(deps)(e)) { + lastkeys = 'Ctrl + Alt + I'; + return; + } + + if (createShareHandler(deps)(e)) { + lastkeys = 'Ctrl + Alt + S'; + return; + } + + if (createForkHandler(deps)(e)) { + lastkeys = 'Ctrl + Shift + S'; + return; + } + + if (createSaveHandler(deps)(e)) { + lastkeys = 'Ctrl + S'; + return; + } + + if (createFocusModeHandler(deps)(e)) { + lastkeys = 'Ctrl + Alt + F'; + return; + } + + // Update lastkeys for non-modifier keys + if (!ctrl(e) && !e.altKey && !e.shiftKey) { + lastkeys = e.key; + return; + } + }; + + deps.eventsManager.addEventListener(window, 'keydown', hotKeys, true); +}; From cddb1ea17da2c951f8ac51d6b318d97f1969f9cc Mon Sep 17 00:00:00 2001 From: BassemHalim Date: Sat, 13 Dec 2025 16:48:41 -0800 Subject: [PATCH 2/3] refactor: remove unused showScreen prop --- src/livecodes/core.ts | 1 - .../handlers/__tests__/keyboard-shortcuts.test.ts | 1 - src/livecodes/handlers/keyboard-shortcuts.ts | 8 ++++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index 79cf01695..b0b082d36 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -4966,7 +4966,6 @@ const basicHandlers = () => { getConfig, showEditor, run, - showScreen, toolsPane, split, isEmbed, diff --git a/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts b/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts index 007d383f0..072fae59f 100644 --- a/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts +++ b/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts @@ -84,7 +84,6 @@ describe('Keyboard Shortcuts Handler', () => { getConfig: jest.fn().mockReturnValue(mockConfig), showEditor: jest.fn(), run: jest.fn(), - showScreen: jest.fn(), toolsPane: mockToolsPane, split: mockSplit, isEmbed: false, diff --git a/src/livecodes/handlers/keyboard-shortcuts.ts b/src/livecodes/handlers/keyboard-shortcuts.ts index 2a39a3ac8..71a9ee78e 100644 --- a/src/livecodes/handlers/keyboard-shortcuts.ts +++ b/src/livecodes/handlers/keyboard-shortcuts.ts @@ -1,3 +1,4 @@ +import type { createSplitPanes } from '../UI'; import * as UI from '../UI/selectors'; import type { CodeEditor, Config, EditorId, EventsManager, ToolsPane } from '../models'; import { ctrl } from '../utils'; @@ -11,9 +12,8 @@ export interface KeyboardShortcutDeps { getConfig: () => Config; showEditor: (editorId: EditorId) => void; run: () => Promise; - showScreen: any; toolsPane?: ToolsPane; - split?: any; + split?: ReturnType; isEmbed: boolean; } @@ -32,7 +32,7 @@ const createCommandPaletteHandler = (deps: KeyboardShortcutDeps) => (e: Keyboard const createPreventBookmarkHandler = () => (e: KeyboardEvent) => { if (ctrl(e) && e.code === 'KeyD') { - e.preventDefault?.(); + e.preventDefault(); return true; } return false; @@ -227,7 +227,7 @@ const createFocusModeHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent export const setupKeyboardShortcuts = (deps: KeyboardShortcutDeps): void => { let lastkeys = ''; - const hotKeys = async (e: KeyboardEvent) => { + const hotKeys = (e: KeyboardEvent) => { // Command palette handler if (createCommandPaletteHandler(deps)(e)) { lastkeys = 'Ctrl + P'; From b6358e2489f89c9d25e6429caa0e3ba7fb6e5f97 Mon Sep 17 00:00:00 2001 From: BassemHalim Date: Fri, 2 Jan 2026 13:21:54 -0800 Subject: [PATCH 3/3] refactor(handlers): move lastkeys to module level and rename function - Move lastkeys state outside handlers and assign inside each handler - Rename setupKeyboardShortcuts to handleKeyboardShortcuts for consistency - Simplify hotKeys function by removing redundant lastkeys assignments --- src/livecodes/core.ts | 4 +- .../__tests__/keyboard-shortcuts.test.ts | 46 ++--- src/livecodes/handlers/keyboard-shortcuts.ts | 169 +++++++----------- 3 files changed, 93 insertions(+), 126 deletions(-) diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index b0b082d36..067b0bed0 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -45,7 +45,7 @@ import { customEvents } from './events/custom-events'; import { exportJSON } from './export/export-json'; import { getFormatter } from './formatter'; import type { Formatter } from './formatter/models'; -import { setupKeyboardShortcuts } from './handlers/keyboard-shortcuts'; +import { handleKeyboardShortcuts } from './handlers'; import { aboutScreen, customSettingsScreen, @@ -4960,7 +4960,7 @@ const basicHandlers = () => { handleChangeLanguage(); handleChangeContent(); // Setup keyboard shortcuts with dependency injection - setupKeyboardShortcuts({ + handleKeyboardShortcuts({ eventsManager, getActiveEditor, getConfig, diff --git a/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts b/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts index 072fae59f..18dcb4eb4 100644 --- a/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts +++ b/src/livecodes/handlers/__tests__/keyboard-shortcuts.test.ts @@ -1,6 +1,6 @@ import * as UI from '../../UI/selectors'; import { ctrl } from '../../utils'; -import { setupKeyboardShortcuts, type KeyboardShortcutDeps } from '../keyboard-shortcuts'; +import { handleKeyboardShortcuts, type KeyboardShortcutDeps } from '../keyboard-shortcuts'; // Mock the UI selectors module jest.mock('../../UI/selectors', () => ({ @@ -96,9 +96,9 @@ describe('Keyboard Shortcuts Handler', () => { document.querySelectorAll = jest.fn().mockReturnValue([{ classList: { add: jest.fn() } }]); }); - describe('setupKeyboardShortcuts', () => { + describe('handleKeyboardShortcuts', () => { it('should add event listener to window', () => { - setupKeyboardShortcuts(mockDeps); + handleKeyboardShortcuts(mockDeps); expect(mockEventsManager.addEventListener).toHaveBeenCalledWith( window, @@ -109,13 +109,13 @@ describe('Keyboard Shortcuts Handler', () => { }); it('should handle keyboard events and update lastkeys', () => { - setupKeyboardShortcuts(mockDeps); + handleKeyboardShortcuts(mockDeps); simulateKeydown({ ctrlKey: true, code: 'KeyP' }); expect(mockActiveEditor.monaco.trigger).toHaveBeenCalled(); }); it('should handle non-modifier keys by updating lastkeys', () => { - setupKeyboardShortcuts(mockDeps); + handleKeyboardShortcuts(mockDeps); // Should not throw expect(() => { simulateKeydown({ key: 'a' }); @@ -124,7 +124,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Command Palette (Ctrl+P)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should trigger Monaco command palette on Ctrl+P', () => { const event = simulateKeydown({ ctrlKey: true, code: 'KeyP' }); @@ -150,7 +150,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Prevent Bookmark (Ctrl+D)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should prevent default on Ctrl+D', () => { const event = simulateKeydown({ ctrlKey: true, code: 'KeyD' }); @@ -164,7 +164,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Console Toggle (Ctrl+Alt+C)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should toggle console on Ctrl+Alt+C', () => { const mockConsoleButton = { dispatchEvent: jest.fn() }; @@ -186,7 +186,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Console Maximize (Ctrl+Alt+F after Ctrl+Alt+C)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should maximize console on Ctrl+Alt+F after Ctrl+Alt+C', () => { const mockConsoleButton = { dispatchEvent: jest.fn() }; @@ -211,7 +211,7 @@ describe('Keyboard Shortcuts Handler', () => { // Press Ctrl+Alt+F without prior Ctrl+Alt+C (in non-embed mode, this triggers focus mode) mockDeps.isEmbed = false; - setupKeyboardShortcuts(mockDeps); + handleKeyboardShortcuts(mockDeps); simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyF' }); // Console maximize should not be triggered (dblclick event) @@ -223,7 +223,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Run Tests (Ctrl+Alt+T)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should run tests on Ctrl+Alt+T', () => { const mockRunTestsButton = { click: jest.fn() }; @@ -245,7 +245,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Run Code (Shift+Enter)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should run code on Shift+Enter', () => { const mockRunButton = { click: jest.fn() }; @@ -267,7 +267,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Result Toggle (Ctrl+Alt+R)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should toggle result on Ctrl+Alt+R', () => { const mockResultButton = { click: jest.fn() }; @@ -281,7 +281,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Zoom Toggle (Ctrl+Alt+Z)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should toggle zoom on Ctrl+Alt+Z', () => { const mockZoomButton = { click: jest.fn() }; @@ -295,7 +295,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Focus Editor (Ctrl+Alt+E)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should focus editor on Ctrl+Alt+E', () => { const event = simulateKeydown({ ctrlKey: true, altKey: true, code: 'KeyE' }); @@ -306,7 +306,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Escape Key Handling', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should hide menus and return "Esc" on first escape', () => { simulateKeydown({ code: 'Escape' }); @@ -364,7 +364,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Editor Switching (Ctrl+Alt+1/2/3/Arrow)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should switch to editor 1 on Ctrl+Alt+1', () => { const event = simulateKeydown({ ctrlKey: true, altKey: true, key: '1' }); @@ -416,7 +416,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Project Management Shortcuts (non-embed only)', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should create new project on Ctrl+Alt+N when not embed', () => { const mockNewLink = { click: jest.fn() }; @@ -478,7 +478,7 @@ describe('Keyboard Shortcuts Handler', () => { describe('Embed Mode Restrictions', () => { beforeEach(() => { mockDeps.isEmbed = true; - setupKeyboardShortcuts(mockDeps); + handleKeyboardShortcuts(mockDeps); }); it('should return false for new project when in embed mode', () => { @@ -539,7 +539,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Edge Cases and Error Handling', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should handle missing UI elements gracefully', () => { (UI.getConsoleButton as jest.Mock).mockReturnValue(null); @@ -554,7 +554,7 @@ describe('Keyboard Shortcuts Handler', () => { it('should handle missing split gracefully', () => { mockDeps.split = undefined; - setupKeyboardShortcuts(mockDeps); + handleKeyboardShortcuts(mockDeps); expect(() => { simulateKeydown({ ctrlKey: true, altKey: true, key: '1' }); @@ -567,7 +567,7 @@ describe('Keyboard Shortcuts Handler', () => { getActiveEditor: jest.fn().mockReturnValue({}), toolsPane: undefined, }; - setupKeyboardShortcuts(incompleteDeps); + handleKeyboardShortcuts(incompleteDeps); // Should not throw even with missing toolsPane expect(() => { @@ -578,7 +578,7 @@ describe('Keyboard Shortcuts Handler', () => { }); describe('Key Combination Validation', () => { - beforeEach(() => setupKeyboardShortcuts(mockDeps)); + beforeEach(() => handleKeyboardShortcuts(mockDeps)); it('should correctly identify Ctrl key combinations', () => { const event1 = simulateKeydown({ ctrlKey: true, code: 'KeyD' }); diff --git a/src/livecodes/handlers/keyboard-shortcuts.ts b/src/livecodes/handlers/keyboard-shortcuts.ts index 71a9ee78e..4791bcba1 100644 --- a/src/livecodes/handlers/keyboard-shortcuts.ts +++ b/src/livecodes/handlers/keyboard-shortcuts.ts @@ -17,6 +17,11 @@ export interface KeyboardShortcutDeps { isEmbed: boolean; } +/** + * Module-level state for tracking key sequences + */ +let lastkeys = ''; + /** * Creates individual shortcut handlers as pure functions */ @@ -25,6 +30,7 @@ const createCommandPaletteHandler = (deps: KeyboardShortcutDeps) => (e: Keyboard if (ctrl(e) && e.code === 'KeyP' && activeEditor.monaco) { e.preventDefault(); activeEditor.monaco.trigger('anyString', 'editor.action.quickCommand'); + lastkeys = 'Ctrl + P'; return true; } return false; @@ -33,6 +39,7 @@ const createCommandPaletteHandler = (deps: KeyboardShortcutDeps) => (e: Keyboard const createPreventBookmarkHandler = () => (e: KeyboardEvent) => { if (ctrl(e) && e.code === 'KeyD') { e.preventDefault(); + lastkeys = 'Ctrl + D'; return true; } return false; @@ -42,15 +49,17 @@ const createConsoleToggleHandler = () => (e: KeyboardEvent) => { if (ctrl(e) && e.altKey && e.code === 'KeyC') { e.preventDefault(); UI.getConsoleButton()?.dispatchEvent(new Event('touchstart')); + lastkeys = 'Ctrl + Alt + C'; return true; } return false; }; -const createConsoleMaximizeHandler = (lastkeys: string) => (e: KeyboardEvent) => { +const createConsoleMaximizeHandler = () => (e: KeyboardEvent) => { if (ctrl(e) && e.altKey && e.code === 'KeyF' && lastkeys === 'Ctrl + Alt + C') { e.preventDefault(); UI.getConsoleButton()?.dispatchEvent(new Event('dblclick')); + lastkeys = 'Ctrl + Alt + C, F'; return true; } return false; @@ -60,6 +69,7 @@ const createRunTestsHandler = () => (e: KeyboardEvent) => { if (ctrl(e) && e.altKey && e.code === 'KeyT') { e.preventDefault(); UI.getRunTestsButton()?.click(); + lastkeys = 'Ctrl + Alt + T'; return true; } return false; @@ -69,6 +79,7 @@ const createRunHandler = () => (e: KeyboardEvent) => { if (e.shiftKey && e.key === 'Enter') { e.preventDefault(); UI.getRunButton()?.click(); + lastkeys = 'Shift + Enter'; return true; } return false; @@ -78,6 +89,7 @@ const createResultToggleHandler = () => (e: KeyboardEvent) => { if (ctrl(e) && e.altKey && e.code === 'KeyR') { e.preventDefault(); UI.getResultButton()?.click(); + lastkeys = 'Ctrl + Alt + R'; return true; } return false; @@ -87,6 +99,7 @@ const createZoomToggleHandler = () => (e: KeyboardEvent) => { if (ctrl(e) && e.altKey && e.code === 'KeyZ') { e.preventDefault(); UI.getZoomButton()?.click(); + lastkeys = 'Ctrl + Alt + Z'; return true; } return false; @@ -96,6 +109,7 @@ const createFocusEditorHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEve if (ctrl(e) && e.altKey && e.code === 'KeyE') { e.preventDefault(); deps.getActiveEditor().focus(); + lastkeys = 'Ctrl + Alt + E'; return true; } return false; @@ -104,31 +118,33 @@ const createFocusEditorHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEve // Esc closes dropdown menus // Esc + Esc moves focus out of editor // Esc + Esc + Esc moves focus to logo -const createEscapeHandler = - (deps: KeyboardShortcutDeps, lastkeys: string) => (e: KeyboardEvent) => { - if (e.code === 'Escape') { - document.querySelectorAll('.menu-scroller').forEach((el) => el.classList.add('hidden')); - if (lastkeys === 'Esc') { - e.preventDefault(); - if ( - (deps.toolsPane?.getStatus() === 'open' || deps.toolsPane?.getStatus() === 'full') && - deps.toolsPane.getActiveTool() === 'console' - ) { - UI.getConsoleButton()?.focus(); - } else { - UI.getFocusButton()?.focus(); - } - return 'Esc + Esc'; +const createEscapeHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => { + if (e.code === 'Escape') { + document.querySelectorAll('.menu-scroller').forEach((el) => el.classList.add('hidden')); + if (lastkeys === 'Esc') { + e.preventDefault(); + if ( + (deps.toolsPane?.getStatus() === 'open' || deps.toolsPane?.getStatus() === 'full') && + deps.toolsPane.getActiveTool() === 'console' + ) { + UI.getConsoleButton()?.focus(); + } else { + UI.getFocusButton()?.focus(); } - if (lastkeys === 'Esc + Esc') { - e.preventDefault(); - UI.getLogoLink()?.focus(); - return 'Esc + Esc + Esc'; - } - return 'Esc'; + lastkeys = 'Esc + Esc'; + return true; } - return false; - }; + if (lastkeys === 'Esc + Esc') { + e.preventDefault(); + UI.getLogoLink()?.focus(); + lastkeys = 'Esc + Esc + Esc'; + return true; + } + lastkeys = 'Esc'; + return true; + } + return false; +}; // Ctrl + Alt + (1-3) activates editor 1-3 // Ctrl + Alt + (ArrowLeft/ArrowRight) activates previous/next editor @@ -153,6 +169,7 @@ const createEditorSwitchHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEv index === editorIds.length ? 0 : index === -1 ? editorIds.length - 1 : index; deps.showEditor(editorIds[editorIndex]); + lastkeys = 'Ctrl + Alt + ' + e.key; return true; } return false; @@ -162,6 +179,7 @@ const createNewProjectHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEven if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyN') { e.preventDefault(); UI.getNewLink()?.click(); + lastkeys = 'Ctrl + Alt + N'; return true; } return false; @@ -171,6 +189,7 @@ const createOpenProjectHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEve if (!deps.isEmbed && ctrl(e) && e.code === 'KeyO') { e.preventDefault(); UI.getOpenLink()?.click(); + lastkeys = 'Ctrl + O'; return true; } return false; @@ -180,6 +199,7 @@ const createImportHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) = if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyI') { e.preventDefault(); UI.getImportLink()?.click(); + lastkeys = 'Ctrl + Alt + I'; return true; } return false; @@ -189,6 +209,7 @@ const createShareHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyS') { e.preventDefault(); UI.getShareLink()?.click(); + lastkeys = 'Ctrl + Alt + S'; return true; } return false; @@ -198,6 +219,7 @@ const createForkHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => if (!deps.isEmbed && ctrl(e) && e.shiftKey && e.code === 'KeyS') { e.preventDefault(); UI.getForkLink()?.click(); + lastkeys = 'Ctrl + Shift + S'; return true; } return false; @@ -207,6 +229,7 @@ const createSaveHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent) => if (!deps.isEmbed && ctrl(e) && e.code === 'KeyS') { e.preventDefault(); UI.getSaveLink()?.click(); + lastkeys = 'Ctrl + S'; return true; } return false; @@ -216,120 +239,64 @@ const createFocusModeHandler = (deps: KeyboardShortcutDeps) => (e: KeyboardEvent if (!deps.isEmbed && ctrl(e) && e.altKey && e.code === 'KeyF') { e.preventDefault(); UI.getFocusButton()?.click(); + lastkeys = 'Ctrl + Alt + F'; return true; } return false; }; /** - * Sets up all keyboard shortcuts and event listeners + * Handles all keyboard shortcuts and event listeners */ -export const setupKeyboardShortcuts = (deps: KeyboardShortcutDeps): void => { - let lastkeys = ''; - +export const handleKeyboardShortcuts = (deps: KeyboardShortcutDeps): void => { const hotKeys = (e: KeyboardEvent) => { // Command palette handler - if (createCommandPaletteHandler(deps)(e)) { - lastkeys = 'Ctrl + P'; - return; - } + if (createCommandPaletteHandler(deps)(e)) return; // Prevent bookmark dialog - if (createPreventBookmarkHandler()(e)) { - lastkeys = 'Ctrl + D'; - return; - } + if (createPreventBookmarkHandler()(e)) return; // Console toggle - if (createConsoleToggleHandler()(e)) { - lastkeys = 'Ctrl + Alt + C'; - return; - } + if (createConsoleToggleHandler()(e)) return; // Console maximize (depends on previous key) - if (createConsoleMaximizeHandler(lastkeys)(e)) { - lastkeys = 'Ctrl + Alt + C, F'; - return; - } + if (createConsoleMaximizeHandler()(e)) return; // Run tests - if (createRunTestsHandler()(e)) { - lastkeys = 'Ctrl + Alt + T'; - return; - } + if (createRunTestsHandler()(e)) return; // Run code - if (createRunHandler()(e)) { - lastkeys = 'Shift + Enter'; - return; - } + if (createRunHandler()(e)) return; // Result toggle - if (createResultToggleHandler()(e)) { - lastkeys = 'Ctrl + Alt + R'; - return; - } + if (createResultToggleHandler()(e)) return; // Zoom toggle - if (createZoomToggleHandler()(e)) { - lastkeys = 'Ctrl + Alt + Z'; - return; - } + if (createZoomToggleHandler()(e)) return; // Focus editor - if (createFocusEditorHandler(deps)(e)) { - lastkeys = 'Ctrl + Alt + E'; - return; - } + if (createFocusEditorHandler(deps)(e)) return; // Escape handling - const escapeResult = createEscapeHandler(deps, lastkeys)(e); - if (escapeResult) { - lastkeys = typeof escapeResult === 'string' ? escapeResult : 'Esc'; - return; - } + if (createEscapeHandler(deps)(e)) return; // Editor switching - if (createEditorSwitchHandler(deps)(e)) { - lastkeys = 'Ctrl + Alt + ' + e.key; - return; - } + if (createEditorSwitchHandler(deps)(e)) return; // Project management shortcuts (only if not embed) - if (createNewProjectHandler(deps)(e)) { - lastkeys = 'Ctrl + Alt + N'; - return; - } + if (createNewProjectHandler(deps)(e)) return; - if (createOpenProjectHandler(deps)(e)) { - lastkeys = 'Ctrl + O'; - return; - } + if (createOpenProjectHandler(deps)(e)) return; - if (createImportHandler(deps)(e)) { - lastkeys = 'Ctrl + Alt + I'; - return; - } + if (createImportHandler(deps)(e)) return; - if (createShareHandler(deps)(e)) { - lastkeys = 'Ctrl + Alt + S'; - return; - } + if (createShareHandler(deps)(e)) return; - if (createForkHandler(deps)(e)) { - lastkeys = 'Ctrl + Shift + S'; - return; - } + if (createForkHandler(deps)(e)) return; - if (createSaveHandler(deps)(e)) { - lastkeys = 'Ctrl + S'; - return; - } + if (createSaveHandler(deps)(e)) return; - if (createFocusModeHandler(deps)(e)) { - lastkeys = 'Ctrl + Alt + F'; - return; - } + if (createFocusModeHandler(deps)(e)) return; // Update lastkeys for non-modifier keys if (!ctrl(e) && !e.altKey && !e.shiftKey) {