Загрузка данных
export enum Keys {
'escape' = 'escape',
'delete' = 'delete',
'tab' = 'tab',
'shift' = 'shiftleft',
'control' = 'controlleft',
'meta' = 'metaleft',
'alt' = 'altleft',
't' = 'keyt',
'h' = 'keyh',
'v' = 'keyv',
'f' = 'keyf',
'z' = 'keyz',
'y' = 'keyy',
'c' = 'keyc',
'mousedown' = 'mousedown',
}
type HotkeyCallback = () => void | Promise<void>;
interface RegisterHotkeyParams {
keys: Keys[];
callback: HotkeyCallback;
pressHoldRequired?: boolean;
}
interface UnregisterHotkeyParams {
keys: Keys[];
hash?: string | null;
}
export interface IHotkeys {
register(params: RegisterHotkeyParams): string | null;
unregister(params: UnregisterHotkeyParams): void;
}
type Tree = {
children: Map<Keys, Tree>;
callbacks: Map<string, HotkeyCallback> | null;
pressHoldRequired?: boolean;
};
// todo: возможно можно сделать синглтоном и использовать импортируя эк земпляр класса,
// там где это нужно вместо props drilling, как сейчас
export class Hotkeys implements IHotkeys {
private tree: Tree = createTree();
private buffer: Keys[] = [];
private prevKey: Keys | null = null;
constructor() {
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
}
public unregister({ keys, hash }: UnregisterHotkeyParams): void {
if (!hash) {
return;
}
let node = this.tree;
for (const key of keys) {
const child = node.children.get(normalize(key));
if (!child) {
return;
}
node = child;
}
node.callbacks?.delete(hash);
if (node.callbacks?.size === 0) {
node.callbacks = null;
}
}
public register({
keys,
callback,
pressHoldRequired = false,
}: RegisterHotkeyParams): string | null {
if (keys.length === 0) {
console.error('[Hotkeys] попытка задать пустой хоткей');
return null;
}
let node = this.tree;
keys.forEach((key) => {
const normalizedKey = normalize(key);
if (!node.children.has(normalizedKey)) {
node.children.set(normalizedKey, createTree(pressHoldRequired));
}
node = node.children.get(normalizedKey) as Tree;
});
if (!node.callbacks) {
node.callbacks = new Map();
}
const hash = `hotkeyCallback-${crypto.randomUUID()}`;
node.callbacks.set(hash, callback);
return hash;
}
public destroy(): void {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
this.tree = createTree();
this.buffer = [];
this.prevKey = null;
}
private findNode(keys: Keys[]): Tree | null {
let node = this.tree;
for (const key of keys) {
const child = node.children.get(normalize(key));
if (!child) {
return null;
}
node = child;
}
return node;
}
private handleKeyUp = async (): Promise<void> => {
const node = this.findNode(this.buffer);
if (!node) {
this.resetBuffer();
return;
}
if (node.callbacks && node.pressHoldRequired) {
await this.handleKeyDown({
code: 'Escape',
preventDefault: () => {},
} as KeyboardEvent);
}
this.resetBuffer();
};
private handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
if (isEditableElement(event.target)) {
this.resetBuffer();
return;
}
const key = normalize(event.code);
if (key === this.prevKey) {
return;
}
this.prevKey = key;
if (key === Keys.escape) {
this.buffer = [];
}
this.buffer.push(key);
const node = this.findNode(this.buffer);
if (!node) {
this.resetBuffer();
return;
}
if (!node.callbacks || node.children.size > 0) {
return;
}
event.preventDefault();
const callbacks = [...node.callbacks.values()].reverse();
for (const callback of callbacks) {
// eslint-disable-next-line no-await-in-loop
await callback();
}
this.resetBuffer();
};
private resetBuffer(): void {
this.buffer = [];
this.prevKey = null;
}
}
function createTree(pressHoldRequired = false): Tree {
return {
children: new Map(),
callbacks: null,
pressHoldRequired,
};
}
function normalize(key: string): Keys {
switch (key.toLowerCase()) {
case 'controlright':
return Keys.control;
case 'shiftright':
return Keys.shift;
case 'metaright':
return Keys.meta;
case 'altright':
return Keys.alt;
default:
return key.toLowerCase() as Keys;
}
}
function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
private registerUndoRedoHotkeys(): void {
const undoRedo = this.eventManager.getUndoRedo();
const undo = () => {
undoRedo.undo();
};
const redo = () => {
undoRedo.redo();
};
const hotkeys = [
{
keys: [Keys.control, Keys.z],
callback: undo,
},
{
keys: [Keys.meta, Keys.z],
callback: undo,
},
{
keys: [Keys.control, Keys.shift, Keys.z],
callback: redo,
},
{
keys: [Keys.shift, Keys.control, Keys.z],
callback: redo,
},
{
keys: [Keys.meta, Keys.shift, Keys.z],
callback: redo,
},
{
keys: [Keys.shift, Keys.meta, Keys.z],
callback: redo,
},
{
keys: [Keys.control, Keys.y],
callback: redo,
},
{
keys: [Keys.meta, Keys.y],
callback: redo,
},
];
this.undoRedoHotkeys = hotkeys.map(({ keys, callback }) => ({
keys,
hash: this.hotkeys.register({
keys,
callback,
}),
}));
}
private unregisterUndoRedoHotkeys(): void {
this.undoRedoHotkeys.forEach(({ keys, hash }) => {
this.hotkeys.unregister({
keys,
hash,
});
});
this.undoRedoHotkeys = [];
}