/** * A service responsible for captruing keys within LiteGraph's canvas, and outside of it, allowing * nodes and other services to confidently determine what's going on. */ class KeyEventService extends EventTarget { readonly downKeys: { [key: string]: boolean } = {}; ctrlKey = false; altKey = false; metaKey = false; shiftKey = false; private readonly isMac: boolean = !!( navigator.platform?.toLocaleUpperCase().startsWith("MAC") || (navigator as any).userAgentData?.platform?.toLocaleUpperCase().startsWith("MAC") ); constructor() { super(); this.initialize(); } initialize() { const that = this; // [🤮] Sometimes ComfyUI and/or LiteGraph stop propagation of key events which makes it hard // to determine if keys are currently pressed. To attempt to get around this, we'll hijack // LiteGraph's processKey to try to get better consistency. const processKey = LGraphCanvas.prototype.processKey; LGraphCanvas.prototype.processKey = function (e: KeyboardEvent) { if (e.type === "keydown" || e.type === "keyup") { that.handleKeyDownOrUp(e); } return processKey.apply(this, [...arguments] as any) as any; }; // Now that ComfyUI has more non-canvas UI (like the top bar), we listen on window as well, and // de-dupe when we get multiple events from both window and/or LiteGraph. window.addEventListener("keydown", (e) => { that.handleKeyDownOrUp(e); }); window.addEventListener("keyup", (e) => { that.handleKeyDownOrUp(e); }); // If we get a visibilitychange, then clear the keys since we can't listen for keys up/down when // not visible. document.addEventListener("visibilitychange", (e) => { this.clearKeydowns(); }); // If we get a blur, then also clear the keys since we can't listen for keys up/down when // blurred. This can happen w/o a visibilitychange, like a browser alert. window.addEventListener("blur", (e) => { this.clearKeydowns(); }); } /** * Adds a new queue item, unless the last is the same. */ handleKeyDownOrUp(e: KeyboardEvent) { const key = e.key.toLocaleUpperCase(); // If we're already down, or already up, then ignore and don't fire. if ((e.type === 'keydown' && this.downKeys[key] === true) || (e.type === 'keyup' && this.downKeys[key] === undefined)) { return; } this.ctrlKey = !!e.ctrlKey; this.altKey = !!e.altKey; this.metaKey = !!e.metaKey; this.shiftKey = !!e.shiftKey; if (e.type === "keydown") { this.downKeys[key] = true; this.dispatchCustomEvent("keydown", { originalEvent: e }); } else if (e.type === "keyup") { // See https://github.com/rgthree/rgthree-comfy/issues/238 // A little bit of a hack, but Mac reportedly does something odd with copy/paste. ComfyUI // gobbles the copy event propagation, but it happens for paste too and reportedly 'Enter' which // I can't find a reason for in LiteGraph/comfy. So, for Mac only, whenever we lift a Command // (META) key, we'll also clear any other keys. if (key === "META" && this.isMac) { this.clearKeydowns(); } else { delete this.downKeys[key]; // this.debugRenderKeys(); } this.dispatchCustomEvent("keyup", { originalEvent: e }); } } private clearKeydowns() { this.ctrlKey = false; this.altKey = false; this.metaKey = false; this.shiftKey = false; for (const key in this.downKeys) delete this.downKeys[key]; } /** * Wraps `dispatchEvent` for easier CustomEvent dispatching. */ private dispatchCustomEvent(event: string, detail?: any) { if (detail != null) { return this.dispatchEvent(new CustomEvent(event, { detail })); } return this.dispatchEvent(new CustomEvent(event)); } /** * Parses a shortcut string. * * - 's' => ['S'] * - 'shift + c' => ['SHIFT', 'C'] * - 'shift + meta + @' => ['SHIFT', 'META', '@'] * - 'shift + + + @' => ['SHIFT', '__PLUS__', '='] * - '+ + p' => ['__PLUS__', 'P'] */ private getKeysFromShortcut(shortcut: string | string[]) { let keys; if (typeof shortcut === "string") { // Rip all spaces out. Note, Comfy swallows space, so we don't have to handle it. Otherwise, // we would require space to be fed as "Space" or "Spacebar" instead of " ". shortcut = shortcut.replace(/\s/g, ""); // Change a real "+" to something we can encode. shortcut = shortcut.replace(/^\+/, "__PLUS__").replace(/\+\+/, "+__PLUS__"); keys = shortcut.split("+").map((i) => i.replace("__PLUS__", "+")); } else { keys = [...shortcut]; } return keys.map((k) => k.toLocaleUpperCase()); } /** * Checks if all keys passed in are down. */ areAllKeysDown(keys: string | string[]) { keys = this.getKeysFromShortcut(keys); return keys.every((k) => { return this.downKeys[k]; }); } /** * Checks if only the keys passed in are down; optionally and additionally allowing "shift" key. */ areOnlyKeysDown(keys: string | string[], alsoAllowShift = false) { keys = this.getKeysFromShortcut(keys); const allKeysDown = this.areAllKeysDown(keys); const downKeysLength = Object.values(this.downKeys).length; // All keys are down and they're the only ones. if (allKeysDown && keys.length === downKeysLength) { return true; } // Special case allowing the shift key in addition to the shortcut keys. This helps when a user // may had originally defined "$" as a shortcut, but needs to press "shift + $" since it's an // upper key character, etc. if (alsoAllowShift && !keys.includes("SHIFT") && keys.length === downKeysLength - 1) { // If we're holding down shift, have one extra key held down, and the original keys don't // include shift, then we're good to go. return allKeysDown && this.areAllKeysDown(["SHIFT"]); } return false; } } /** The KeyEventService singleton. */ export const SERVICE = new KeyEventService();