Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src/__tests__/keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<KeyboardEvent> = {}) {
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');
});
});
});
5 changes: 4 additions & 1 deletion src/mdeck/controllers/defaultController.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,7 +10,9 @@ import * as location from './inputs/location.js';

export class DefaultController {
constructor(events: EventEmitter, dom: Dom, slideshowView: SlideshowView, options: Record<string, unknown> = {}) {
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);
Expand Down
146 changes: 103 additions & 43 deletions src/mdeck/controllers/inputs/keyboard.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
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<string, string[]> {
const result: Record<string, string[]> = {};

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<string, string[]>): Map<string, string> {
const map = new Map<string, string>();
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<string, string>;
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?.();
}
}
}
14 changes: 14 additions & 0 deletions src/mdeck/models/slideshow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,22 @@ export interface SlideshowOptions {
includePresenterNotes?: boolean;
inheritPresenterNotes?: boolean;
timer?: Record<string, unknown>;
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<Record<string, string | string[] | null>>;

export type SlideOptions = Pick<SlideshowOptions, 'countIncrementalSlides' | 'excludedClasses' | 'disableIncrementalSlides' | 'includePresenterNotes' | 'inheritPresenterNotes'>;

export class Slideshow {
Expand Down