Release package version 1.0.0
All checks were successful
CI / build (push) Successful in 23s

Added files
This commit is contained in:
Andrey Kernichniy 2026-03-13 01:00:24 +07:00
parent bf0142bd6a
commit 7c351972cc
17 changed files with 966 additions and 8 deletions

View file

@ -41,7 +41,7 @@ jobs:
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: gxc-math-${{ github.sha }}
name: gxc-hotkeys-${{ github.sha }}
path: ./dist/
deploy:
@ -53,7 +53,7 @@ jobs:
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: gxc-math-${{ github.sha }}
name: gxc-hotkeys-${{ github.sha }}
path: ./artifact
- name: Setup Node.js

View file

@ -0,0 +1,2 @@
/** Identifier for global keyboard zone */
export const GLOBAL_ZONE_TOKEN = "GLOBAL";

View file

@ -0,0 +1,205 @@
import { IKeyLabel } from "../interfaces";
import { Key } from "./key";
/** Labels for Windows keys */
export const WINDOWS_KEYS: IKeyLabel[] = [
{ code: Key.Backspace, label: "Backspace" },
{ code: Key.Tab, label: "Tab" },
{ code: Key.Enter, label: "Enter" },
{ code: Key.Shift, label: "Shift" },
{ code: Key.Ctrl, label: "Ctrl" },
{ code: Key.Alt, label: "Alt" },
{ code: Key.PauseBreak, label: "Pause" },
{ code: Key.CapsLock, label: "Caps Lock" },
{ code: Key.Escape, label: "Esc" },
{ code: Key.Space, label: "Space" },
{ code: Key.PageUp, label: "PgUp" },
{ code: Key.PageDown, label: "PgDn" },
{ code: Key.End, label: "End" },
{ code: Key.Home, label: "Home" },
{ code: Key.LeftArrow, label: "←" },
{ code: Key.UpArrow, label: "↑" },
{ code: Key.RightArrow, label: "→" },
{ code: Key.DownArrow, label: "↓" },
{ code: Key.Insert, label: "Insert" },
{ code: Key.Delete, label: "Del" },
{ code: Key.Zero, label: "0", altLabel: ")" },
{ code: Key.One, label: "1", altLabel: "!" },
{ code: Key.Two, label: "2", altLabel: "@" },
{ code: Key.Three, label: "3", altLabel: "#" },
{ code: Key.Four, label: "4", altLabel: "$" },
{ code: Key.Five, label: "5", altLabel: "%" },
{ code: Key.Six, label: "6", altLabel: "^" },
{ code: Key.Seven, label: "7", altLabel: "&" },
{ code: Key.Eight, label: "8", altLabel: "*" },
{ code: Key.Nine, label: "9", altLabel: "(" },
{ code: Key.A, label: "A" },
{ code: Key.B, label: "B" },
{ code: Key.C, label: "C" },
{ code: Key.D, label: "D" },
{ code: Key.E, label: "E" },
{ code: Key.F, label: "F" },
{ code: Key.G, label: "G" },
{ code: Key.H, label: "H" },
{ code: Key.I, label: "I" },
{ code: Key.J, label: "J" },
{ code: Key.K, label: "K" },
{ code: Key.L, label: "L" },
{ code: Key.M, label: "M" },
{ code: Key.N, label: "N" },
{ code: Key.O, label: "O" },
{ code: Key.P, label: "P" },
{ code: Key.Q, label: "Q" },
{ code: Key.R, label: "R" },
{ code: Key.S, label: "S" },
{ code: Key.T, label: "T" },
{ code: Key.U, label: "U" },
{ code: Key.V, label: "V" },
{ code: Key.W, label: "W" },
{ code: Key.X, label: "X" },
{ code: Key.Y, label: "Y" },
{ code: Key.Z, label: "Z" },
{ code: Key.LeftWindowKey, label: "Win" },
{ code: Key.RightWindowKey, label: "Win" },
{ code: Key.SelectKey, label: "Menu" },
{ code: Key.Numpad0, label: "0" },
{ code: Key.Numpad1, label: "1" },
{ code: Key.Numpad2, label: "2" },
{ code: Key.Numpad3, label: "3" },
{ code: Key.Numpad4, label: "4" },
{ code: Key.Numpad5, label: "5" },
{ code: Key.Numpad6, label: "6" },
{ code: Key.Numpad7, label: "7" },
{ code: Key.Numpad8, label: "8" },
{ code: Key.Numpad9, label: "9" },
{ code: Key.Multiply, label: "*" },
{ code: Key.Add, label: "+" },
{ code: Key.Subtract, label: "-" },
{ code: Key.DecimalPoint, label: "." },
{ code: Key.Divide, label: "/" },
{ code: Key.F1, label: "F1" },
{ code: Key.F2, label: "F2" },
{ code: Key.F3, label: "F3" },
{ code: Key.F4, label: "F4" },
{ code: Key.F5, label: "F5" },
{ code: Key.F6, label: "F6" },
{ code: Key.F7, label: "F7" },
{ code: Key.F8, label: "F8" },
{ code: Key.F9, label: "F9" },
{ code: Key.F10, label: "F10" },
{ code: Key.F11, label: "F11" },
{ code: Key.F12, label: "F12" },
{ code: Key.NumLock, label: "Num Lock" },
{ code: Key.ScrollLock, label: "ScrLk" },
{ code: Key.SemiColon, label: ";" },
{ code: Key.Equals, label: "=", altLabel: "+" },
{ code: Key.Comma, label: "," },
{ code: Key.Dash, label: "-", altLabel: "_" },
{ code: Key.Period, label: "." },
{ code: Key.ForwardSlash, label: "/" },
{ code: Key.Tilde, label: "~", altLabel: "`" },
{ code: Key.OpenBracket, label: "[" },
{ code: Key.ClosedBracket, label: "]" },
{ code: Key.Quote, label: "'" },
];
/** Labels for Mac OS keys */
export const MAC_KEYS: IKeyLabel[] = [
{ code: Key.Backspace, label: "Backspace" },
{ code: Key.Tab, label: "⇥" },
{ code: Key.Enter, label: "Enter" },
{ code: Key.Shift, label: "⇧" },
{ code: Key.Ctrl, label: "Control" },
{ code: Key.Alt, label: "⌥" },
{ code: Key.PauseBreak, label: "Pause" },
{ code: Key.CapsLock, label: "⇪" },
{ code: Key.Escape, label: "Esc" },
{ code: Key.Space, label: "Space" },
{ code: Key.PageUp, label: "PgUp" },
{ code: Key.PageDown, label: "PgDn" },
{ code: Key.End, label: "End" },
{ code: Key.Home, label: "Home" },
{ code: Key.LeftArrow, label: "←" },
{ code: Key.UpArrow, label: "↑" },
{ code: Key.RightArrow, label: "→" },
{ code: Key.DownArrow, label: "↓" },
{ code: Key.Insert, label: "Insert" },
{ code: Key.Delete, label: "Backspace" },
{ code: Key.Zero, label: "0", altLabel: ")" },
{ code: Key.One, label: "1", altLabel: "!" },
{ code: Key.Two, label: "2", altLabel: "@" },
{ code: Key.Three, label: "3", altLabel: "#" },
{ code: Key.Four, label: "4", altLabel: "$" },
{ code: Key.Five, label: "5", altLabel: "%" },
{ code: Key.Six, label: "6", altLabel: "^" },
{ code: Key.Seven, label: "7", altLabel: "&" },
{ code: Key.Eight, label: "8", altLabel: "*" },
{ code: Key.Nine, label: "9", altLabel: "(" },
{ code: Key.A, label: "A" },
{ code: Key.B, label: "B" },
{ code: Key.C, label: "C" },
{ code: Key.D, label: "D" },
{ code: Key.E, label: "E" },
{ code: Key.F, label: "F" },
{ code: Key.G, label: "G" },
{ code: Key.H, label: "H" },
{ code: Key.I, label: "I" },
{ code: Key.J, label: "J" },
{ code: Key.K, label: "K" },
{ code: Key.L, label: "L" },
{ code: Key.M, label: "M" },
{ code: Key.N, label: "N" },
{ code: Key.O, label: "O" },
{ code: Key.P, label: "P" },
{ code: Key.Q, label: "Q" },
{ code: Key.R, label: "R" },
{ code: Key.S, label: "S" },
{ code: Key.T, label: "T" },
{ code: Key.U, label: "U" },
{ code: Key.V, label: "V" },
{ code: Key.W, label: "W" },
{ code: Key.X, label: "X" },
{ code: Key.Y, label: "Y" },
{ code: Key.Z, label: "Z" },
{ code: Key.LeftWindowKey, label: "⌘" },
{ code: Key.RightWindowKey, label: "⌘" },
{ code: Key.Numpad0, label: "0" },
{ code: Key.Numpad1, label: "1" },
{ code: Key.Numpad2, label: "2" },
{ code: Key.Numpad3, label: "3" },
{ code: Key.Numpad4, label: "4" },
{ code: Key.Numpad5, label: "5" },
{ code: Key.Numpad6, label: "6" },
{ code: Key.Numpad7, label: "7" },
{ code: Key.Numpad8, label: "8" },
{ code: Key.Numpad9, label: "9" },
{ code: Key.Multiply, label: "*" },
{ code: Key.Add, label: "+" },
{ code: Key.Subtract, label: "-" },
{ code: Key.DecimalPoint, label: "." },
{ code: Key.Divide, label: "/" },
{ code: Key.F1, label: "F1" },
{ code: Key.F2, label: "F2" },
{ code: Key.F3, label: "F3" },
{ code: Key.F4, label: "F4" },
{ code: Key.F5, label: "F5" },
{ code: Key.F6, label: "F6" },
{ code: Key.F7, label: "F7" },
{ code: Key.F8, label: "F8" },
{ code: Key.F9, label: "F9" },
{ code: Key.F10, label: "F10" },
{ code: Key.F11, label: "F11" },
{ code: Key.F12, label: "F12" },
{ code: Key.NumLock, label: "Num Lock" },
{ code: Key.ScrollLock, label: "ScrLk" },
{ code: Key.SemiColon, label: ";" },
{ code: Key.Equals, label: "=", altLabel: "+" },
{ code: Key.Comma, label: "," },
{ code: Key.Dash, label: "-", altLabel: "_" },
{ code: Key.Period, label: "." },
{ code: Key.ForwardSlash, label: "/" },
{ code: Key.Tilde, label: "~", altLabel: "`" },
{ code: Key.OpenBracket, label: "[" },
{ code: Key.ClosedBracket, label: "]" },
{ code: Key.Quote, label: "'" },
];

127
lib/src/constants/key.ts Normal file
View file

@ -0,0 +1,127 @@
export enum Key {
Backspace = 8,
Tab = 9,
Enter = 13,
Shift = 16,
Ctrl = 17,
Alt = 18,
PauseBreak = 19,
CapsLock = 20,
Escape = 27,
Space = 32,
PageUp = 33,
PageDown = 34,
End = 35,
Home = 36,
LeftArrow = 37,
UpArrow = 38,
RightArrow = 39,
DownArrow = 40,
Insert = 45,
Delete = 46,
Zero = 48,
ClosedParen = Zero,
One = 49,
ExclamationMark = One,
Two = 50,
AtSign = Two,
Three = 51,
PoundSign = Three,
Hash = PoundSign,
Four = 52,
DollarSign = Four,
Five = 53,
PercentSign = Five,
Six = 54,
Caret = Six,
Hat = Caret,
Seven = 55,
Ampersand = Seven,
Eight = 56,
Star = Eight,
Asterik = Star,
Nine = 57,
OpenParen = Nine,
A = 65,
B = 66,
C = 67,
D = 68,
E = 69,
F = 70,
G = 71,
H = 72,
I = 73,
J = 74,
K = 75,
L = 76,
M = 77,
N = 78,
O = 79,
P = 80,
Q = 81,
R = 82,
S = 83,
T = 84,
U = 85,
V = 86,
W = 87,
X = 88,
Y = 89,
Z = 90,
LeftWindowKey = 91,
RightWindowKey = 92,
SelectKey = 93,
Numpad0 = 96,
Numpad1 = 97,
Numpad2 = 98,
Numpad3 = 99,
Numpad4 = 100,
Numpad5 = 101,
Numpad6 = 102,
Numpad7 = 103,
Numpad8 = 104,
Numpad9 = 105,
Multiply = 106,
Add = 107,
Subtract = 109,
DecimalPoint = 110,
Divide = 111,
F1 = 112,
F2 = 113,
F3 = 114,
F4 = 115,
F5 = 116,
F6 = 117,
F7 = 118,
F8 = 119,
F9 = 120,
F10 = 121,
F11 = 122,
F12 = 123,
NumLock = 144,
ScrollLock = 145,
SemiColon = 186,
Equals = 187,
Comma = 188,
Dash = 189,
Period = 190,
UnderScore = Dash,
PlusSign = Equals,
ForwardSlash = 191,
Tilde = 192,
GraveAccent = Tilde,
OpenBracket = 219,
ClosedBracket = 221,
Quote = 222,
}

View file

@ -0,0 +1,18 @@
import { adaptKeysToPlatform, getOS } from ".";
import { Key } from "../constants/key";
import { MAC_SPECIAL_KEYS, OPERATING_SYSTEM } from "../interfaces";
describe("Utils", () => {
beforeEach(async () => {});
it("Adapting keys to platform", () => {
const os = getOS();
const adapted = adaptKeysToPlatform([[Key.Ctrl]]);
if (os === OPERATING_SYSTEM.WINDOWS) {
expect(adapted[0][0]).toBe(Key.Ctrl);
}
if (os === OPERATING_SYSTEM.MAC_OS) {
expect(adapted[0][0]).toBe(MAC_SPECIAL_KEYS.CmdLeft as any);
}
});
});

View file

@ -0,0 +1,70 @@
import { Key } from "../constants/key";
import { MAC_KEYS, WINDOWS_KEYS } from "../constants/key-labels";
import { IKeyLabel, IKeyRegistry, OPERATING_SYSTEM } from "../interfaces";
import { getMacKeys } from "../utils";
/**
* Create label string for hotkey depending on OS
* @param keySet - array describing the hotkey. For example: [Key.Ctrl, Key.A]
* @param delimiter - string for joining labels
* @returns label string for hotkey. For example "Ctrl + A"
*/
export const getLabelByKeySet = (keySet: Key[], delimiter?: string): string => {
let platformKeySet: IKeyLabel[] = WINDOWS_KEYS;
if (getOS() === OPERATING_SYSTEM.MAC_OS) {
if (delimiter == null) delimiter = "";
platformKeySet = MAC_KEYS;
} else {
if (delimiter == null) delimiter = " + ";
}
if (Array.isArray(keySet)) {
return keySet
.map((key) => platformKeySet.find((item) => item.code === key)?.label ?? "")
.filter((label) => label !== "")
.join(delimiter);
}
return "";
};
/**
* Create label string for hotkey depending on OS using interface `IKeyRegistry`.
* For creating label will be use first hotkey from `keys` array
*/
export const getLabel = (hotkey: IKeyRegistry, delimiter?: string): string => hotkey.label ?? getLabelByKeySet(hotkey.keys[0], delimiter);
/**
* Adapt keyboard keys to platform
* @param keySet - array describing the hotkey. For example: [Key.Ctrl, Key.A]
* @returns keyboard keys adapted to the platform
*/
export const adaptKeysToPlatform = (keySet: Key[][]): Key[][] => {
switch (getOS()) {
case OPERATING_SYSTEM.WINDOWS:
case OPERATING_SYSTEM.OTHER:
return keySet;
case OPERATING_SYSTEM.MAC_OS:
return getMacKeys(keySet);
default:
return keySet;
}
};
/** Get current OS */
export const getOS = () => {
const userAgent = navigator.userAgent.toLowerCase();
const macosPlatforms = /(macintosh|macintel|macppc|mac68k|macos)/i;
const windowsPlatforms = /(win32|win64|windows|wince)/i;
const iosPlatforms = /(iphone|ipad|ipod)/i;
if (macosPlatforms.test(userAgent)) {
return OPERATING_SYSTEM.MAC_OS;
} else if (windowsPlatforms.test(userAgent)) {
return OPERATING_SYSTEM.WINDOWS;
} else if (iosPlatforms.test(userAgent)) {
return OPERATING_SYSTEM.IOS;
}
return OPERATING_SYSTEM.OTHER;
};

308
lib/src/hotkey-manager.ts Normal file
View file

@ -0,0 +1,308 @@
import { arraysIsEqual } from "@gxc-solutions/lett-js/algorithms/array";
import { Subject, fromEvent, takeWhile } from "rxjs";
import { GLOBAL_ZONE_TOKEN } from "./constants";
import { Key } from "./constants/key";
import { FindKeyCb, IKeyRegistry, IKeyboardEvent, IRegistrationCheckResult, MouseKey } from "./interfaces";
import { KeyRegistry } from "./models/key-registry";
import { actionExistError, correspondsToKeySet, hotkeyExistError } from "./utils";
/** A service that registers hotkeys, handles events, and notifies of hotkey events */
export class HotkeyManager<A = string> {
protected _zonesMap = new Map<string, Map<A, KeyRegistry<A>>>([[GLOBAL_ZONE_TOKEN, new Map()]]);
protected _events = new Subject<IKeyboardEvent<A>>();
/** Current pressed keyboard key */
protected _currentDownKeys: Key[] = [];
/** Current pressed mouse buttons */
protected _currentMouseDownKeys = new Set<MouseKey>([]);
protected _isDestroyed = false;
/** Set `true` for ignore events */
public ignore = false;
/** Notify when found hotkeys event */
get $keyboardEvents() {
return this._events.asObservable();
}
/** @hideconstructor */
constructor(
private _element: HTMLElement,
rootHotkeys: IKeyRegistry<A>[],
) {
// Keyboard
fromEvent(this._element, "keydown")
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe((e: KeyboardEvent) => this.handleKeyPressing(e));
fromEvent(this._element, "keyup")
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe((e: KeyboardEvent) => this.handleKeyUp(e));
// Mouse
fromEvent(this._element, "blur")
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe(() => (this._currentDownKeys = []));
fromEvent(this._element, "wheel", { passive: false })
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe((e: WheelEvent) => this.handleWheel(e));
fromEvent(this._element, "click")
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe((e: MouseEvent) => this.handleClick(e));
fromEvent(this._element, "dbclick")
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe((e: MouseEvent) => this.handleDbClick(e));
// fromEvent(window, "mousemove").subscribe((e: MouseEvent) => this._handleMove(e));
fromEvent(this._element, "mousedown")
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe((e: MouseEvent) => this.handleMouseDown(e));
fromEvent(this._element, "mouseup")
.pipe(takeWhile(() => !this._isDestroyed))
.subscribe((e: MouseEvent) => this.handleMouseUp(e));
// Registry root hotkeys
this.registryKeysSet(rootHotkeys);
}
/**
* Register hotkeys to system
* @param keyInfo - hotkey registry data
* @param provider - handler provider service
*/
public registryKeys(keyInfo: IKeyRegistry<A>): void {
const keyRegistry = new KeyRegistry<A>(keyInfo);
const map = this._zonesMap.get(keyRegistry.zone);
const result = this._isRegisteredHotkey(keyRegistry);
if (result.isRegistered) {
throw new Error(result.message);
}
if (map) {
map.set(keyRegistry.action, keyRegistry);
} else {
const actionMap = new Map<A, KeyRegistry<A>>();
this._zonesMap.set(keyRegistry.zone, actionMap);
actionMap.set(keyRegistry.action, keyRegistry);
}
}
/**
* Unregister hotkey from system
* @param keyInfo - hotkey registry data
*/
public unregisterKeys(keyInfo: IKeyRegistry<A>): void {
this._zonesMap.get(keyInfo.zone || GLOBAL_ZONE_TOKEN)?.delete(keyInfo.action);
}
/**
* Bulk hotkeys registration
* @param keysSet - array hotkey registry data
* @param provider - handler provider service
*/
public registryKeysSet(keysSet: IKeyRegistry<A>[]): void {
keysSet.forEach((item) => this.registryKeys(item));
}
/**
* Bulk hotkeys unregister
* @param keysSet - array hotkey registry data
*/
public unregisterKeysSet(keysSet: IKeyRegistry<A>[]): void {
keysSet.forEach((item) => this.unregisterKeys(item));
}
/**
* Found hotkey event by current pressed keys and other events
* @param zone - zone for find
* @returns hotkey event
* @hidden
*/
public getEventByKeys(zone: string, event: Event, cb: FindKeyCb): IKeyboardEvent<A> | undefined {
const [action, inZone] = this._findHotkeyAction(zone, cb);
if (action) {
return {
action,
keys: [...this._currentDownKeys],
zone: inZone,
triggeringEvent: event,
};
}
}
/**
* Handle key press event and try found registered hotkey event
* @param zone - the zone from which the event came
* @returns found hotkeys event (if found)
*/
public handleKeyPressing(e: KeyboardEvent, zone: string = GLOBAL_ZONE_TOKEN): IKeyboardEvent<A> | undefined {
if (this._isPressedKey(e.keyCode)) {
return;
}
this._currentDownKeys.push(Key[Key[e.keyCode]]);
const event = this.getEventByKeys(zone, e, (k, m, mouseKeys) => this._isCurrentKeySet(k) && !(mouseKeys && mouseKeys?.length) && !m);
this._notify(e, event);
return event;
}
private _notify(e: Event, event: IKeyboardEvent<A>): void {
if (event && !this.ignore) {
e.preventDefault();
this._events.next(event);
}
}
/** Remove key from list current down keys */
public handleKeyUp(e: KeyboardEvent): void {
this._currentDownKeys = this._currentDownKeys.filter((key) => key !== Key[Key[e.keyCode]]);
if ((e as any).key === "Meta") {
this.clearCurrentDownKeys();
}
}
/**
* Handle wheel event and try found registered hotkey event
* @param zone - the zone from which the event came
* @returns found hotkeys event (if found)
*/
public handleWheel(e: WheelEvent, zone: string = GLOBAL_ZONE_TOKEN): IKeyboardEvent<A> {
let event: IKeyboardEvent<A>;
if (e.deltaY < 0) {
event = this.getEventByKeys(
zone,
e,
(keys, mouseEvents, mouseKeys) => this._isCurrentKeySet(keys) && this._isCurMouseKeySet(mouseKeys) && mouseEvents === "wheelup",
);
} else {
event = this.getEventByKeys(
zone,
e,
(keys, mouseEvents, mouseKeys) => this._isCurrentKeySet(keys) && this._isCurMouseKeySet(mouseKeys) && mouseEvents === "wheeldown",
);
}
this._notify(e, event);
return event;
}
/**
* Handle click event and try found registered hotkey event
* @param zone - the zone from which the event came
* @returns found hotkeys event (if found)
*/
public handleClick(e: MouseEvent, zone: string = GLOBAL_ZONE_TOKEN): IKeyboardEvent<A> {
const event = this.getEventByKeys(zone, e, (keys, mouseEvent) => this._isCurrentKeySet(keys) && mouseEvent === "click");
this._notify(e, event);
return event;
}
/**
* Handle double click event and try found registered hotkey event
* @param zone - the zone from which the event came
* @returns found hotkeys event (if found)
*/
public handleDbClick(e: MouseEvent, zone: string = GLOBAL_ZONE_TOKEN): IKeyboardEvent<A> {
const event = this.getEventByKeys(
zone,
e,
(keys, mouseEvent, mouseKeys) => this._isCurrentKeySet(keys) && this._isCurMouseKeySet(mouseKeys) && mouseEvent === "dbclick",
);
this._notify(e, event);
return event;
}
/**
* Handle mouse down event and try found registered hotkey event
* @param zone - the zone from which the event came
* @returns found hotkeys event (if found)
*/
public handleMouseDown(e: MouseEvent, zone: string = GLOBAL_ZONE_TOKEN): IKeyboardEvent<A> {
this._currentMouseDownKeys = new Set([]);
// * See: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
[
[1, MouseKey.LMB],
[2, MouseKey.RMB],
[4, MouseKey.MMB],
[8, MouseKey.BMB],
[16, MouseKey.FMB],
].forEach(([v, b]) => {
if (e.buttons >= v) {
this._currentMouseDownKeys.add(b);
}
});
const event = this.getEventByKeys(
zone,
e,
(keys, mouseEvent, mouseKeys) => this._isCurrentKeySet(keys) && this._isCurMouseKeySet(mouseKeys) && !mouseEvent,
);
this._notify(e, event);
return event;
}
/** Remove key from list current mouse down keys */
public handleMouseUp(e: MouseEvent): void {
this._currentMouseDownKeys.delete(e.button);
}
private _handleMove(e: MouseEvent, zone: string = GLOBAL_ZONE_TOKEN) {
const event = this.getEventByKeys(zone, e, (keys, mouseEvent) => this._isCurrentKeySet(keys) && mouseEvent === "mousemove");
this._notify(e, event);
}
private _isPressedKey(keyCode: number): boolean {
return this._currentDownKeys.includes(keyCode);
}
private _findHotkeyAction(zone: string, cb: FindKeyCb): [A | undefined, string] {
const foundAction = this._findAction(zone, cb);
return foundAction ? [foundAction, zone] : [this._findAction(GLOBAL_ZONE_TOKEN, cb), GLOBAL_ZONE_TOKEN];
}
private _isCurrentKeySet(keys: Key[][]): boolean {
return correspondsToKeySet(this._currentDownKeys, keys);
}
private _isCurMouseKeySet(mouseKeys: MouseKey[]): boolean {
if (!mouseKeys && this._currentMouseDownKeys.size === 0) {
return true;
}
if (!mouseKeys || mouseKeys?.length !== this._currentMouseDownKeys.size) {
return false;
}
return mouseKeys.every((mk) => this._currentMouseDownKeys.has(mk));
}
private _findAction(zone: string, cb: FindKeyCb): A | undefined {
let foundAction: A | undefined;
this._zonesMap.get(zone).forEach(({ mouseEvents, action, keys, mouseKeys }) => {
if (cb(keys, mouseEvents, mouseKeys)) {
foundAction = action;
}
});
return foundAction;
}
/** Clear current down keyboard keys */
public clearCurrentDownKeys(): void {
this._currentDownKeys = [];
}
/** Allows you to check whether a hotkey or action is registered or not */
public isRegisteredHotkey(data: IKeyRegistry<A>): boolean {
const result = this._isRegisteredHotkey(new KeyRegistry<A>(data));
return result.isRegistered;
}
private _isRegisteredHotkey(data: KeyRegistry<A>): IRegistrationCheckResult {
const actionsMap = this._zonesMap.get(data.zone);
if (actionsMap == null) return { isRegistered: false };
if (actionsMap.get(data.action)) {
return { isRegistered: true, message: actionExistError(data.action) };
}
for (const registry of actionsMap.values()) {
const someHotkeyEqual = registry.keys.some((existKeySet) => data.keys.some((keySet) => arraysIsEqual(keySet, existKeySet)));
const mouseKeysEqual = arraysIsEqual(data.mouseKeys, registry.mouseKeys);
const mouseEventEqual = data.mouseEvents === registry.mouseEvents;
if (someHotkeyEqual && mouseKeysEqual && mouseEventEqual) {
return { isRegistered: true, message: hotkeyExistError(data, registry) };
}
}
return { isRegistered: false };
}
public destroy() {
this._isDestroyed = true;
}
}

View file

@ -0,0 +1,5 @@
export * from "./hotkey-manager";
export * from "./utils";
export * from "./functions";
export * from "./interfaces";
export * from "./constants";

View file

@ -0,0 +1,88 @@
import { Key } from "../constants/key";
/** Describe the mouse events */
export type TMouseEvent = "click" | "dbclick" | "mousemove" | "wheelup" | "wheeldown";
// "mouseup" | "mousedown"
/** Interface for describe hotkey */
export interface IKeyRegistry<A = string> {
/** Type of mouse event */
mouseEvents?: TMouseEvent;
/** List of mouse keys. */
mouseKeys?: MouseKey[];
/** Action identifier */
action: A;
/** Label for hotkey */
label?: string;
/** List of hotkeys */
keys: Key[][];
/** Zone where hotkeys works */
zone?: string;
}
/** Describe keyboard event */
export interface IKeyboardEvent<A = string, P = any> {
/** Pressed keys */
keys: Key[];
/** Action identifier */
action: A;
/** Keyboard zone */
zone: string | "GLOBAL";
/** Event which provoked actuation of hotkey event */
triggeringEvent: Event;
/** Event payload. Can be any data */
payload?: P;
}
/** Describe types of mouse keys */
export enum MouseKey {
LMB = 0,
MMB = 1,
RMB = 2,
FMB = 3,
BMB = 4,
}
/** Available types for actions */
export type Action = string | number;
/** Function type for check is the hotkey suitable or not */
export type FindKeyCb = (keys: Key[][], mouseEvents: TMouseEvent, mouseKeys: MouseKey[]) => boolean;
/** Description keyboard key label */
export interface IKeyLabel {
/** Key code */
code: number;
/** Label for key */
label: string;
/** Alternate label for key */
altLabel?: string | null;
}
/** Describe OS types */
export enum OPERATING_SYSTEM {
WINDOWS,
MAC_OS,
IOS,
OTHER,
}
/** Mac OS key codes */
export enum MAC_SPECIAL_KEYS {
/** 91 - key code for CmdLeft on macOS */
CmdLeft = 91,
/** 93 - key code for CmdRight on macOS */
CmdRight = 93,
/** 8 - key code for Backspace on macOS */
Backspace = 8,
/** 18 - key code for Option on macOS */
Option = 18,
}
/** Describes the result of registration checking */
export interface IRegistrationCheckResult {
/** `true` if action or hotkey is registered */
isRegistered: boolean;
/** Describes detail of checking */
message?: string;
}

View file

@ -0,0 +1,28 @@
import { GLOBAL_ZONE_TOKEN } from "../constants";
import { Key } from "../constants/key";
import { IKeyRegistry, MouseKey, TMouseEvent } from "../interfaces";
/** Describe data of hotkey */
export class KeyRegistry<T = string> implements IKeyRegistry<T> {
/** Type of mouse event */
mouseEvents: TMouseEvent;
/** List of mouse keys. */
mouseKeys: MouseKey[];
/** Action identifier */
action: T;
/** Label for hotkey */
label: string;
/** List of hotkeys */
keys: Key[][];
/** Zone where hotkeys works */
zone: string;
constructor(keyRegistry: IKeyRegistry<T>) {
this.mouseEvents = keyRegistry.mouseEvents;
this.mouseKeys = keyRegistry.mouseKeys ?? [];
this.action = keyRegistry.action;
this.keys = keyRegistry.keys;
this.label = keyRegistry.label;
this.zone = keyRegistry.zone ?? GLOBAL_ZONE_TOKEN;
}
}

View file

@ -1,10 +1,14 @@
{
"name": "@gxc-solutions/lib",
"version": "0.0.1",
"name": "@gxc-solutions/hotkeys",
"version": "1.0.0",
"main": "index.js",
"author": "GXC Solutions",
"publishConfig": {
"access": "public",
"registry": "https://npm.gxc-solutions.ru"
},
"peerDependencies": {
"@gxc-solutions/lett-js": "1.0.0",
"rxjs": "^7.8.2"
}
}

3
lib/src/utils/README.MD Normal file
View file

@ -0,0 +1,3 @@
# Описание
Эта директория предназначена для **непубличных** функций, т.е функций используемых внутри пакета.

View file

@ -0,0 +1,31 @@
import { getMacKeys } from ".";
import { Key } from "../constants/key";
import { MAC_SPECIAL_KEYS } from "../interfaces";
describe("getMacKeys", () => {
it("Correct 'CTRL' key transform", () => {
const result = getMacKeys([[Key.Ctrl, Key.A]]);
expect(result).toEqual([[MAC_SPECIAL_KEYS.CmdLeft as number, Key.A]]);
});
it("Correct 'DELETE' key transform", () => {
const result = getMacKeys([[Key.Delete, Key.A]]);
expect(result).toEqual([[MAC_SPECIAL_KEYS.Backspace as number, Key.A]]);
});
it("Correct 'ALT' key transform", () => {
const result = getMacKeys([[Key.Alt, Key.A]]);
expect(result).toEqual([[MAC_SPECIAL_KEYS.Option as number, Key.A]]);
});
it("Correct transform all key set", () => {
const result = getMacKeys([
[Key.Alt, Key.A],
[Key.Alt, Key.A],
]);
expect(result).toEqual([
[MAC_SPECIAL_KEYS.Option as number, Key.A],
[MAC_SPECIAL_KEYS.Option as number, Key.A],
]);
});
});

41
lib/src/utils/index.ts Normal file
View file

@ -0,0 +1,41 @@
import { Key } from "../constants/key";
import { IKeyRegistry, MAC_SPECIAL_KEYS } from "../interfaces";
/**
* Adapt keyboard keys for another platform to Mac OS
* @param keys - array describing the hotkey. For example: [Key.Ctrl, Key.A]
* @returns keyboard keys adapted to the Mac OS
*/
export const getMacKeys = (keys: Key[][]): Key[][] =>
keys.map((keySet) =>
keySet.map((key) => {
switch (key) {
case Key.Delete:
return MAC_SPECIAL_KEYS.Backspace as number;
case Key.Ctrl:
return MAC_SPECIAL_KEYS.CmdLeft as number;
case Key.Alt:
return MAC_SPECIAL_KEYS.Option as number;
default:
return key;
}
}),
);
/** Does the key match the given keys set */
export const correspondsToKeySet = (keys: Key[], keySet: Key[][]) => keySet.some((set) => set.every((key, idx) => keys[idx] === key));
/** Add package name to message */
const addPackageName = (message: string) => `[@aurigma/ng-hotkeys] ${message}`;
/** Create message for error */
export const actionExistError = <A>(action: A) =>
addPackageName(`Attempt to register a hotkey with the action '${action}'. This action already exists. Call unregisterKeys() first.`);
/** Create message for error */
export const hotkeyExistError = <A>(data: IKeyRegistry<A>, exist: IKeyRegistry<A>) =>
addPackageName(
`Attempt to register a hotkey ${JSON.stringify(data)} in to zone '${data.zone}'. This hotkey already exists as ${JSON.stringify(
exist,
)}. Call unregisterKeys() first.`,
);

28
package-lock.json generated
View file

@ -1,13 +1,17 @@
{
"name": "template-of-lib-repo",
"name": "gxc-hotkeys",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "template-of-lib-repo",
"name": "gxc-hotkeys",
"version": "0.0.0",
"license": "ISC",
"dependencies": {
"@gxc-solutions/lett-js": "1.0.0",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.4",
"@eslint/js": "^10.0.1",
@ -691,6 +695,11 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@gxc-solutions/lett-js": {
"version": "1.0.0",
"resolved": "https://npm.gxc-solutions.ru/@gxc-solutions/lett-js/-/lett-js-1.0.0.tgz",
"integrity": "sha512-9qkFVWS6ZRoMBGe4gPC9eR+6ozxIKFqm0nSbPqWywOzUJNpTQByuHHCtejTyIxrxLqyoASI8quiFtf9/UpWmow=="
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2331,6 +2340,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -2420,6 +2438,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -1,5 +1,5 @@
{
"name": "template-of-lib-repo",
"name": "gxc-hotkeys",
"version": "0.0.0",
"main": "index.js",
"scripts": {
@ -29,5 +29,9 @@
"rimraf": "^6.0.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@gxc-solutions/lett-js": "1.0.0",
"rxjs": "^7.8.2"
}
}

View file

@ -17,5 +17,5 @@
"outDir": "./dist"
},
"include": ["./lib/src/**/*"],
"exclude": ["src/**/*.test.ts"]
"exclude": ["./lib/src/**/*.test.ts", "./lib/src/**/*.spec.ts"]
}