Something went wrong. Try again.
archived: handles keyboard shortcuts sensibly
Something went wrong. Try again.
13 kB · 450 lines
TypeScript
at trunk
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451const PLATFORM = typeof navigator == 'object' ? navigator.platform : '';
/** whether the current environment is an Apple device. */export const IS_APPLE_DEVICE: boolean = /iP[aho]|Mac/.test(PLATFORM);
/** whether the current environment is a Windows device. */export const IS_WINDOWS_DEVICE: boolean = PLATFORM == 'Win32';
/** modifier key name corresponding to '$mod' on the current platform. */export const MOD: string = IS_APPLE_DEVICE ? 'Meta' : 'Control';
/** modifier key names that act as aliases for AltGraph on the current platform. */export const ALT_GRAPH_ALIASES: string[] = IS_WINDOWS_DEVICE ? ['Alt', 'Control'] : IS_APPLE_DEVICE ? ['Alt'] : [];
/** known modifier key names. */export const MODIFIERS: string[] = ['Alt', 'Control', 'Meta', 'Shift'];
/** callback listener function for a matched keybind event. */export type KeybindListener = (ev: KeyboardEvent) => void;
/** map of keybind strings to their listener callbacks. */export type KeybindMapping = Record<string, KeybindListener>;
/** parsed single key press containing modifier names, target key, and inhibition flags. */export type Keybind = [mods: string[], key: string, flags: number];
/** sequence of parsed key presses. */export type KeybindChord = Keybind[];
/** a chord and its associated value. */export type KeybindEntry<T> = [chord: KeybindChord, value: T];
/** result of processing a keyboard event. */export type KeybindMatch<T> = // no matching chord | readonly [type: 'none'] // a chord is pending, including after ignored repeat or modifier events | readonly [type: 'pending'] // completes a chord | readonly [type: 'match', value: T];
/** tracks progress through chords across successive keyboard events. */export interface KeybindTracker<T> { /** * updates chord progress without preventing the event's default action. * @param ev keyboard event to process * @returns no match, a pending chord, or a completed chord's value */ match(ev: KeyboardEvent): KeybindMatch<T>; /** cancels any pending chord. */ reset(): void;}
/** options for `createKeybindTracker`. */export interface KeybindTrackerOptions { /** timeout between chord presses in milliseconds. defaults to `1000`. */ timeout?: number;}
/** options for `createKeybindHandler`. */export interface KeybindHandlerOptions extends KeybindTrackerOptions { /** prevent default actions for completed matches. pending events are always prevented. defaults to `true`. */ preventDefault?: boolean;}
/** @internal */export const enum InhibitFlags { // disabled on <input type=text> TEXT = 1 << 0, // disabled on <select> SELECT = 1 << 1,
// specific keys in particular ARROW = 1 << 2, ENTER = 1 << 3, ENTER_EXTRA = 1 << 4, SPACE = 1 << 5,}
const types: Record<string, number | undefined> = { email: InhibitFlags.TEXT, number: InhibitFlags.TEXT, password: InhibitFlags.TEXT, search: InhibitFlags.TEXT, tel: InhibitFlags.TEXT, text: InhibitFlags.TEXT, url: InhibitFlags.TEXT,
date: InhibitFlags.TEXT | InhibitFlags.ENTER, datetime: InhibitFlags.TEXT | InhibitFlags.ENTER, month: InhibitFlags.TEXT | InhibitFlags.ENTER, time: InhibitFlags.TEXT | InhibitFlags.ENTER, week: InhibitFlags.TEXT | InhibitFlags.ENTER,
button: InhibitFlags.ENTER | InhibitFlags.SPACE, color: InhibitFlags.ENTER | InhibitFlags.SPACE, file: InhibitFlags.ENTER | InhibitFlags.SPACE, image: InhibitFlags.ENTER | InhibitFlags.SPACE, reset: InhibitFlags.ENTER | InhibitFlags.SPACE, submit: InhibitFlags.ENTER | InhibitFlags.SPACE,
checkbox: InhibitFlags.SPACE,
radio: InhibitFlags.ARROW | InhibitFlags.SPACE, range: InhibitFlags.ARROW,};
// keys that neither insert nor edit textconst NON_TEXT = /Alt|Control|Escape|F\d|Lock|Meta|Tab|Enter/;// ...plus deletion, which Shift alone doesn't turn into text insertionconst NON_TEXT_SHIFT = /Alt|Control|Escape|F\d|Lock|Meta|Tab|Enter|Backspace|Delete/;// native text editing shortcuts, matching both `event.key` and `event.code` spellingsconst MOD_EDIT = /^(?:Key)?[acvxyz]$|Arrow|Backspace|Delete|Home|End/i;const MOD_SHIFT_EDIT = /^(?:Key)?[vz]$|Arrow|Home|End/i;
/** * parses a single key press into a `Keybind` tuple. whitespace is not allowed; use `parseKeybindChord` * to parse a chord of several presses. * @param keybind key press string to parse * @returns parsed keybind tuple * @throws {TypeError} if the string contains whitespace, names no key, or specifies an unknown modifier */export const parseKeybind = (keybind: string): Keybind => { // prefixing with `!` opts out of every inhibition below const escaped = keybind[0] == '!';
if (/\s/.test(keybind)) { throw new TypeError(`keybind press cannot contain whitespace: ${keybind}`); }
const mods = (escaped ? keybind.slice(1) : keybind) .split(/\b\+/) .map((mod) => (mod == '$mod' ? MOD : mod));
const key = mods.pop()!;
if (key == '') { throw new TypeError(`keybind press names no key: ${keybind}`); }
// typo'd modifiers would otherwise never match, silently const unknown = mods.find((mod) => !MODIFIERS.includes(mod)); if (unknown !== undefined) { throw new TypeError(`unknown modifier: ${unknown}`); }
if (escaped) { return [mods, key, 0]; }
const len = mods.length; const standalone = len == 0; const shiftModifierOnly = len == 1 && mods[0] == 'Shift'; const modModifierOnly = len == 1 && mods[0] == MOD; const modShiftModifierOnly = len == 2 && mods.includes(MOD) && mods.includes('Shift');
let flags = 0;
if ( (standalone && !NON_TEXT.test(key)) || (shiftModifierOnly && !NON_TEXT_SHIFT.test(key)) || (modModifierOnly && MOD_EDIT.test(key)) || (modShiftModifierOnly && MOD_SHIFT_EDIT.test(key)) ) { flags |= InhibitFlags.TEXT; }
if (standalone && !NON_TEXT_SHIFT.test(key)) { flags |= InhibitFlags.SELECT; }
if (standalone && /Arrow/.test(key)) { flags |= InhibitFlags.ARROW; }
if (standalone && /Space/.test(key)) { flags |= InhibitFlags.SPACE; }
if (standalone && /Enter/.test(key)) { flags |= InhibitFlags.ENTER; }
if ((shiftModifierOnly || modModifierOnly || modShiftModifierOnly) && /Enter/.test(key)) { flags |= InhibitFlags.ENTER_EXTRA; }
return [mods, key, flags];};
/** * parses whitespace-separated key presses, e.g. `'$mod+k s'`. the tracker checks inhibition only * on the first press; prefix it with `!` to bypass inhibition for the chord. * @param chord chord string to parse * @returns parsed presses, in the order they must be triggered * @throws {TypeError} if a press names no key or specifies an unknown modifier */export const parseKeybindChord = (chord: string): KeybindChord => { return chord .trim() .split(/\s+/) .map((press) => parseKeybind(press));};
/** * checks if a keybind is allowed to match a keyboard event. * @param ev keyboard event to check * @param flags inhibition flags to evaluate * @returns true if the keybind is allowed to match, false otherwise */export const isKeybindAllowed = (ev: KeyboardEvent, flags: number): boolean => { if (flags == 0) { return true; }
// `event.target` is retargeted to the host for events inside a shadow root const target = ev.composedPath()[0];
if (target instanceof HTMLInputElement) { const type = target.type; return !((types[type] || 0) & flags); }
if (target instanceof HTMLTextAreaElement) { const f = InhibitFlags.ENTER | InhibitFlags.TEXT; return !(flags & f); }
if (target instanceof HTMLSelectElement) { return !(flags & InhibitFlags.SELECT); }
if (target instanceof HTMLButtonElement) { const f = InhibitFlags.ENTER | InhibitFlags.SPACE; return !(flags & f); }
if (target instanceof HTMLAnchorElement) { const f = InhibitFlags.ENTER | InhibitFlags.ENTER_EXTRA; // anchors without a destination don't reserve Enter return target.href == '' || !(flags & f); }
if (target instanceof HTMLElement && target.isContentEditable) { const f = InhibitFlags.ENTER | InhibitFlags.TEXT; return !(flags & f); }
return true;};
/** * checks if a modifier key is pressed. * @param ev keyboard event to check * @param mod modifier key name to check * @returns true if the modifier is pressed, false otherwise */export const matchModifier = (ev: KeyboardEvent, mod: string): boolean => { return ev.getModifierState(mod) || (ev.getModifierState('AltGraph') && ALT_GRAPH_ALIASES.includes(mod));};
/** * checks if a key press matches a keyboard event, ignoring inhibition. * @param ev keyboard event to test * @param keybind keybind to test * @returns true if the key and modifiers match the event, false otherwise */export const matchKeybindPress = (ev: KeyboardEvent, [mods, key]: Keybind): boolean => { return ( // check if key matches either `event.key` or `event.code` (ev.key == key || ev.code == key) && // check if all modifiers pass mods.every((mod) => matchModifier(ev, mod)) && // don't match on extraneous modifiers MODIFIERS.every((mod) => mods.includes(mod) || !matchModifier(ev, mod)) );};
/** * checks if a keybind matches a keyboard event, taking inhibition into account. * @param ev keyboard event to test * @param keybind keybind to test * @returns true if the keybind matches the event, false otherwise */export const matchKeybind = (ev: KeyboardEvent, keybind: Keybind): boolean => { // avoid inspecting the event target for nonmatching keys return matchKeybindPress(ev, keybind) && isKeybindAllowed(ev, keybind[2]);};
// reuse results that carry no value to avoid per-event allocationconst NONE: readonly [type: 'none'] = ['none'];const PENDING: readonly [type: 'pending'] = ['pending'];
// normalize modifier order for prefix comparisonsconst serializePress = ([mods, key]: Keybind): string => { return mods.length != 0 ? mods.toSorted().join('+') + '+' + key : key;};
// immediate dispatch can't distinguish a complete chord from a longer chord's prefixconst assertNoPrefixes = (entries: KeybindEntry<unknown>[]): void => { // map each prefix to a full chord for the error message const prefixes = new Map<string, string>(); const paths: string[] = [];
for (const [chord] of entries) { const presses = chord.map((press) => serializePress(press)); const path = presses.join(' ');
for (let at = 1, len = presses.length; at < len; at++) { prefixes.set(presses.slice(0, at).join(' '), path); }
paths.push(path); }
for (const path of paths) { const longer = prefixes.get(path);
if (longer !== undefined) { throw new TypeError(`unreachable keybind: \`${path}\` is a prefix of \`${longer}\``); } }};
/** * creates a stateful chord tracker. use one instance per independent event stream. * @param entries chords and their associated values * @param options tracker options * @returns keybind tracker * @throws {TypeError} if a chord is a prefix of another */export const createKeybindTracker = <T>( entries: KeybindEntry<T>[], { timeout = 1000 }: KeybindTrackerOptions = {},): KeybindTracker<T> => { assertNoPrefixes(entries);
// candidate chords and matched press count; all entries are candidates when idle let live = entries; let depth = 0; let deadline = 0;
const reset = () => { live = entries; depth = 0; };
const match = (ev: KeyboardEvent): KeybindMatch<T> => { if (depth != 0 && ev.timeStamp > deadline) { reset(); }
// don't let key repeat complete `g g` or modifier presses cancel a chord. // return pending so the handler still prevents their default actions. if (depth != 0 && (ev.repeat || ev.getModifierState(ev.key))) { return PENDING; }
// retry a nonmatching continuation as a new opening press for (;;) { let pending: KeybindEntry<T>[] | null = null;
for (let idx = 0, len = live.length; idx < len; idx++) { const entry = live[idx]; const chord = entry[0]; const press = chord[depth];
if (!matchKeybindPress(ev, press)) { continue; }
if (depth == 0 && !isKeybindAllowed(ev, press[2])) { continue; }
if (depth == chord.length - 1) { reset(); return ['match', entry[1]]; }
(pending ??= []).push(entry); }
if (pending !== null) { live = pending; depth++; deadline = ev.timeStamp + timeout;
return PENDING; }
if (depth == 0) { return NONE; }
reset(); } };
return { match, reset };};
/** * creates a keybind event handler. * @param mapping map of keybind strings to listener callbacks * @param options handler options * @returns keyboard event handler function * @throws {TypeError} if a keybind fails to parse, or if one is a prefix of another */export const createKeybindHandler = ( mapping: KeybindMapping, { preventDefault = true, ...options }: KeybindHandlerOptions = {},): (ev: KeyboardEvent) => void => { const entries = Object.entries(mapping).map(([raw, listener]): KeybindEntry<KeybindListener> => { return [parseKeybindChord(raw), listener]; });
const tracker = createKeybindTracker(entries, options);
const listener = (ev: KeyboardEvent) => { if (ev.defaultPrevented) { return; }
const [type, callback] = tracker.match(ev);
switch (type) { case 'match': { if (preventDefault) { ev.preventDefault(); }
callback(ev); break; }
case 'pending': { // prevent browser shortcuts or text input while a chord is pending ev.preventDefault(); break; } } };
return listener;};