Something went wrong. Try again.
archived: handles keyboard shortcuts sensibly
Something went wrong. Try again.
12 kB · 428 lines
TypeScript
at trunk
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429import { assertEquals, assertThrows } from '@std/assert';
import { createKeybindHandler, createKeybindTracker, InhibitFlags, type KeybindTracker, matchKeybindPress, MOD, MODIFIERS, parseKeybind, parseKeybindChord,} from './mod.ts';
// `const enum` members are only reachable through property accessconst ARROW = InhibitFlags.ARROW;const ENTER = InhibitFlags.ENTER;const ENTER_EXTRA = InhibitFlags.ENTER_EXTRA;const SELECT = InhibitFlags.SELECT;const SPACE = InhibitFlags.SPACE;const TEXT = InhibitFlags.TEXT;
const INHIBITED: [keybind: string, flags: number][] = [ // text insertion ['a', SELECT | TEXT], ['z', SELECT | TEXT], ['Space', SPACE | SELECT | TEXT], // uppercase text insertion ['Shift+X', TEXT], ['Shift+Y', TEXT], // text deletion ['Backspace', TEXT], ['$mod+Backspace', TEXT], // per-char cursor positioning ['ArrowLeft', ARROW | SELECT | TEXT], ['ArrowRight', ARROW | SELECT | TEXT], ['ArrowUp', ARROW | SELECT | TEXT], ['ArrowDown', ARROW | SELECT | TEXT], // per-char cursor selection ['Shift+ArrowLeft', TEXT], ['Shift+ArrowRight', TEXT], ['Shift+ArrowUp', TEXT], ['Shift+ArrowDown', TEXT], // per-word cursor positioning ['$mod+ArrowLeft', TEXT], ['$mod+ArrowRight', TEXT], ['$mod+ArrowUp', TEXT], ['$mod+ArrowDown', TEXT], // per-word cursor selection ['$mod+Shift+ArrowLeft', TEXT], ['$mod+Shift+ArrowRight', TEXT], ['$mod+Shift+ArrowUp', TEXT], ['$mod+Shift+ArrowDown', TEXT], // common text input shortcuts ['$mod+a', TEXT], ['$mod+c', TEXT], ['$mod+v', TEXT], ['$mod+x', TEXT], ['$mod+y', TEXT], ['$mod+z', TEXT], // enter action ['Enter', ENTER], ['NumpadEnter', ENTER], ['$mod+Enter', ENTER_EXTRA], ['$mod+Shift+Enter', ENTER_EXTRA], // keys whose names merely start like a modifier ['Comma', SELECT | TEXT], ['End', SELECT | TEXT], ['Home', SELECT | TEXT], ['ContextMenu', SELECT | TEXT], // per-word deletion and per-document positioning ['$mod+Delete', TEXT], ['$mod+Home', TEXT], ['$mod+End', TEXT], ['$mod+Shift+Home', TEXT], ['$mod+Shift+End', TEXT], // redo, and paste without formatting ['$mod+Shift+z', TEXT], ['$mod+Shift+v', TEXT], // `event.code` spellings have to inhibit the same as `event.key` ones ['KeyA', SELECT | TEXT], ['$mod+KeyA', TEXT], ['$mod+KeyC', TEXT], ['$mod+KeyV', TEXT], ['$mod+Shift+KeyZ', TEXT], // keys that don't produce text ['Escape', 0], ['Tab', 0], ['F5', 0], ['CapsLock', 0], ['Shift+Tab', 0], // bindings that shouldn't be inhibited at all ['$mod+k', 0], ['$mod+KeyK', 0], ['$mod+Shift+k', 0],];
const ESCAPED: [keybind: string, flags: number][] = [ ['!s', 0], ['!ArrowDown', 0], ['!Enter', 0], ['!$mod+a', 0],];
// #region test doubles
class FakeAnchor { constructor(readonly href: string) {}}
class FakeInput { constructor(readonly type: string) {}}
// provide DOM constructors for `isKeybindAllowed`'s instanceof checks in DenoObject.assign(globalThis, { HTMLAnchorElement: FakeAnchor, HTMLButtonElement: class {}, HTMLElement: class {}, HTMLInputElement: FakeInput, HTMLSelectElement: class {}, HTMLTextAreaElement: class {},});
interface FakeEvent { key: string; code: string; repeat: boolean; timeStamp: number; defaultPrevented: boolean; preventDefault(): void; getModifierState(mod: string): boolean; composedPath(): EventTarget[];}
interface EventOptions { defaultPrevented?: boolean; repeat?: boolean; target?: object; timeStamp?: number;}
// the double implements only the KeyboardEvent members these tests needconst asEvent = (ev: FakeEvent): KeyboardEvent => ev as unknown as KeyboardEvent;
const createEvent = ( press: string, { defaultPrevented = false, repeat = false, target, timeStamp = 0 }: EventOptions = {},): FakeEvent => { const [mods, key] = parseKeybind(press); const path = target !== undefined ? [target as EventTarget] : [];
const ev: FakeEvent = { key, code: key, repeat, timeStamp, defaultPrevented, preventDefault: () => { ev.defaultPrevented = true; }, // a modifier reports itself as held while its own keydown is dispatched getModifierState: (mod) => mods.includes(mod) || (mod == key && MODIFIERS.includes(key)), composedPath: () => path, };
return ev;};
const dispatch = ( handler: (ev: KeyboardEvent) => void, press: string, options?: EventOptions,): FakeEvent => { const ev = createEvent(press, options); handler(asEvent(ev)); return ev;};
const feed = <T>(tracker: KeybindTracker<T>, press: string, options?: EventOptions) => { return tracker.match(asEvent(createEvent(press, options)));};
// #endregion
Deno.test('parseKeybind handles certain keybinds', async (t) => { for (const [keybind, expected] of INHIBITED) { await t.step(keybind, () => { const [_mods, _key, flags] = parseKeybind(keybind); assertEquals(flags, expected); }); }});
Deno.test('parseKeybind opts out of inhibition when prefixed with `!`', async (t) => { for (const [keybind, expected] of ESCAPED) { await t.step(keybind, () => { const [_mods, _key, flags] = parseKeybind(keybind); assertEquals(flags, expected); }); }});
Deno.test('parseKeybind resolves $mod', () => { const [mods] = parseKeybind('$mod+Shift+k'); assertEquals(mods, [MOD, 'Shift']);});
Deno.test('parseKeybind rejects unknown modifiers', () => { assertThrows(() => parseKeybind('Ctrl+k'), TypeError); assertThrows(() => parseKeybind('$mod+Option+k'), TypeError); parseKeybind('$mod+Shift+k');});
Deno.test('parseKeybind rejects presses that name no key', () => { assertThrows(() => parseKeybind(''), TypeError); assertThrows(() => parseKeybind('$mod+'), TypeError); assertEquals(parseKeybind('$mod++')[1], '+');});
Deno.test('parseKeybind rejects whitespace', () => { assertThrows(() => parseKeybind('$mod + k'), TypeError); assertThrows(() => parseKeybind('g g'), TypeError);});
Deno.test('parseKeybindChord splits presses on whitespace', () => { const chord = parseKeybindChord('$mod+k s'); assertEquals(chord.length, 2); assertEquals(chord[0][0], [MOD]); assertEquals(chord[0][1], 'k'); assertEquals(chord[1][0], []); assertEquals(chord[1][1], 's');});
Deno.test('parseKeybindChord applies `!` to the opening press', () => { const chord = parseKeybindChord('!s a'); assertEquals(chord[0][2], 0);});
Deno.test('matchKeybindPress ignores inhibition', () => { const keybind = parseKeybind('s'); const ev = createEvent('s'); ev.composedPath = () => { throw new Error('composedPath should not be called'); };
assertEquals(matchKeybindPress(asEvent(ev), keybind), true);});
// #region chord handling
Deno.test('createKeybindHandler fires a single press keybind', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k': () => fired++ });
const ev = dispatch(handler, '$mod+k');
assertEquals(fired, 1); assertEquals(ev.defaultPrevented, true);});
Deno.test('createKeybindHandler fires a chord only once complete', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ });
const first = dispatch(handler, '$mod+k'); assertEquals(fired, 0); assertEquals(first.defaultPrevented, true);
dispatch(handler, 's'); assertEquals(fired, 1);
// completion resets chord progress dispatch(handler, 's'); assertEquals(fired, 1);});
Deno.test('createKeybindHandler fires a chord of bare presses', () => { let fired = 0; const handler = createKeybindHandler({ 'g g': () => fired++ });
dispatch(handler, 'g'); dispatch(handler, 'g');
assertEquals(fired, 1);});
Deno.test('createKeybindHandler re-evaluates a press that abandons a chord', () => { const fired: string[] = []; const handler = createKeybindHandler({ '$mod+k s': () => fired.push('chord'), 'g': () => fired.push('g'), });
dispatch(handler, '$mod+k'); dispatch(handler, 'g');
assertEquals(fired, ['g']);});
Deno.test('createKeybindHandler keeps a chord alive across modifier presses', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ });
dispatch(handler, '$mod+k'); dispatch(handler, 'Shift'); dispatch(handler, 's');
assertEquals(fired, 1);});
Deno.test('createKeybindHandler abandons a chord after the timeout', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ }, { timeout: 1000 });
dispatch(handler, '$mod+k', { timeStamp: 0 }); const late = dispatch(handler, 's', { timeStamp: 1500 });
assertEquals(fired, 0); assertEquals(late.defaultPrevented, false);});
Deno.test('createKeybindHandler skips events that were already handled', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k': () => fired++ });
dispatch(handler, '$mod+k', { defaultPrevented: true });
assertEquals(fired, 0);});
Deno.test('createKeybindHandler ignores held keys mid-chord', () => { let fired = 0; const handler = createKeybindHandler({ 'g g': () => fired++ });
dispatch(handler, 'g');
const held = dispatch(handler, 'g', { repeat: true }); assertEquals(fired, 0); assertEquals(held.defaultPrevented, true);
dispatch(handler, 'g'); assertEquals(fired, 1);});
Deno.test('createKeybindHandler leaves matched events alone when opted out', () => { const handler = createKeybindHandler({ '$mod+k s': () => {} }, { preventDefault: false });
const first = dispatch(handler, '$mod+k'); const second = dispatch(handler, 's');
assertEquals(first.defaultPrevented, true); assertEquals(second.defaultPrevented, false);});
Deno.test('createKeybindHandler only inhibits the opening press of a chord', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ }); const target = new FakeInput('text');
// bare `s` is inhibited in a text field, but allowed as a continuation dispatch(handler, '$mod+k', { target }); dispatch(handler, 's', { target });
assertEquals(fired, 1);});
Deno.test('createKeybindHandler inhibits a chord that opens on a bare press', () => { let fired = 0; const handler = createKeybindHandler({ 'g g': () => fired++ }); const target = new FakeInput('text');
dispatch(handler, 'g', { target }); dispatch(handler, 'g', { target });
assertEquals(fired, 0);});
Deno.test('createKeybindHandler only reserves Enter on anchors that navigate', () => { let fired = 0; const handler = createKeybindHandler({ Enter: () => fired++ });
dispatch(handler, 'Enter', { target: new FakeAnchor('https://example.com') }); assertEquals(fired, 0);
dispatch(handler, 'Enter', { target: new FakeAnchor('') }); assertEquals(fired, 1);});
Deno.test('createKeybindTracker reports what each press amounted to', () => { const tracker = createKeybindTracker([[parseKeybindChord('$mod+k s'), 'save']]);
assertEquals(feed(tracker, 'Escape'), ['none']); assertEquals(feed(tracker, '$mod+k'), ['pending']); assertEquals(feed(tracker, 's'), ['match', 'save']);});
Deno.test('createKeybindTracker abandons the chord underway on reset', () => { const tracker = createKeybindTracker([[parseKeybindChord('$mod+k s'), 'save']]);
feed(tracker, '$mod+k'); tracker.reset();
assertEquals(feed(tracker, 's'), ['none']);});
Deno.test('createKeybindHandler rejects a chord that is a prefix of another', () => { assertThrows( () => createKeybindHandler({ '$mod+k': () => {}, '$mod+k s': () => {} }), TypeError, );
assertThrows( () => createKeybindHandler({ '$mod+k s': () => {}, '$mod+k': () => {} }), TypeError, );
// modifier order doesn't make two presses distinct assertThrows( () => createKeybindHandler({ 'Shift+$mod+k': () => {}, '$mod+Shift+k s': () => {} }), TypeError, );
// shared prefixes are allowed when neither chord is complete createKeybindHandler({ '$mod+k s': () => {}, '$mod+k t': () => {} });});
// #endregion