UNPKG

@waradu/keyboard

Version:

A keyboard manager compatible with JavaScript, TypeScript and Nuxt

411 lines (408 loc) 12.3 kB
import { Keybind } from './keybind.mjs'; import { detectOsInBrowser, isEditableElement, merge } from './helper.mjs'; import { keys, ANYKEY } from './keys.mjs'; class Keyboard { constructor(config = {}) { this.config = config; if (!config.noInit) this.init(); } handlers = []; allLayers = /* @__PURE__ */ new Set(); disabledLayers = /* @__PURE__ */ new Set(); subscribers = []; abortSignalListeners; ready = false; paused = false; log(...data) { if (this.config.debug) console.log("<KEYBOARD>", ...data); } onKeydown = (event) => { if (this.paused) return; if (event.isComposing) return; const eventKey = event.key.toLowerCase(); const currentKey = keys[eventKey]; if (currentKey) this.log(`pressed '${currentKey}'`); const browserPlatform = this.config.platform ?? detectOsInBrowser(); const candidates = this.handlers.filter((handler) => { for (const key of handler.keys) { if (key.platform) { if (key.platform === "linux" && browserPlatform !== "linux") continue; if (key.platform === "win" && browserPlatform !== "windows") continue; if (key.platform === "macos" && browserPlatform !== "macos") continue; if (key.platform === "no-linux" && browserPlatform === "linux") continue; if (key.platform === "no-win" && browserPlatform === "windows") continue; if (key.platform === "no-macos" && browserPlatform === "macos") continue; } if (key.key !== ANYKEY) { if (!currentKey) continue; if (key.key === "$num" && Number.isNaN(parseInt(currentKey))) { continue; } else if (key.key !== "$num" && currentKey !== key.key) { continue; } if (key.modifiers.shift !== event.shiftKey) continue; if (key.modifiers.alt !== event.altKey) continue; const ctrlCmd = key.modifiers.ctrlCmd; const expectsCtrl = key.modifiers.ctrl || ctrlCmd && browserPlatform !== "macos"; const expectsMeta = key.modifiers.meta || ctrlCmd && browserPlatform === "macos"; if (expectsCtrl !== event.ctrlKey) continue; if (expectsMeta !== event.metaKey) continue; } if (!handler.config.layers || handler.config.layers.length === 0) return true; return handler.config.layers.some((layer) => !this.disabledLayers.has(layer)); } return false; }); if (candidates.length === 0) return; candidates.forEach((handler) => { const activeElement = document.activeElement; if (handler.config?.ignoreIfEditable && activeElement && activeElement instanceof Element && isEditableElement(activeElement)) return; if (handler.config?.runIfFocused) { const run = handler.config?.runIfFocused; if (Array.isArray(run)) { if (!run.some((element) => { return element && document.activeElement && element === document.activeElement; })) return; } } if (!handler.config.when) { return; } else if (typeof handler.config.when === "function") { try { const when = handler.config.when(); if (!when) return; } catch (e) { console.error(e); return; } } if (handler.config?.prevent) event.preventDefault(); if (handler.config?.stop === true) event.stopPropagation(); if (handler.config?.stop === "immediate") event.stopImmediatePropagation(); if (handler.config?.stop === "both") { event.stopPropagation(); event.stopImmediatePropagation(); } const pressedNumber = currentKey ? parseInt(currentKey) : void 0; try { handler.handler({ template: Number.isNaN(pressedNumber) ? void 0 : pressedNumber, handler, event }); } catch (e) { this.log(e); } this.log(`handled '${handler.id}'`); if (handler.config?.once) this.unbind(handler.id); }); }; /** * Clear all listeners. */ clear() { while (this.handlers.length > 0) { const handler = this.handlers[0]; if (!handler) break; handler.off(); } this.handlers.length = 0; this.allLayers.clear(); this.disabledLayers.clear(); this.log(`cleared`); this.notify(); } /** * Removes all event handlers. * To re-enable listening after calling this, call `init()` again. */ stop() { this.abortSignalListeners?.abort(); this.abortSignalListeners = void 0; if (typeof window !== "undefined") { window.removeEventListener("keydown", this.onKeydown); this.ready = false; this.log("stopped"); } } /** * Removes all event listeners and registered handlers. * Use this if you are not planning to re-enable listening with `init()` after. */ destroy() { this.stop(); this.clear(); } /** * Temporarily pause listener */ pause() { this.paused = true; } /** * Resume listener */ resume() { this.paused = false; } /** * Toggle paused state */ toggle() { this.paused = !this.paused; } /** * Initialize the keyboard. Call this when `window` is available (it will fail silently). * You can define listeners before initializing. */ init(opts) { this.stop(); if (typeof window !== "undefined") { window.addEventListener("keydown", this.onKeydown); this.ready = true; const configSignal = this.config.signal; const initSignal = opts?.signal; if (configSignal?.aborted || initSignal?.aborted) { this.destroy(); return; } if (configSignal || initSignal) { this.abortSignalListeners = new AbortController(); if (configSignal) { configSignal.addEventListener("abort", () => this.destroy(), { once: true, signal: this.abortSignalListeners.signal }); } if (initSignal && initSignal !== configSignal) { initSignal.addEventListener("abort", () => this.destroy(), { once: true, signal: this.abortSignalListeners.signal }); } } this.log("initialized"); } else { this.log("ERROR: window was not found to initialize"); } } bind(options, config = {}) { if (!Array.isArray(options)) { options = [options]; } let layers = [...config.layers ?? []]; for (const option of options) { layers = [...layers, ...option.config?.layers ?? []]; } layers.forEach((layer) => this.allLayers.add(layer)); const results = options.map((option) => { const local = merge(option.config, config, { prevent: false, stop: false, ignoreIfEditable: false, once: false, when: true }); local.layers = [.../* @__PURE__ */ new Set([...local.layers ?? [], ...config.layers ?? []])]; if (local.signal?.aborted) return; const keyArray = Array.isArray(option.keys) ? option.keys : [option.keys]; let keys2 = keyArray.map((key) => Keybind.from(key)).filter((k) => !!k); if (!keys2.length) return; const id = Math.random().toString(36).slice(2, 7); const off = () => { const index = this.handlers.findIndex((handler2) => handler2.id === id); if (index === -1) return; this.handlers.splice(index, 1); local.signal?.removeEventListener("abort", off); this.log(`removed: '${id}'`); this.notify(); }; if (local?.signal) local.signal.addEventListener("abort", off, { once: true }); const handler = { id, off, keys: keys2, handler: option.run, config: local }; this.handlers.push(handler); this.log(`added '${keys2.map((key) => key.toReadable().join(", "))}' with id: '${id}'`); return { id, off }; }); this.notify(); return () => { results.forEach((result) => result?.off()); }; } unbind(id) { this.handlers.find((handler) => handler.id === id)?.off(); } notify() { this.subscribers.forEach((cb) => cb([...this.handlers])); } /** * Subscribe to changes of all registered keyboard listeners. * * The callback is called: * - once immediately with the current listeners * - on every add/remove/clear of listeners * * @param callback Receives the current list of listeners on each change. * @returns Function to unsubscribe from further updates. */ subscribe(cb) { this.subscribers.push(cb); cb([...this.handlers]); return () => { this.subscribers = this.subscribers.filter((fn) => fn !== cb); }; } /** * Check if Key String listener already exists. */ exists(sequence) { const shape = Keybind.from(sequence); if (!shape) return false; return this.handlers.some((handler) => { for (const key of handler.keys) { if (key.equals(shape)) return true; } return false; }); } /** * Record pressed keys and emit them as a Keybind. * * @param callback Receives each recorded keybind. * @returns Function to stop recording. */ record(cb) { const handler = (event) => { if (event.isComposing) return; const eventKey = event.key.toLowerCase(); const key = keys[eventKey]; if (!key) return; cb( Keybind.fromShape({ key, modifiers: { shift: event.shiftKey, alt: event.altKey, ctrl: event.ctrlKey, ctrlCmd: false, meta: event.metaKey } }) ); }; if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); } this.log("ERROR: window was not found for recording"); return () => { }; } layers = { /** * Create new keybind layer. */ create: (layer, disabled) => { const offs = []; this.allLayers.add(layer); if (disabled) this.disabledLayers.add(layer); return { /** * Disable this layer */ disable: () => { this.disabledLayers.add(layer); }, /** * Enable this layer */ enable: () => { this.disabledLayers.delete(layer); }, /** * Enable this layer */ toggle: () => { if (this.disabledLayers.has(layer)) { this.disabledLayers.delete(layer); } else { this.disabledLayers.add(layer); } }, bind: ((...args) => { const config = { ...args[1] }; if (!config.layers) config.layers = []; config.layers.push(layer); const binding = this.bind(args[0], config); offs.push(binding); return binding; }), /** * Disables all listeners in this layer. */ off: () => { for (const off of offs) { off(); } } }; }, /** * Enable specific layers. */ enable: (layers) => { if (!Array.isArray(layers)) { layers = [layers]; } for (const layer of layers) { this.disabledLayers.delete(layer); } }, /** * Disable specific layers. */ disable: (layers) => { if (!Array.isArray(layers)) { layers = [layers]; } for (const layer of layers) { this.disabledLayers.add(layer); } }, /** * Set active layers. */ set: (layers) => { if (!Array.isArray(layers)) { layers = [layers]; } this.disabledLayers.clear(); for (const layer of this.allLayers) { if (layers.includes(layer)) continue; this.disabledLayers.add(layer); } }, /** * Enable all layers. */ all: () => { this.disabledLayers.clear(); }, /** * Disable all layers. */ none: () => { for (const layer of this.allLayers) { this.disabledLayers.add(layer); } } }; } export { Keyboard };