@waradu/keyboard
Version:
A keyboard manager compatible with JavaScript, TypeScript and Nuxt
411 lines (408 loc) • 12.3 kB
JavaScript
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 };