@ngneat/hotkeys
Version:
A declarative library for handling hotkeys in Angular applications
375 lines (366 loc) • 21 kB
JavaScript
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: '⇧',
backspace: '⌫',
tab: '⇥',
space: '␣',
left: '←',
right: '→',
up: '↑',
down: '↓',
enter: '⌤',
};
const appleSymbols = {
meta: '⌘',
altleft: '⌥',
control: '⌃',
escape: '⎋',
};
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()\">✕</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()\">✕</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