UNPKG

@ngneat/hotkeys

Version:

A declarative library for handling hotkeys in Angular applications

375 lines (366 loc) 21 kB
import { DOCUMENT } from '@angular/common'; import * as i0 from '@angular/core'; import { signal, computed, Injectable, Inject, inject, ElementRef, input, EventEmitter, Directive, Output, Pipe, Component, Input } from '@angular/core'; import { Subject, fromEvent, of, EMPTY, Observable, merge } from 'rxjs'; import { tap, debounceTime, mergeMap, takeUntil, filter, finalize, mergeAll } from 'rxjs/operators'; import * as i1 from '@angular/platform-browser'; function coerceArray(params) { return Array.isArray(params) ? params : [params]; } function hostPlatform() { const appleDevices = ['Mac', 'iPhone', 'iPad']; return appleDevices.some((d) => navigator.userAgent.includes(d)) ? 'apple' : 'pc'; } function normalizeKeys(keys, platform) { const transformMap = { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight', }; function transform(key) { if (platform === 'pc' && key === 'meta') { key = 'control'; } if (key in transformMap) { key = transformMap[key]; } return key; } return keys .toLowerCase() .split('>') .map((s) => s.split('.').map(transform).join('.')) .join('>'); } class HotkeysService { constructor(eventManager, document) { this.eventManager = eventManager; this.document = document; this.hotkeys = new Map(); this.dispose = new Subject(); this.defaults = { trigger: 'keydown', allowIn: [], element: this.document.documentElement, group: undefined, description: undefined, showInHelpMenu: true, preventDefault: true, }; this.callbacks = []; this.sequenceMaps = new Map(); this.sequenceDebounce = 250; this._isActive = signal(true); // readonly interface for the isActive value this.isActive = computed(() => this._isActive()); } getHotkeys() { const sequenceKeys = Array.from(this.sequenceMaps.values()) .map((s) => [s.hotkeyMap].reduce((_acc, val) => [...val.values()], [])) .reduce((_x, y) => y, []) .map((h) => h.hotkey); return Array.from(this.hotkeys.values()).concat(sequenceKeys); } getShortcuts() { const hotkeys = this.getHotkeys(); const groups = []; for (const hotkey of hotkeys) { if (!hotkey.showInHelpMenu) { continue; } let group = groups.find((g) => g.group === hotkey.group); if (!group) { group = { group: hotkey.group, hotkeys: [] }; groups.push(group); } const normalizedKeys = normalizeKeys(hotkey.keys, hostPlatform()); group.hotkeys.push({ keys: normalizedKeys, description: hotkey.description }); } return groups; } addSequenceShortcut(options) { const getSequenceObserver = (element, eventName) => { let sequence = ''; return fromEvent(element, eventName).pipe(tap((e) => (sequence = `${sequence}${sequence ? '>' : ''}${e.ctrlKey ? 'control.' : ''}${e.altKey ? 'alt.' : ''}${e.shiftKey ? 'shift.' : ''}${e.key}`)), debounceTime(this.sequenceDebounce), mergeMap(() => { const resultSequence = sequence; sequence = ''; const summary = this.sequenceMaps.get(element); if (summary.hotkeyMap.has(resultSequence)) { const hotkeySummary = summary.hotkeyMap.get(resultSequence); hotkeySummary.subject.next(hotkeySummary.hotkey); return of(hotkeySummary.hotkey); } else { return EMPTY; } })); }; const mergedOptions = { ...this.defaults, ...options }; let normalizedKeys = normalizeKeys(mergedOptions.keys, hostPlatform()); const getSequenceCompleteObserver = () => { const hotkeySummary = { subject: new Subject(), hotkey: mergedOptions, }; const hotkeyElement = mergedOptions.global ? this.document.documentElement : mergedOptions.element; if (this.sequenceMaps.has(hotkeyElement)) { const sequenceSummary = this.sequenceMaps.get(hotkeyElement); if (sequenceSummary.hotkeyMap.has(normalizedKeys)) { console.error('Duplicated shortcut'); return of(null); } sequenceSummary.hotkeyMap.set(normalizedKeys, hotkeySummary); } else { const observer = getSequenceObserver(hotkeyElement, mergedOptions.trigger); const subscription = observer.subscribe(); const hotkeyMap = new Map([[normalizedKeys, hotkeySummary]]); const sequenceSummary = { subscription, observer, hotkeyMap }; this.sequenceMaps.set(hotkeyElement, sequenceSummary); } return hotkeySummary.subject.asObservable(); }; return getSequenceCompleteObserver().pipe(takeUntil(this.dispose.pipe(filter((v) => v === normalizedKeys))), filter((hotkey) => !this.targetIsExcluded(hotkey.allowIn)), filter((hotkey) => this._isActive()), tap((hotkey) => { this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element)); }), finalize(() => this.removeShortcuts(normalizedKeys))); } addShortcut(options) { const mergedOptions = { ...this.defaults, ...options }; const normalizedKeys = normalizeKeys(mergedOptions.keys, hostPlatform()); if (this.hotkeys.has(normalizedKeys)) { console.error('Duplicated shortcut'); return of(null); } this.hotkeys.set(normalizedKeys, mergedOptions); const event = `${mergedOptions.trigger}.${normalizedKeys}`; return new Observable((observer) => { const handler = (e) => { const hotkey = this.hotkeys.get(normalizedKeys); const skipShortcutTrigger = this.targetIsExcluded(hotkey.allowIn); if (skipShortcutTrigger) { return; } if (mergedOptions.preventDefault) { e.preventDefault(); } if (this._isActive()) { this.callbacks.forEach((cb) => cb(e, normalizedKeys, hotkey.element)); observer.next(e); } }; const dispose = this.eventManager.addEventListener(mergedOptions.global ? this.document.documentElement : mergedOptions.element, event, handler); return () => { this.hotkeys.delete(normalizedKeys); dispose(); }; }).pipe(filter(() => this._isActive()), takeUntil(this.dispose.pipe(filter((v) => v === normalizedKeys)))); } removeShortcuts(hotkeys) { const coercedHotkeys = coerceArray(hotkeys).map((hotkey) => normalizeKeys(hotkey, hostPlatform())); coercedHotkeys.forEach((hotkey) => { this.hotkeys.delete(hotkey); this.dispose.next(hotkey); this.sequenceMaps.forEach((v, k) => { const summary = v.hotkeyMap.get(hotkey); if (summary) { summary.subject.observers .filter((o) => !o.closed) .forEach((o) => o.unsubscribe()); v.hotkeyMap.delete(hotkey); } if (v.hotkeyMap.size === 0) { v.subscription.unsubscribe(); this.sequenceMaps.delete(k); } }); }); } setSequenceDebounce(debounce) { this.sequenceDebounce = debounce; } onShortcut(callback) { this.callbacks.push(callback); return () => (this.callbacks = this.callbacks.filter((cb) => cb !== callback)); } registerHelpModal(openHelpModalFn, helpShortcut = '') { this.addShortcut({ keys: helpShortcut || 'shift.?', showInHelpMenu: false, preventDefault: false }).subscribe((e) => { const skipMenu = /^(input|textarea|select)$/i.test(document.activeElement.nodeName) || e.target.isContentEditable; if (!skipMenu && this.hotkeys.size) { openHelpModalFn(); } }); } targetIsExcluded(allowIn) { const activeElement = this.document.activeElement; const elementName = activeElement.nodeName; const elementIsContentEditable = activeElement.isContentEditable; let isExcluded = ['INPUT', 'SELECT', 'TEXTAREA'].includes(elementName) || elementIsContentEditable; if (isExcluded && allowIn?.length) { for (let t of allowIn) { if (activeElement.nodeName === t || (t === 'CONTENTEDITABLE' && elementIsContentEditable)) { isExcluded = false; break; } } } return isExcluded; } pause() { this._isActive.set(false); } resume() { this._isActive.set(true); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysService, deps: [{ token: i1.EventManager }, { token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.EventManager }, { type: Document, decorators: [{ type: Inject, args: [DOCUMENT] }] }] }); class HotkeysDirective { constructor() { this.hotkeysService = inject(HotkeysService); this.elementRef = inject(ElementRef); this.hotkeys = input(); // allows the user to set the value by just adding the attribute to the element this.isSequence = input(false, { transform: (value) => (typeof value === 'string' ? value === '' || value === 'true' : value), }); this.isGlobal = input(false, { transform: (value) => (typeof value === 'string' ? value === '' || value === 'true' : value), }); this.hotkeysGroup = input(); this.hotkeysOptions = input({}); this.hotkeysDescription = input(); this.hotkey = new EventEmitter(); this._hotkey = computed(() => ({ keys: this.hotkeys(), group: this.hotkeysGroup(), description: this.hotkeysDescription(), global: this.isGlobal(), ...this.hotkeysOptions(), })); } ngOnChanges(changes) { this.deleteHotkeys(); if (!this.hotkeys) { return; } this.setHotkeys(this._hotkey()); } ngOnDestroy() { this.deleteHotkeys(); } deleteHotkeys() { if (this.subscription) { this.subscription.unsubscribe(); } this.subscription = null; } setHotkeys(hotkeys) { const coercedHotkeys = coerceArray(hotkeys); this.subscription = merge(coercedHotkeys.map((hotkey) => { return this.isSequence() ? this.hotkeysService.addSequenceShortcut({ ...hotkey, element: this.elementRef.nativeElement }) : this.hotkeysService.addShortcut({ ...hotkey, element: this.elementRef.nativeElement }); })) .pipe(mergeAll()) .subscribe((e) => this.hotkey.next(e)); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "17.2.4", type: HotkeysDirective, isStandalone: true, selector: "[hotkeys]", inputs: { hotkeys: { classPropertyName: "hotkeys", publicName: "hotkeys", isSignal: true, isRequired: false, transformFunction: null }, isSequence: { classPropertyName: "isSequence", publicName: "isSequence", isSignal: true, isRequired: false, transformFunction: null }, isGlobal: { classPropertyName: "isGlobal", publicName: "isGlobal", isSignal: true, isRequired: false, transformFunction: null }, hotkeysGroup: { classPropertyName: "hotkeysGroup", publicName: "hotkeysGroup", isSignal: true, isRequired: false, transformFunction: null }, hotkeysOptions: { classPropertyName: "hotkeysOptions", publicName: "hotkeysOptions", isSignal: true, isRequired: false, transformFunction: null }, hotkeysDescription: { classPropertyName: "hotkeysDescription", publicName: "hotkeysDescription", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { hotkey: "hotkey" }, usesOnChanges: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysDirective, decorators: [{ type: Directive, args: [{ standalone: true, selector: '[hotkeys]', }] }], propDecorators: { hotkey: [{ type: Output }] } }); const symbols = { shift: '&#8679;', backspace: '&#9003;', tab: '&#8677;', space: '&#9251;', left: '&#8592;', right: '&#8594;', up: '&#8593;', down: '&#8595;', enter: '&#8996;', }; const appleSymbols = { meta: '&#8984;', altleft: '&#8997;', control: '&#8963;', escape: '&#9099;', }; const pcSymbols = { control: 'Ctrl', altleft: 'Alt', escape: 'Esc', }; class HotkeysShortcutPipe { constructor() { const platform = hostPlatform(); this.symbols = this.getPlatformSymbols(platform); } transform(value, dotSeparator = ' + ', thenSeparator = ' then ', aliases = {}) { if (!value) { return ''; } return value .split('>') .map((s) => s .split('.') .map((c) => c.toLowerCase()) .map((c) => aliases[c] || this.symbols[c] || c) .join(dotSeparator)) .join(thenSeparator); } getPlatformSymbols(platform) { return platform === 'apple' ? { ...symbols, ...appleSymbols } : { ...symbols, ...pcSymbols }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysShortcutPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "17.2.4", ngImport: i0, type: HotkeysShortcutPipe, isStandalone: true, name: "hotkeysShortcut" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysShortcutPipe, decorators: [{ type: Pipe, args: [{ standalone: true, name: 'hotkeysShortcut', }] }], ctorParameters: () => [] }); class HotkeysHelpComponent { constructor() { this.hotkeysService = inject(HotkeysService); this.title = 'Available Shortcuts'; this.dismiss = new EventEmitter(); this.hotkeys = this.hotkeysService.getShortcuts(); } handleDismiss() { this.dismiss.emit(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysHelpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.2.4", type: HotkeysHelpComponent, isStandalone: true, selector: "ng-component", inputs: { title: "title" }, outputs: { dismiss: "dismiss" }, ngImport: i0, template: "<div class=\"modal-header\">\n @if (title) {\n <div class=\"hotkeys-help-header\">\n <span class=\"hotkeys-help-header-title\">{{ title }}</span>\n </div>\n }\n <button type=\"button\" class=\"hotkeys-help-header-dismiss-button\" (click)=\"handleDismiss()\">&#x2715;</button>\n</div>\n<div class=\"modal-body preview-modal-body\">\n @for (hotkeyGroup of hotkeys; track $index) {\n <table class=\"hotkeys-table-help\">\n @if (hotkeyGroup.group) {\n <thead>\n <tr>\n <th class=\"hotkeys-table-help-group\" colspan=\"2\">{{ hotkeyGroup.group }}</th>\n </tr>\n </thead>\n }\n <tbody>\n @for (hotkey of hotkeyGroup.hotkeys; track hotkey) {\n <tr class=\"hotkeys-table-help-shortcut\">\n <td class=\"hotkeys-table-help-shortcut-description\">{{ hotkey.description }}</td>\n <td class=\"hotkeys-table-help-shortcut-keys\">\n <kbd [innerHTML]=\"hotkey.keys | hotkeysShortcut\"></kbd>\n </td>\n </tr>\n }\n </tbody>\n </table>\n }\n</div>\n", styles: [":host table{border:1px solid #e1e4e8;border-collapse:collapse;width:100%;margin-bottom:1rem;color:#212529}:host th{background-color:#f6f8fa;border-top-left-radius:2px;border-top-right-radius:2px;border:1px solid #d1d5da;font-weight:500;font-size:14px;padding:8px 16px;border-bottom:0;text-align:left}:host td{padding:8px 16px;border-top:1px solid #dee2e6}:host kbd{margin-right:6px;background-color:#fafbfc;border:1px solid #d1d5da;border-bottom-color:#c6cbd1;border-radius:3px;box-shadow:inset 0 -1px #c6cbd1;color:#444d56;font-size:12px;padding:3px 5px}:host .hotkeys-help-shortcut-keys{text-align:right}:host .modal-header{justify-content:space-between;align-items:center}:host .hotkeys-help-header{font-size:1.25em}:host .hotkeys-help-header-title{line-height:1.5}:host .hotkeys-help-header-dismiss-button{border:none;font-size:18px;background:transparent;cursor:pointer}:host .hotkeys-help-header-dismiss-button:focus{outline:none}\n"], dependencies: [{ kind: "pipe", type: HotkeysShortcutPipe, name: "hotkeysShortcut" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.2.4", ngImport: i0, type: HotkeysHelpComponent, decorators: [{ type: Component, args: [{ standalone: true, imports: [HotkeysShortcutPipe], template: "<div class=\"modal-header\">\n @if (title) {\n <div class=\"hotkeys-help-header\">\n <span class=\"hotkeys-help-header-title\">{{ title }}</span>\n </div>\n }\n <button type=\"button\" class=\"hotkeys-help-header-dismiss-button\" (click)=\"handleDismiss()\">&#x2715;</button>\n</div>\n<div class=\"modal-body preview-modal-body\">\n @for (hotkeyGroup of hotkeys; track $index) {\n <table class=\"hotkeys-table-help\">\n @if (hotkeyGroup.group) {\n <thead>\n <tr>\n <th class=\"hotkeys-table-help-group\" colspan=\"2\">{{ hotkeyGroup.group }}</th>\n </tr>\n </thead>\n }\n <tbody>\n @for (hotkey of hotkeyGroup.hotkeys; track hotkey) {\n <tr class=\"hotkeys-table-help-shortcut\">\n <td class=\"hotkeys-table-help-shortcut-description\">{{ hotkey.description }}</td>\n <td class=\"hotkeys-table-help-shortcut-keys\">\n <kbd [innerHTML]=\"hotkey.keys | hotkeysShortcut\"></kbd>\n </td>\n </tr>\n }\n </tbody>\n </table>\n }\n</div>\n", styles: [":host table{border:1px solid #e1e4e8;border-collapse:collapse;width:100%;margin-bottom:1rem;color:#212529}:host th{background-color:#f6f8fa;border-top-left-radius:2px;border-top-right-radius:2px;border:1px solid #d1d5da;font-weight:500;font-size:14px;padding:8px 16px;border-bottom:0;text-align:left}:host td{padding:8px 16px;border-top:1px solid #dee2e6}:host kbd{margin-right:6px;background-color:#fafbfc;border:1px solid #d1d5da;border-bottom-color:#c6cbd1;border-radius:3px;box-shadow:inset 0 -1px #c6cbd1;color:#444d56;font-size:12px;padding:3px 5px}:host .hotkeys-help-shortcut-keys{text-align:right}:host .modal-header{justify-content:space-between;align-items:center}:host .hotkeys-help-header{font-size:1.25em}:host .hotkeys-help-header-title{line-height:1.5}:host .hotkeys-help-header-dismiss-button{border:none;font-size:18px;background:transparent;cursor:pointer}:host .hotkeys-help-header-dismiss-button:focus{outline:none}\n"] }] }], propDecorators: { title: [{ type: Input }], dismiss: [{ type: Output }] } }); /** * Generated bundle index. Do not edit. */ export { HotkeysDirective, HotkeysHelpComponent, HotkeysService, HotkeysShortcutPipe }; //# sourceMappingURL=ngneat-hotkeys.mjs.map