2
0
mirror of https://github.com/frappe/books.git synced 2024-11-15 17:57:08 +00:00
books/src/utils/shortcuts.ts
18alantom 1e8b1152bb fix: minor refactor shortcuts, dont maintain stack
- better dialog messages
- use Shortcut for escape in modal
2023-03-27 00:27:00 -07:00

240 lines
5.6 KiB
TypeScript

import { Keys } from 'utils/types';
import { watch } from 'vue';
import { getIsMac } from './misc';
interface ModMap {
alt: boolean;
ctrl: boolean;
meta: boolean;
shift: boolean;
repeat: boolean;
}
type Mod = keyof ModMap;
type Context = unknown;
type ShortcutFunction = () => void;
type ShortcutConfig = {
callback: ShortcutFunction;
propagate: boolean;
};
type ShortcutMap = Map<Context, Map<string, ShortcutConfig>>;
const mods: Readonly<Mod[]> = ['alt', 'ctrl', 'meta', 'repeat', 'shift'];
/**
* Used to add shortcuts based on **context**.
*
* **Context** is a identifier for where the shortcut belongs. For instance
* a _Form_ component having shortcuts for _Submit Form_.
*
* In the above example an app can have multiple instances of the _Form_
* component active at the same time, so the passed context should be a
* unique identifier such as the component object.
*
* If only one instance of a component is meant to be active at a time
* (for example a _Sidebar_ component) then do not use objects, use some
* primitive datatype (`string`).
*/
export class Shortcuts {
keys: Keys;
isMac: boolean;
shortcuts: ShortcutMap;
modMap: Partial<Record<Mod, boolean>>;
keySet: Set<string>;
constructor(keys: Keys) {
this.modMap = {};
this.keySet = new Set();
this.keys = keys;
this.shortcuts = new Map();
this.isMac = getIsMac();
watch(this.keys, (keys) => {
const key = this.getKey(Array.from(keys.pressed), keys);
if (!key) {
return false;
}
if (!key || !this.keySet.has(key)) {
return;
}
this.#trigger(key);
});
}
#trigger(key: string) {
const configList = Array.from(this.shortcuts.keys())
.map((cxt) => this.shortcuts.get(cxt)?.get(key))
.filter(Boolean)
.reverse() as ShortcutConfig[];
for (const config of configList) {
config.callback();
if (!config.propagate) {
break;
}
}
}
/**
* Check if a context is present or if a shortcut
* is present in a context.
*
* @param context context in which the shortcut is to be checked
* @param shortcut shortcut that is to be checked
* @returns boolean indicating presence
*/
has(context: Context, shortcut?: string[]): boolean {
if (!shortcut) {
return this.shortcuts.has(context);
}
const contextualShortcuts = this.shortcuts.get(context);
if (!contextualShortcuts) {
return false;
}
const key = this.getKey(shortcut);
if (!key) {
return false;
}
return contextualShortcuts.has(key);
}
/**
* Assign a function to a shortcut in a given context.
*
* @param context context object to which the shortcut belongs
* @param shortcut keyboard event codes used as shortcut chord
* @param callback function to be called when the shortcut is pressed
* @param propagate whether to check and execute shortcuts in earlier contexts
* @param removeIfSet whether to delete the set shortcut
*/
set(
context: Context,
shortcut: string[],
callback: ShortcutFunction,
propagate: boolean = false,
removeIfSet: boolean = true
): void {
const key = this.getKey(shortcut);
if (!key) {
return;
}
let contextualShortcuts = this.shortcuts.get(context);
/**
* Maintain context order.
*/
if (!contextualShortcuts) {
contextualShortcuts = new Map();
} else {
this.shortcuts.delete(contextualShortcuts);
}
if (contextualShortcuts.has(key) && !removeIfSet) {
this.shortcuts.set(context, contextualShortcuts);
throw new Error(`Shortcut ${key} already exists.`);
}
this.keySet.add(key);
contextualShortcuts.set(key, { callback, propagate });
this.shortcuts.set(context, contextualShortcuts);
}
/**
* Either delete a single shortcut or all the shortcuts in
* a given context.
*
* @param context context from which the shortcut is to be removed
* @param shortcut shortcut that is to be deleted
* @returns boolean indicating success
*/
delete(context: Context, shortcut?: string[]): boolean {
if (!shortcut) {
return this.shortcuts.delete(context);
}
const contextualShortcuts = this.shortcuts.get(context);
if (!contextualShortcuts) {
return false;
}
const key = this.getKey(shortcut);
if (!key) {
return false;
}
return contextualShortcuts.delete(key);
}
/**
* Converts shortcuts list and modMap to a string to be
* used as a shortcut key.
*
* @param shortcut array of shortcut keys
* @param modMap boolean map of mod keys to be used
* @returns string to be used as the shortcut Map key
*/
getKey(shortcut: string[], modMap?: Partial<ModMap>): string | null {
const _modMap = modMap || this.modMap;
this.modMap = {};
const shortcutString = shortcut.sort().join('+');
const modString = mods.filter((k) => _modMap[k]).join('+');
if (shortcutString && modString) {
return modString + '+' + shortcutString;
}
if (!modString) {
return shortcutString;
}
if (!shortcutString) {
return modString;
}
return null;
}
/**
* Shortcut Modifiers
*/
get alt() {
this.modMap['alt'] = true;
return this;
}
get ctrl() {
this.modMap['ctrl'] = true;
return this;
}
get meta() {
this.modMap['meta'] = true;
return this;
}
get shift() {
this.modMap['shift'] = true;
return this;
}
get repeat() {
this.modMap['repeat'] = true;
return this;
}
get pmod() {
if (this.isMac) {
return this.meta;
}
return this.ctrl;
}
}