From 7c351972cc9921e4f8851f35db48c4dfd336fde8 Mon Sep 17 00:00:00 2001 From: Andrey Kernichniy Date: Fri, 13 Mar 2026 01:00:24 +0700 Subject: [PATCH] Release package version 1.0.0 Added files --- .forgejo/workflows/release.yml | 4 +- lib/src/constants/index.ts | 2 + lib/src/constants/key-labels.ts | 205 +++++++++++++++++++++ lib/src/constants/key.ts | 127 +++++++++++++ lib/src/functions/index.spec.ts | 18 ++ lib/src/functions/index.ts | 70 ++++++++ lib/src/hotkey-manager.ts | 308 ++++++++++++++++++++++++++++++++ lib/src/index.ts | 5 + lib/src/interfaces/index.ts | 88 +++++++++ lib/src/models/key-registry.ts | 28 +++ lib/src/package.json | 8 +- lib/src/utils/README.MD | 3 + lib/src/utils/index.spec.ts | 31 ++++ lib/src/utils/index.ts | 41 +++++ package-lock.json | 28 ++- package.json | 6 +- tsconfig.json | 2 +- 17 files changed, 966 insertions(+), 8 deletions(-) create mode 100644 lib/src/constants/key-labels.ts create mode 100644 lib/src/constants/key.ts create mode 100644 lib/src/functions/index.spec.ts create mode 100644 lib/src/functions/index.ts create mode 100644 lib/src/hotkey-manager.ts create mode 100644 lib/src/models/key-registry.ts create mode 100644 lib/src/utils/README.MD create mode 100644 lib/src/utils/index.spec.ts create mode 100644 lib/src/utils/index.ts diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 6bdf090..4d90455 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -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 diff --git a/lib/src/constants/index.ts b/lib/src/constants/index.ts index e69de29..dbdff26 100644 --- a/lib/src/constants/index.ts +++ b/lib/src/constants/index.ts @@ -0,0 +1,2 @@ +/** Identifier for global keyboard zone */ +export const GLOBAL_ZONE_TOKEN = "GLOBAL"; diff --git a/lib/src/constants/key-labels.ts b/lib/src/constants/key-labels.ts new file mode 100644 index 0000000..11fd145 --- /dev/null +++ b/lib/src/constants/key-labels.ts @@ -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: "'" }, +]; diff --git a/lib/src/constants/key.ts b/lib/src/constants/key.ts new file mode 100644 index 0000000..805af05 --- /dev/null +++ b/lib/src/constants/key.ts @@ -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, +} diff --git a/lib/src/functions/index.spec.ts b/lib/src/functions/index.spec.ts new file mode 100644 index 0000000..456914e --- /dev/null +++ b/lib/src/functions/index.spec.ts @@ -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); + } + }); +}); diff --git a/lib/src/functions/index.ts b/lib/src/functions/index.ts new file mode 100644 index 0000000..6d74d15 --- /dev/null +++ b/lib/src/functions/index.ts @@ -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; +}; diff --git a/lib/src/hotkey-manager.ts b/lib/src/hotkey-manager.ts new file mode 100644 index 0000000..bb43445 --- /dev/null +++ b/lib/src/hotkey-manager.ts @@ -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 { + protected _zonesMap = new Map>>([[GLOBAL_ZONE_TOKEN, new Map()]]); + protected _events = new Subject>(); + /** Current pressed keyboard key */ + protected _currentDownKeys: Key[] = []; + /** Current pressed mouse buttons */ + protected _currentMouseDownKeys = new Set([]); + 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[], + ) { + // 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): void { + const keyRegistry = new KeyRegistry(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>(); + this._zonesMap.set(keyRegistry.zone, actionMap); + actionMap.set(keyRegistry.action, keyRegistry); + } + } + + /** + * Unregister hotkey from system + * @param keyInfo - hotkey registry data + */ + public unregisterKeys(keyInfo: IKeyRegistry): 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[]): void { + keysSet.forEach((item) => this.registryKeys(item)); + } + + /** + * Bulk hotkeys unregister + * @param keysSet - array hotkey registry data + */ + public unregisterKeysSet(keysSet: IKeyRegistry[]): 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 | 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 | 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): 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 { + let event: IKeyboardEvent; + 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 { + 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 { + 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 { + 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): boolean { + const result = this._isRegisteredHotkey(new KeyRegistry(data)); + return result.isRegistered; + } + + private _isRegisteredHotkey(data: KeyRegistry): 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; + } +} diff --git a/lib/src/index.ts b/lib/src/index.ts index e69de29..03e205e 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -0,0 +1,5 @@ +export * from "./hotkey-manager"; +export * from "./utils"; +export * from "./functions"; +export * from "./interfaces"; +export * from "./constants"; diff --git a/lib/src/interfaces/index.ts b/lib/src/interfaces/index.ts index e69de29..c132746 100644 --- a/lib/src/interfaces/index.ts +++ b/lib/src/interfaces/index.ts @@ -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 { + /** 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 { + /** 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; +} diff --git a/lib/src/models/key-registry.ts b/lib/src/models/key-registry.ts new file mode 100644 index 0000000..bce25de --- /dev/null +++ b/lib/src/models/key-registry.ts @@ -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 implements IKeyRegistry { + /** 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) { + 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; + } +} diff --git a/lib/src/package.json b/lib/src/package.json index f576691..f25d030 100644 --- a/lib/src/package.json +++ b/lib/src/package.json @@ -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" } } diff --git a/lib/src/utils/README.MD b/lib/src/utils/README.MD new file mode 100644 index 0000000..56e7d11 --- /dev/null +++ b/lib/src/utils/README.MD @@ -0,0 +1,3 @@ +# Описание + +Эта директория предназначена для **непубличных** функций, т.е функций используемых внутри пакета. diff --git a/lib/src/utils/index.spec.ts b/lib/src/utils/index.spec.ts new file mode 100644 index 0000000..e14cf4e --- /dev/null +++ b/lib/src/utils/index.spec.ts @@ -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], + ]); + }); +}); diff --git a/lib/src/utils/index.ts b/lib/src/utils/index.ts new file mode 100644 index 0000000..e70be10 --- /dev/null +++ b/lib/src/utils/index.ts @@ -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 = (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 = (data: IKeyRegistry, exist: IKeyRegistry) => + 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.`, + ); diff --git a/package-lock.json b/package-lock.json index e92ee18..b1d4103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 23fa27e..21d281d 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/tsconfig.json b/tsconfig.json index 6f67808..8663497 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "outDir": "./dist" }, "include": ["./lib/src/**/*"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["./lib/src/**/*.test.ts", "./lib/src/**/*.spec.ts"] }