import keymaster from "keymaster";
|
import { inject } from "mobx-react";
|
import { observer } from "mobx-react";
|
import { createElement, Fragment } from "react";
|
import { Tooltip } from "@humansignal/ui";
|
import Hint from "../components/Hint/Hint";
|
import { cn } from "../utils/bem";
|
import { FF_MULTI_OBJECT_HOTKEYS, isFF } from "../utils/feature-flags";
|
import { isDefined, isMacOS } from "../utils/utilities";
|
import defaultKeymap from "./settings/keymap.json";
|
|
type Keymap = typeof defaultKeymap;
|
|
if (!isFF(FF_MULTI_OBJECT_HOTKEYS)) {
|
const prev = (defaultKeymap as Keymap)["image:prev"];
|
const next = (defaultKeymap as Keymap)["image:next"];
|
|
if (prev) {
|
prev.key = prev.mac = "ctrl+a";
|
}
|
if (next) {
|
next.key = next.mac = "ctrl+d";
|
}
|
}
|
|
// Validate keymap integrity
|
const allowedKeymapKeys = ["key", "mac", "description", "modifier", "modifierDescription", "active"];
|
|
const validateKeymap = (keymap: Keymap) => {
|
Object.entries(keymap).forEach(([name, settings]) => {
|
Object.keys(settings).forEach((key) => {
|
if (!allowedKeymapKeys.includes(key)) {
|
throw new Error(`Unknown keymap property ${key} for key ${name}`);
|
}
|
});
|
});
|
};
|
|
validateKeymap(defaultKeymap);
|
|
type HotkeyMap = {
|
[key: string]: keymaster.KeyHandler;
|
};
|
|
type HotkeyNamespace = {
|
description: string;
|
readonly keys: HotkeyMap;
|
readonly descriptions: [string, string][];
|
};
|
|
type HotKeyRef = {
|
readonly namespace: string;
|
readonly func: keymaster.KeyHandler;
|
};
|
|
type HotKeyRefs = {
|
[key: string]: HotKeyRef[];
|
};
|
|
type HotkeyScopes = {
|
[key: string]: HotKeyRefs;
|
};
|
|
const DEFAULT_SCOPE = "__main__";
|
const INPUT_SCOPE = "__input__";
|
|
const _hotkeys_desc: { [key: string]: string } = {};
|
const _namespaces: { [key: string]: HotkeyNamespace } = {};
|
const _destructors: (() => void)[] = [];
|
const _scopes: HotkeyScopes = {
|
[DEFAULT_SCOPE]: {},
|
[INPUT_SCOPE]: {},
|
};
|
|
const translateNumpad = (event: any) => {
|
const numPadKeyCode = event.keyCode;
|
const translatedToDigit = numPadKeyCode - 48;
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { keyCode: translatedToDigit }));
|
};
|
|
keymaster.filter = (event) => {
|
if (keymaster.getScope() === "__none__") return false;
|
|
const tag = (event.target || event.srcElement)?.tagName;
|
const inNumberPadCodeRange = (event as any).keyCode >= 96 && (event as any).keyCode <= 105;
|
|
if (inNumberPadCodeRange) translateNumpad(event);
|
if (tag) {
|
keymaster.setScope(/^(INPUT|TEXTAREA|SELECT)$/.test(tag) ? INPUT_SCOPE : DEFAULT_SCOPE);
|
}
|
|
return true;
|
};
|
|
const ALIASES: Record<string, string> = {
|
plus: "=", // "ctrl plus" is actually a "ctrl =" because shift is not used
|
minus: "-",
|
// Here is a magic trick. Keymaster doesn't work with comma correctly (it breaks down everything upon unbinding), but the key code for comma it expects is 188
|
// And the magic is that '¼' has the same keycode. So we are going to trick keymaster to handle this in the right way.
|
",": "¼",
|
};
|
|
export const Hotkey = (namespace = "global", description = "Hotkeys") => {
|
let _hotkeys_map: HotkeyMap = {};
|
|
_namespaces[namespace] = _namespaces[namespace] ?? {
|
description,
|
get keys() {
|
return _hotkeys_map;
|
},
|
get descriptions() {
|
const descriptions = Object.keys(this.keys).reduce<[string, string][]>((res, key) => {
|
if (_hotkeys_desc[key]) res.push([key, _hotkeys_desc[key]]);
|
|
return res;
|
}, []);
|
|
return Object.fromEntries(descriptions);
|
},
|
};
|
|
// Saving handlers of current namespace to the global list for the further rebinding by necessity
|
// We need this since `keymaster.unbind` works with all handlers at the same time but our logic is based on namespaces
|
const addKeyHandlerRef = (scopeName: string, keyName: string, func: keymaster.KeyHandler) => {
|
if (!isDefined(_scopes[scopeName])) {
|
_scopes[scopeName] = {};
|
}
|
const scope = _scopes[scopeName];
|
|
if (!isDefined(scope[keyName])) {
|
scope[keyName] = [];
|
}
|
|
scope[keyName].push({
|
namespace,
|
func,
|
});
|
};
|
// Removing handlers of current namespace from the global list
|
const removeKeyHandlerRef = (scopeName: string, keyName: string) => {
|
const scope = _scopes[scopeName];
|
|
if (!scope || !scope[keyName]) return;
|
|
scope[keyName] = scope[keyName].filter((hotKeyRef) => {
|
return hotKeyRef.namespace !== namespace;
|
});
|
};
|
// Rebinding key handlers that are still in the global list
|
const rebindKeyHandlers = (scopeName: string, keyName: string) => {
|
const scope = _scopes[scopeName];
|
|
if (!scope || !scope[keyName]) return;
|
|
scope[keyName].forEach((hotKeyRef) => {
|
keymaster(keyName, scopeName, hotKeyRef.func);
|
});
|
};
|
|
const getKeys = (key: string) => {
|
const tokenRegex = /((?:\w+\+)*(?:[^,]+|,)),?/g;
|
|
return [...key.replace(/\s/, "").matchAll(tokenRegex)].map((match) => match[1]);
|
};
|
|
const unbind = () => {
|
for (const scope of [DEFAULT_SCOPE, INPUT_SCOPE]) {
|
for (const key of Object.keys(_hotkeys_map)) {
|
const keys = getKeys(key);
|
|
for (const key of keys) {
|
removeKeyHandlerRef(scope, key);
|
keymaster.unbind(key, scope);
|
rebindKeyHandlers(scope, key);
|
delete _hotkeys_desc[key];
|
}
|
}
|
}
|
|
_hotkeys_map = {};
|
};
|
|
_destructors.push(unbind);
|
|
return {
|
applyAliases(key: string) {
|
const keys = getKeys(key);
|
|
return keys
|
.map((k) =>
|
k
|
.split("+")
|
.map((k) => ALIASES[k.trim()] ?? k)
|
.join("+"),
|
)
|
.join(",");
|
},
|
/**
|
* Add key
|
*/
|
addKey(key: string, func: keymaster.KeyHandler, desc?: string, scope: string = DEFAULT_SCOPE) {
|
if (!isDefined(key)) return;
|
|
if (_hotkeys_map[key]) {
|
console.warn(`Key already added: ${key}. It's possibly a bug.`);
|
}
|
|
const keyName = this.applyAliases(key.toLowerCase());
|
|
_hotkeys_map[keyName] = func;
|
if (desc) _hotkeys_desc[keyName] = desc;
|
|
scope
|
.split(",")
|
.map((s) => s.trim())
|
.filter(Boolean)
|
.forEach((scope) => {
|
const handler: keymaster.KeyHandler = (...args) => {
|
const e = args[0];
|
|
e.stopPropagation();
|
e.preventDefault();
|
|
func(...args);
|
};
|
|
addKeyHandlerRef(scope, keyName, handler);
|
keymaster(keyName, scope, handler);
|
});
|
},
|
|
/**
|
* Given a key temp overwrites the function, the overwrite is removed
|
* after the returning function is called
|
*/
|
overwriteKey(key: string, func: keymaster.KeyHandler, desc?: string, scope: string = DEFAULT_SCOPE) {
|
if (!isDefined(key)) return;
|
|
if (this.hasKey(key)) {
|
this.removeKey(key, scope);
|
}
|
|
this.addKey(key, func, desc, scope);
|
},
|
|
/**
|
* Removes a shortcut
|
*/
|
removeKey(key: string, scope: string = DEFAULT_SCOPE) {
|
if (!isDefined(key)) return;
|
|
const keyName = key.toLowerCase();
|
|
if (this.hasKey(keyName)) {
|
scope
|
.split(",")
|
.map((s) => s.trim())
|
.filter(Boolean)
|
.forEach((scope) => {
|
removeKeyHandlerRef(scope, key);
|
keymaster.unbind(keyName, scope);
|
rebindKeyHandlers(scope, key);
|
});
|
|
delete _hotkeys_map[keyName];
|
delete _hotkeys_desc[keyName];
|
}
|
},
|
|
/**
|
* Add hotkey from keymap
|
*/
|
addNamed(name: string, func: keymaster.KeyHandler, scope?: string) {
|
const hotkey = Hotkey.keymap[name as keyof Keymap];
|
|
if (isDefined(hotkey)) {
|
const shortcut = isMacOS() ? (hotkey.mac ?? hotkey.key) : hotkey.key;
|
|
this.addKey(shortcut, func, hotkey.description, scope);
|
|
if (hotkey.modifier) {
|
this.addKey(`${hotkey.modifier}+${shortcut}`, func, hotkey.modifierDescription, scope);
|
}
|
} else {
|
throw new Error(`Unknown named hotkey ${hotkey}`);
|
}
|
},
|
|
lookupKey(name: string) {
|
const hotkey = Hotkey.keymap[name as keyof Keymap];
|
if (isDefined(hotkey)) {
|
return isMacOS() ? (hotkey.mac ?? hotkey.key) : hotkey.key;
|
}
|
},
|
|
/**
|
* Removed named hotkey
|
*/
|
removeNamed(name: string, scope?: string) {
|
const hotkey = Hotkey.keymap[name as keyof Keymap];
|
|
if (isDefined(hotkey)) {
|
const shortcut = isMacOS() ? (hotkey.mac ?? hotkey.key) : hotkey.key;
|
|
this.removeKey(shortcut, scope);
|
|
if (hotkey.modifier) {
|
this.removeKey(`${hotkey.modifier}+${shortcut}`);
|
}
|
} else {
|
throw new Error(`Unknown named hotkey ${hotkey}`);
|
}
|
},
|
|
/**
|
* Add hotkey from keymap
|
* @param {keyof keymap} name
|
* @param {keymaster.KeyHandler} func
|
* @param {DEFAULT_SCOPE | INPUT_SCOPE} scope
|
*/
|
overwriteNamed(name: string, func: keymaster.KeyHandler, scope?: string) {
|
const hotkey = Hotkey.keymap[name as keyof Keymap];
|
|
if (isDefined(hotkey)) {
|
const shortcut = isMacOS() ? (hotkey.mac ?? hotkey.key) : hotkey.key;
|
|
this.overwriteKey(shortcut, func, hotkey.description, scope);
|
|
if (hotkey.modifier) {
|
this.overwriteKey(`${hotkey.modifier}+${shortcut}`, func, hotkey.modifierDescription, scope);
|
}
|
} else {
|
throw new Error(`Unknown named hotkey ${name}`);
|
}
|
},
|
|
hasKey(key: string) {
|
if (!isDefined(key)) return;
|
|
const keyName = key.toLowerCase();
|
|
return isDefined(_hotkeys_map[keyName]);
|
},
|
|
hasKeyByName(name: string) {
|
if (!isDefined(name)) return;
|
|
const hotkey = Hotkey.keymap[name as keyof Keymap];
|
|
if (!isDefined(hotkey)) {
|
return false;
|
}
|
|
const shortcut = isMacOS() ? (hotkey.mac ?? hotkey.key) : hotkey.key;
|
|
return this.hasKey(shortcut);
|
},
|
|
hasName(name: string) {
|
if (!isDefined(name)) return;
|
|
const keyName = name.toLowerCase();
|
|
return isDefined(keyName in Hotkey.keymap);
|
},
|
|
getKeys() {
|
return Object.keys(_hotkeys_map);
|
},
|
|
getNamespace() {
|
return _namespaces[namespace];
|
},
|
|
addDescription(key: string, description: string) {
|
if (!_hotkeys_map[key]) {
|
_hotkeys_desc[key] = description;
|
}
|
},
|
|
removeDescription(key: string) {
|
if (!_hotkeys_map) {
|
_hotkeys_desc[key];
|
}
|
},
|
|
/**
|
* Unbund all hotkeys
|
*/
|
unbindAll() {
|
unbind();
|
},
|
|
/**
|
* Create combination
|
*/
|
makeComb() {
|
const prefix = null;
|
const st = "1234567890qwetasdfgzxcvbyiopjklnm";
|
const combs = st.split("");
|
|
for (let i = 0; i <= combs.length; i++) {
|
let comb;
|
|
if (prefix) comb = `${prefix}+${combs[i]}`;
|
else comb = combs[i];
|
|
if (!{}.hasOwnProperty.call(_hotkeys_map, comb)) return comb;
|
}
|
|
return null;
|
},
|
};
|
};
|
|
Hotkey.DEFAULT_SCOPE = DEFAULT_SCOPE;
|
|
Hotkey.INPUT_SCOPE = INPUT_SCOPE;
|
|
Hotkey.ALL_SCOPES = [DEFAULT_SCOPE, INPUT_SCOPE].join(",");
|
|
Hotkey.keymap = { ...defaultKeymap } as Keymap;
|
|
Hotkey.setKeymap = (newKeymap: Keymap) => {
|
validateKeymap(newKeymap);
|
|
Object.assign(Hotkey.keymap, newKeymap);
|
};
|
|
Hotkey.keysDescipritions = () => _hotkeys_desc;
|
|
Hotkey.namespaces = () => {
|
return _namespaces;
|
};
|
|
Hotkey.unbindAll = () => {
|
_destructors.forEach((unbind) => unbind());
|
};
|
|
/**
|
* Set scope of hotkeys
|
* @param {*} scope
|
*/
|
Hotkey.setScope = (scope: string) => {
|
keymaster.setScope(scope);
|
};
|
|
/**
|
* @param {{name: keyof defaultKeymap}} param0
|
*/
|
Hotkey.Tooltip = inject("store")(
|
observer(({ store, name, children, ...props }: any) => {
|
const hotkey = Hotkey.keymap[name as keyof Keymap];
|
const enabled = store.settings.enableTooltips && store.settings.enableHotkeys;
|
|
if (isDefined(hotkey)) {
|
const shortcut = isMacOS() ? (hotkey.mac ?? hotkey.key) : hotkey.key;
|
|
const description = props.title ?? hotkey.description;
|
const hotkeys: JSX.Element[] = [];
|
|
if (enabled) {
|
shortcut.split(",").forEach((combination: string) => {
|
const keys = combination.split("+").map((key: string) =>
|
createElement(
|
"kbd",
|
{
|
className: cn("hotkey").elem("key").toClassName(),
|
},
|
key,
|
),
|
);
|
|
hotkeys.push(
|
createElement(
|
"span",
|
{
|
className: cn("key-group").toClassName(),
|
style: { marginLeft: 5 },
|
},
|
...keys,
|
),
|
);
|
});
|
}
|
|
return createElement(
|
Tooltip,
|
{
|
...props,
|
theme: "light",
|
title: createElement(Fragment, {}, ...[description, ...hotkeys]),
|
},
|
children,
|
);
|
}
|
|
return children;
|
}),
|
);
|
|
/**
|
* @param {{name: keyof typeof defaultKeymap}} param0
|
*/
|
Hotkey.Hint = inject("store")(
|
observer(({ store, name }: any) => {
|
const hotkey = Hotkey.keymap[name as keyof Keymap];
|
const enabled = store.settings.enableTooltips && store.settings.enableHotkeys;
|
|
if (isDefined(hotkey) && enabled) {
|
const shortcut = isMacOS() ? (hotkey.mac ?? hotkey.key) : hotkey.key;
|
|
return createElement(Hint, {}, [shortcut]);
|
}
|
|
return null;
|
}),
|
);
|
|
export type HotkeyList = keyof typeof Hotkey.keymap;
|
|
export default {
|
DEFAULT_SCOPE,
|
INPUT_SCOPE,
|
Hotkey,
|
};
|