diff --git a/src/__tests__/keyboard.test.ts b/src/__tests__/keyboard.test.ts new file mode 100644 index 0000000..ec11054 --- /dev/null +++ b/src/__tests__/keyboard.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import EventEmitter from 'eventemitter3'; +import { Keyboard, DEFAULT_SHORTCUTS } from '../mdeck/controllers/inputs/keyboard.js'; + +function fakeKey(key: string, extra: Partial = {}): KeyboardEvent { + return { key, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, preventDefault: vi.fn(), ...extra } as unknown as KeyboardEvent; +} + +describe('Keyboard', () => { + let events: EventEmitter; + let emitted: string[]; + + beforeEach(() => { + events = new EventEmitter(); + emitted = []; + // Track all emitted actions + for (const action of Object.keys(DEFAULT_SHORTCUTS)) { + events.on(action, () => emitted.push(action)); + } + events.on('gotoSlideNumber', (n: string) => emitted.push(`gotoSlideNumber:${n}`)); + }); + + function press(_kb: unknown, key: string, extra: Partial = {}) { + events.emit('keydown', fakeKey(key, extra)); + } + + describe('default shortcuts', () => { + it('ArrowRight emits gotoNextSlide', () => { + new Keyboard(events); + press(null!, 'ArrowRight'); + expect(emitted).toContain('gotoNextSlide'); + }); + + it('j emits gotoNextSlide', () => { + new Keyboard(events); + press(null!, 'j'); + expect(emitted).toContain('gotoNextSlide'); + }); + + it('Space emits gotoNextSlide', () => { + new Keyboard(events); + press(null!, ' '); + expect(emitted).toContain('gotoNextSlide'); + }); + + it('Shift+Space emits gotoPreviousSlide', () => { + new Keyboard(events); + press(null!, ' ', { shiftKey: true }); + expect(emitted).toContain('gotoPreviousSlide'); + expect(emitted).not.toContain('gotoNextSlide'); + }); + + it('ArrowLeft emits gotoPreviousSlide', () => { + new Keyboard(events); + press(null!, 'ArrowLeft'); + expect(emitted).toContain('gotoPreviousSlide'); + }); + + it('p emits togglePresenterMode', () => { + new Keyboard(events); + press(null!, 'p'); + expect(emitted).toContain('togglePresenterMode'); + }); + + it('Escape emits hideOverlay', () => { + new Keyboard(events); + events.on('hideOverlay', () => emitted.push('hideOverlay')); + press(null!, 'Escape'); + expect(emitted).toContain('hideOverlay'); + }); + + it('digits then Enter emits gotoSlideNumber', () => { + new Keyboard(events); + press(null!, '4'); + press(null!, '2'); + press(null!, 'Enter'); + expect(emitted).toContain('gotoSlideNumber:42'); + }); + + it('Enter without digits does nothing', () => { + new Keyboard(events); + press(null!, 'Enter'); + expect(emitted).toHaveLength(0); + }); + + it('ignores keydown with metaKey', () => { + new Keyboard(events); + press(null!, 'j', { metaKey: true }); + expect(emitted).toHaveLength(0); + }); + + it('ignores keydown with ctrlKey', () => { + new Keyboard(events); + press(null!, 'j', { ctrlKey: true }); + expect(emitted).toHaveLength(0); + }); + }); + + describe('keyboardShortcuts config', () => { + it('overrides a default binding with a new key', () => { + new Keyboard(events, { gotoNextSlide: 'n' }); + press(null!, 'n'); + expect(emitted).toContain('gotoNextSlide'); + }); + + it('original key no longer triggers action when overridden', () => { + new Keyboard(events, { gotoNextSlide: 'n' }); + press(null!, 'j'); + expect(emitted).not.toContain('gotoNextSlide'); + }); + + it('accepts an array of keys for one action', () => { + new Keyboard(events, { gotoNextSlide: ['n', 'ArrowRight'] }); + press(null!, 'n'); + press(null!, 'ArrowRight'); + expect(emitted.filter(e => e === 'gotoNextSlide')).toHaveLength(2); + }); + + it('disables a default binding when set to null', () => { + new Keyboard(events, { gotoNextSlide: null }); + press(null!, 'ArrowRight'); + press(null!, 'j'); + expect(emitted).not.toContain('gotoNextSlide'); + }); + + it('keeps unmentioned defaults intact', () => { + new Keyboard(events, { gotoNextSlide: 'n' }); + press(null!, 'ArrowLeft'); + expect(emitted).toContain('gotoPreviousSlide'); + }); + + it('allows binding a completely new action', () => { + events.on('customAction', () => emitted.push('customAction')); + new Keyboard(events, { customAction: 'x' }); + press(null!, 'x'); + expect(emitted).toContain('customAction'); + }); + }); + + describe('deactivate / activate', () => { + it('deactivate stops responding to keys', () => { + const kb = new Keyboard(events); + kb.deactivate(); + press(null!, 'ArrowRight'); + expect(emitted).toHaveLength(0); + }); + + it('activate restores key handling after deactivate', () => { + const kb = new Keyboard(events); + kb.deactivate(); + kb.activate(); + press(null!, 'ArrowRight'); + expect(emitted).toContain('gotoNextSlide'); + }); + }); +}); diff --git a/src/mdeck/controllers/defaultController.ts b/src/mdeck/controllers/defaultController.ts index f264bcf..8f6568c 100644 --- a/src/mdeck/controllers/defaultController.ts +++ b/src/mdeck/controllers/defaultController.ts @@ -1,6 +1,7 @@ import type EventEmitter from 'eventemitter3'; import type { Dom } from '../dom.js'; import type { SlideshowView } from '../views/slideshowView.js'; +import type { KeyboardShortcutsConfig } from '../models/slideshow.js'; import { Keyboard } from './inputs/keyboard.js'; import * as mouse from './inputs/mouse.js'; import * as touch from './inputs/touch.js'; @@ -9,7 +10,9 @@ import * as location from './inputs/location.js'; export class DefaultController { constructor(events: EventEmitter, dom: Dom, slideshowView: SlideshowView, options: Record = {}) { - const keyboard = options.keyboard !== false ? new Keyboard(events) : null; + const keyboard = options.keyboard !== false + ? new Keyboard(events, options.keyboardShortcuts as KeyboardShortcutsConfig | undefined) + : null; message.register(events); location.register(events, dom, slideshowView); mouse.register(events, options); diff --git a/src/mdeck/controllers/inputs/keyboard.ts b/src/mdeck/controllers/inputs/keyboard.ts index 4fffd25..b7b84b4 100644 --- a/src/mdeck/controllers/inputs/keyboard.ts +++ b/src/mdeck/controllers/inputs/keyboard.ts @@ -1,64 +1,124 @@ import type EventEmitter from 'eventemitter3'; +import type { KeyboardShortcutsConfig } from '../../models/slideshow.js'; + +/** + * Default keyboard shortcuts. Keys use KeyboardEvent.key values. + * Multiple keys can be bound to one action. + */ +export const DEFAULT_SHORTCUTS: Record = { + gotoNextSlide: ['ArrowRight', 'ArrowDown', 'PageDown', 'j', ' '], + gotoPreviousSlide: ['ArrowLeft', 'ArrowUp', 'PageUp', 'k'], + gotoFirstSlide: ['Home'], + gotoLastSlide: ['End'], + toggleBlackout: ['b'], + toggleMirrored: ['m'], + createClone: ['c'], + togglePresenterMode: ['p'], + toggleFullscreen: ['f'], + toggleTimer: ['s'], + resetTimer: ['t'], + toggleHelp: ['h', '?'], + hideOverlay: ['Escape'], +}; + +/** Resolve the effective shortcut map by merging defaults with user overrides. */ +function resolveShortcuts(userConfig?: KeyboardShortcutsConfig): Record { + const result: Record = {}; + + for (const [action, keys] of Object.entries(DEFAULT_SHORTCUTS)) { + const override = userConfig?.[action]; + if (override === null) continue; // disabled + if (override === undefined) { + result[action] = keys; // keep default + } else { + result[action] = Array.isArray(override) ? override : [override]; // user override + } + } + + // Allow user to define entirely new action → key bindings + if (userConfig) { + for (const [action, keys] of Object.entries(userConfig)) { + if (action in DEFAULT_SHORTCUTS) continue; // already handled above + if (keys === null || keys === undefined) continue; + result[action] = Array.isArray(keys) ? keys : [keys]; + } + } + + return result; +} + +/** Build an inverted map: normalised key string → action name. */ +function buildKeyMap(shortcuts: Record): Map { + const map = new Map(); + for (const [action, keys] of Object.entries(shortcuts)) { + for (const k of keys) { + map.set(normaliseKey(k), action); + } + } + return map; +} + +/** Normalise a key string to lowercase for case-insensitive single-char keys. */ +function normaliseKey(key: string): string { + return key.length === 1 ? key.toLowerCase() : key; +} export class Keyboard { private _events: EventEmitter; + private _keyMap: Map; private _gotoSlideNumber = ''; + private _handler: ((event: KeyboardEvent) => void) | null = null; - constructor(events: EventEmitter) { + constructor(events: EventEmitter, shortcuts?: KeyboardShortcutsConfig) { this._events = events; + this._keyMap = buildKeyMap(resolveShortcuts(shortcuts)); this.activate(); } activate() { this._gotoSlideNumber = ''; - this.addKeyboardEventListeners(); + this._handler = (event: KeyboardEvent) => this._onKeydown(event); + this._events.on('keydown', this._handler); } deactivate() { - this.removeKeyboardEventListeners(); + if (this._handler) { + this._events.removeListener('keydown', this._handler); + this._handler = null; + } } - addKeyboardEventListeners() { - const events = this._events; - events.on('keydown', (event: KeyboardEvent) => { - if (event.metaKey || event.ctrlKey || event.altKey) return; - switch (event.keyCode) { - case 33: case 37: case 38: events.emit('gotoPreviousSlide'); break; - case 32: event.shiftKey ? events.emit('gotoPreviousSlide') : events.emit('gotoNextSlide'); break; - case 34: case 39: case 40: events.emit('gotoNextSlide'); break; - case 36: events.emit('gotoFirstSlide'); break; - case 35: events.emit('gotoLastSlide'); break; - case 27: events.emit('hideOverlay'); break; - case 13: - if (this._gotoSlideNumber) { events.emit('gotoSlideNumber', this._gotoSlideNumber); this._gotoSlideNumber = ''; } - break; - } - }); - events.on('keypress', (event: KeyboardEvent) => { - if (event.metaKey || event.ctrlKey) return; - const key = String.fromCharCode(event.which).toLowerCase(); - let prevent = true; - switch (key) { - case 'j': events.emit('gotoNextSlide'); break; - case 'k': events.emit('gotoPreviousSlide'); break; - case 'b': events.emit('toggleBlackout'); break; - case 'm': events.emit('toggleMirrored'); break; - case 'c': events.emit('createClone'); break; - case 'p': events.emit('togglePresenterMode'); break; - case 'f': events.emit('toggleFullscreen'); break; - case 's': events.emit('toggleTimer'); break; - case 't': events.emit('resetTimer'); break; - case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': case '0': - this._gotoSlideNumber += key; break; - case 'h': case '?': events.emit('toggleHelp'); break; - default: prevent = false; + private _onKeydown(event: KeyboardEvent) { + if (event.metaKey || event.ctrlKey || event.altKey) return; + + const key = event.key; + + // Digit accumulator: 0-9 builds a slide number, Enter commits it. + if (/^[0-9]$/.test(key)) { + this._gotoSlideNumber += key; + event.preventDefault?.(); + return; + } + if (key === 'Enter') { + if (this._gotoSlideNumber) { + this._events.emit('gotoSlideNumber', this._gotoSlideNumber); + this._gotoSlideNumber = ''; } - if (prevent) event.preventDefault?.(); - }); - } + return; + } + + // Shift+Space → previous slide (special case, not in keymap). + if (key === ' ' && event.shiftKey) { + this._events.emit('gotoPreviousSlide'); + event.preventDefault?.(); + return; + } - removeKeyboardEventListeners() { - this._events.removeAllListeners('keydown'); - this._events.removeAllListeners('keypress'); + const normKey = normaliseKey(key); + const action = this._keyMap.get(normKey); + if (action) { + this._events.emit(action); + event.preventDefault?.(); + } } } diff --git a/src/mdeck/models/slideshow.ts b/src/mdeck/models/slideshow.ts index 2d66be4..bcac195 100644 --- a/src/mdeck/models/slideshow.ts +++ b/src/mdeck/models/slideshow.ts @@ -29,8 +29,22 @@ export interface SlideshowOptions { includePresenterNotes?: boolean; inheritPresenterNotes?: boolean; timer?: Record; + keyboardShortcuts?: KeyboardShortcutsConfig; } +/** + * Maps action names to one or more key strings (using KeyboardEvent.key values), + * or null to disable the default binding for that action. + * + * Example: + * keyboardShortcuts: { + * gotoNextSlide: ['ArrowRight', 'ArrowDown', 'j', ' '], + * gotoPreviousSlide: ['ArrowLeft', 'ArrowUp', 'k'], + * toggleFullscreen: null, // disable + * } + */ +export type KeyboardShortcutsConfig = Partial>; + export type SlideOptions = Pick; export class Slideshow {