UNPKG

ng-hotkeys

Version:

ng-hotkeys for Angular 14+

1,411 lines (1,399 loc) 62.6 kB
import * as i0 from '@angular/core'; import { Injectable, Inject, Component, Input, Directive, TemplateRef, ViewChild, NgModule } from '@angular/core'; import { Subject, BehaviorSubject, fromEvent, timer, throwError, of } from 'rxjs'; import { filter, switchMap, first, tap, repeat, map, throttle, takeUntil, catchError, scan, distinctUntilChanged } from 'rxjs/operators'; import * as i1 from '@angular/common'; import { DOCUMENT, CommonModule } from '@angular/common'; import { trigger, transition, style, animate } from '@angular/animations'; function isFunction(x) { return typeof x === "function"; } const invert = obj => { const new_obj = {}; for (const prop in obj) { if (obj.hasOwnProperty(prop)) { new_obj[obj[prop]] = prop; } } return new_obj; }; const any = (fn, list) => { let idx = 0; while (idx < list.length) { if (fn(list[idx])) { return true; } idx += 1; } return false; }; const identity = x => x; /** * @ignore * @param x * @returns boolean */ const isNill = x => x == null; /** * @ignore * @param xs * @param key * @returns any */ const groupBy = (xs, key) => xs.reduce((result, x) => (Object.assign(Object.assign({}, result), { [x[key]]: [...(result[x[key]] || []), x] })), {}); /** * @ignore * @param first * @param second * @returns any[] */ const difference = (first, second) => first.filter(item => !second.includes(item)); /** * @ignore * @param preds * @returns (...args) => boolean; */ const allPass = preds => (...args) => { let idx = 0; const len = preds.length; while (idx < len) { if (!preds[idx].apply(this, args)) { return false; } idx += 1; } return true; }; const prop = prop => object => object[prop]; const minMaxArrayProp = type => (property, array) => Math[type].apply(Math, array.map(prop(property))); const maxArrayProp = (property, array) => { return array.reduce((acc, curr) => { const propFn = prop(property); const currentValue = propFn(curr); const previousValue = propFn(acc); return currentValue > previousValue ? curr : acc; }, { [property]: 0 }); }; const isMac = typeof navigator !== "undefined" ? navigator.userAgent.includes("Mac OS") : false; const modifiers = { shift: "shiftKey", ctrl: "ctrlKey", alt: "altKey", cmd: isMac ? "metaKey" : "ctrlKey", command: isMac ? "metaKey" : "ctrlKey", meta: isMac ? "metaKey" : "ctrlKey", "left command": "metaKey", "right command": "MetaRight", "⌘": isMac ? "metaKey" : "ctrlKey", option: "altKey", ctl: "ctrlKey", control: "ctrlKey" }; const _SPECIAL_CASES = { plus: "+" }; const symbols = { cmd: isMac ? "⌘" : "Ctrl", command: isMac ? "⌘" : "Ctrl", "left command": isMac ? "⌘" : "Ctrl", "right command": isMac ? "⌘" : "Ctrl", option: isMac ? "⌥" : "Alt", plus: "+", left: "←", right: "→", up: "↑", down: "↓", alt: isMac ? "⌥" : "Alt", ctrl: "Ctrl", control: "Ctrl", shift: "⇧" }; const _MAP = { 8: "backspace", 9: "tab", 13: "enter", 16: "shift", 17: ["ctrl", "control"], 18: "alt", 20: "capslock", 27: ["esc", "escape"], 32: ["space", "spc"], 33: "pageup", 34: "pagedown", 35: "end", 36: "home", 37: "left", 38: "up", 39: "right", 40: "down", 45: "ins", 46: "del", 91: ["meta", "cmd", "command"], 93: ["meta", "cmd", "command"], 224: ["meta", "cmd", "command"] }; /* * mapping for special characters so they can support * * this dictionary is only used incase you want to bind a * keyup or keydown event to one of these keys * */ const _KEYCODE_MAP = { 106: "*", 107: "+", 109: "-", 110: ".", 111: "/", 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", 192: "`", 219: "[", 220: "\\", 221: "]", 222: "'" }; /** * this is a mapping of keys that require shift on a US keypad * back to the non shift equivelents * * this is so you can use keyup events with these keys * * note that this will only work reliably on US keyboards * */ const _SHIFT_MAP = { "`": "~", "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", "-": "_", "=": "+", ";": ":", "'": '"', ",": "<", ".": ">", "/": "?", "\\": "|" }; const _INVERTED_SHIFT_MAP = invert(_SHIFT_MAP); /** * loop through the f keys, f1 to f19 and add them to the map * programatically */ for (let i = 1; i < 20; ++i) { _MAP[111 + i] = "f" + i; } /** * loop through to map numbers on the numeric keypad */ for (let i = 0; i <= 9; ++i) { // This needs to use a string cause otherwise since 0 is falsey // event will never fire for numpad 0 pressed as part of a keydown // event. _MAP[i + 96] = i.toString(); } /** * @ignore * @type {number} */ let guid = 0; class NgHotkeysService { /** * @ignore */ constructor(document) { this.document = document; /** * Parsed shortcuts * for each key create a predicate function */ this._shortcuts = []; this._sequences = []; /** * Throttle the keypress event. */ this.throttleTime = 0; this._pressed = new Subject(); /** * Streams of pressed events, can be used instead or with a command. */ this.pressed$ = this._pressed.asObservable(); /** * Disable all keyboard shortcuts */ this.disabled = false; this._shortcutsSub = new BehaviorSubject([]); this.shortcuts$ = this._shortcutsSub .asObservable() .pipe(filter(shortcuts => !!shortcuts.length)); this._ignored = ["INPUT", "TEXTAREA", "SELECT"]; /** * @ignore * Subscription for on destroy. */ this.subscriptions = []; /** * @ignore * @param shortcut */ this.isAllowed = (shortcut) => { const target = shortcut.event.target; if (target === shortcut.target) { return true; } if (shortcut.allowIn.length) { return !difference(this._ignored, shortcut.allowIn).includes(target.nodeName); } return !this._ignored.includes(target.nodeName); }; /** * @ignore * @param event */ this.mapEvent = event => { return this._shortcuts .filter(shortcut => !shortcut.isSequence) .map(shortcut => Object.assign({}, shortcut, { predicates: any(identity, shortcut.predicates.map((predicates) => allPass(predicates)(event))), event: event })) .filter(shortcut => shortcut.predicates) .reduce((acc, shortcut) => (acc.priority > shortcut.priority ? acc : shortcut), { priority: 0 }); }; this.keydown$ = fromEvent(this.document, "keydown"); /** * fixes for firefox prevent default * on click event on button focus: * see issue: * keeping this here for now, but it is commented out * Firefox reference bug: * https://bugzilla.mozilla.org/show_bug.cgi?id=1487102 * and my repo: * * https://github.com/omridevk/ng-keyboard-shortcuts/issues/35 */ this.ignore$ = this.pressed$.pipe(filter(e => e.event.defaultPrevented), switchMap(() => this.clicks$.pipe(first())), tap((e) => { e.preventDefault(); e.stopPropagation(); }), repeat()); /** * @ignore */ this.clicks$ = fromEvent(this.document, "click", { capture: true }); this.keyup$ = fromEvent(this.document, "keyup"); /** * @ignore */ this.keydownCombo$ = this.keydown$.pipe(filter(_ => !this.disabled), map(this.mapEvent), filter((shortcut) => !shortcut.target || shortcut.event.target === shortcut.target), filter((shortcut) => isFunction(shortcut.command)), filter(this.isAllowed), tap(shortcut => { if (!shortcut.preventDefault) { return; } shortcut.event.preventDefault(); shortcut.event.stopPropagation(); }), throttle(shortcut => timer(shortcut.throttleTime)), tap(shortcut => shortcut.command({ event: shortcut.event, key: shortcut.key })), tap(shortcut => this._pressed.next({ event: shortcut.event, key: shortcut.key })), takeUntil(this.keyup$), repeat(), catchError(error => throwError(error))); /** * @ignore */ this.timer$ = new Subject(); /** * @ignore */ this.resetCounter$ = this.timer$ .asObservable() .pipe(switchMap(() => timer(NgHotkeysService.TIMEOUT_SEQUENCE))); /** * @ignore */ this.keydownSequence$ = this.shortcuts$.pipe(map(shortcuts => shortcuts.filter(shortcut => shortcut.isSequence)), switchMap(sequences => this.keydown$.pipe(map(event => { return { event, sequences }; }), tap(val => this.timer$.next(val)))), scan((acc, arg) => { let { event } = arg; const currentLength = acc.events.length; const sequences = currentLength ? acc.sequences : arg.sequences; let [characters] = this.characterFromEvent(event); characters = Array.isArray(characters) ? [...characters, event.key] : [characters, event.key]; const result = sequences .map(sequence => { const sequences = sequence.sequence.filter(seque => characters.some(key => (_SPECIAL_CASES[seque[currentLength]] || seque[currentLength]) === key)); const partialMatch = sequences.length > 0; if (sequence.fullMatch) { return sequence; } return Object.assign(Object.assign({}, sequence), { sequence: sequences, partialMatch, event: event, fullMatch: partialMatch && this.isFullMatch({ command: sequence, events: acc.events }) }); }) .filter(sequences => sequences.partialMatch || sequences.fullMatch); let [match] = result; if (!match || this.modifiersOn(event)) { return { events: [], sequences: this._sequences }; } /* * handle case of "?" sequence and "? a" sequence * need to determine which one to trigger. * if both match, we pick the longer one (? a) in this case. */ const guess = maxArrayProp("priority", result); if (result.length > 1 && guess.fullMatch) { return { events: [], command: guess, sequences: this._sequences }; } if (result.length > 1) { return { events: [...acc.events, event], command: result, sequences: result }; } if (match.fullMatch) { return { events: [], command: match, sequences: this._sequences }; } return { events: [...acc.events, event], command: result, sequences: result }; }, { events: [], sequences: [] }), switchMap(({ command }) => { if (Array.isArray(command)) { /* * Add a timer to handle the case where for example: * a sequence "?" is registered and "? a" is registered as well * if the user does not hit any key for 500ms, the single sequence will trigger * if any keydown event occur, this timer will reset, given a chance to complete * the full sequence (? a) in this case. * This delay only occurs when single key sequence is the beginning of another sequence. */ return timer(500).pipe(map(() => ({ command: command.filter(command => command.fullMatch)[0] }))); } return of({ command }); }), takeUntil(this.pressed$), filter(({ command }) => command && command.fullMatch), map(({ command }) => command), filter((shortcut) => isFunction(shortcut.command)), filter((shortcut) => !shortcut.target || shortcut.event.target === shortcut.target), filter(this.isAllowed), tap(shortcut => !shortcut.preventDefault || shortcut.event.preventDefault()), throttle(shortcut => timer(shortcut.throttleTime)), tap(shortcut => shortcut.command({ event: shortcut.event, key: shortcut.key })), tap(shortcut => this._pressed.next({ event: shortcut.event, key: shortcut.key })), takeUntil(this.resetCounter$), repeat()); /** * @ignore * transforms a shortcut to: * a predicate function */ this.getKeys = (keys) => { return keys .map(key => key.trim()) .filter(key => key !== "+") .map(key => { // for modifiers like control key // look for event['ctrlKey'] // otherwise use the keyCode key = _SPECIAL_CASES[key] || key; if (modifiers.hasOwnProperty(key)) { return event => { return !!event[modifiers[key]]; }; } return event => { const isUpper = key === key.toUpperCase(); const isNonAlpha = (/[^a-zA-Z\d\s:]/).test(key); const inShiftMap = _INVERTED_SHIFT_MAP[key]; let [characters, shiftKey] = this.characterFromEvent(event); const allModifiers = Object.keys(modifiers).map((key) => { return modifiers[key]; }); const hasModifiers = allModifiers.some(modifier => event[modifier]); characters = Array.isArray(characters) ? [...characters, event.key] : [characters, event.key]; // if has modifiers: // we want to make sure it is not upper case letters // (because upper has modifiers so we want continue the check) // we also want to make sure it is not alphanumeric char like ? / ^ & and others (since those could require modifiers as well) // we also want to check this only if the length of // of the keys is one (i.e the command key is "?" or "c" // this while check is here to verify that: // if registered key like "e" // it won't be fired when clicking ctrl + e, or any modifiers + the key // we only want to trigger when the single key is clicked alone // thus all these edge cases. // hopefully this would cover all cases // TODO:: find a way simplify this if (hasModifiers && (!isUpper || isNonAlpha) && !inShiftMap && keys.length === 1) { return false; } return characters.some(char => { if (char === key && isUpper) { return true; } return key === char; }); }; }); }; this.subscriptions.push(this.keydownSequence$.subscribe(), this.keydownCombo$.subscribe() // this.ignore$.subscribe() ); } /** * @ignore * @param command * @param events */ isFullMatch({ command, events }) { if (!command) { return false; } return command.sequence.some(sequence => { return sequence.length === events.length + 1; }); } /** * @ignore */ get shortcuts() { return this._shortcuts; } /** * @ignore * @param event */ _characterFromEvent(event) { if (typeof event.which !== "number") { event.which = event.keyCode; } if (_SPECIAL_CASES[event.which]) { return [_SPECIAL_CASES[event.which], event.shiftKey]; } if (_MAP[event.which]) { // for non keypress events the special maps are needed return [_MAP[event.which], event.shiftKey]; } if (_KEYCODE_MAP[event.which]) { return [_KEYCODE_MAP[event.which], event.shiftKey]; } // in case event key is lower case but registered key is upper case // return it in the lower case if (String.fromCharCode(event.which).toLowerCase() !== event.key) { return [String.fromCharCode(event.which).toLowerCase(), event.shiftKey]; } return [event.key, event.shiftKey]; } characterFromEvent(event) { let [key, shiftKey] = this._characterFromEvent(event); if (shiftKey && _SHIFT_MAP[key]) { return [_SHIFT_MAP[key], shiftKey]; } return [key, shiftKey]; } /** * @ignore * Remove subscription. */ ngOnDestroy() { this.subscriptions.forEach(sub => sub.unsubscribe()); } /** * @ignore * @param shortcuts */ isSequence(shortcuts) { return !shortcuts.some(shortcut => shortcut.includes("+") || shortcut.length === 1); } /** * Add new shortcut/s */ add(shortcuts) { shortcuts = Array.isArray(shortcuts) ? shortcuts : [shortcuts]; const commands = this.parseCommand(shortcuts); commands.forEach(command => { if (command.isSequence) { this._sequences.push(command); return; } this._shortcuts.push(command); }); setTimeout(() => { this._shortcutsSub.next([...this._shortcuts, ...this._sequences]); }); return commands.map(command => command.id); } /** * Remove a command based on key or array of keys. * can be used for cleanup. * @returns * @param ids */ remove(ids) { ids = Array.isArray(ids) ? ids : [ids]; this._shortcuts = this._shortcuts.filter(shortcut => !ids.includes(shortcut.id)); this._sequences = this._sequences.filter(shortcut => !ids.includes(shortcut.id)); setTimeout(() => { this._shortcutsSub.next([...this._shortcuts, ...this._sequences]); }); return this; } /** * Returns an observable of keyboard shortcut filtered by a specific key. * @param key - the key to filter the observable by. */ select(key) { return this.pressed$.pipe(filter(({ event, key: eventKeys }) => { eventKeys = Array.isArray(eventKeys) ? eventKeys : [eventKeys]; return !!eventKeys.find(eventKey => eventKey === key); })); } /** * @ignore * @param event */ modifiersOn(event) { return ["metaKey", "altKey", "ctrlKey"].some(mod => event[mod]); } /** * @ignore * Parse each command using getKeys function */ parseCommand(command) { const commands = Array.isArray(command) ? command : [command]; return commands.map(command => { const keys = Array.isArray(command.key) ? command.key : [command.key]; const priority = Math.max(...keys.map(key => key.split(" ").filter(identity).length)); const predicates = keys.map(key => this.getKeys(key.split(" ").filter(identity))); const isSequence = this.isSequence(keys); const sequence = isSequence ? keys.map(key => key .split(" ") .filter(identity) .map(key => key.trim())) : []; return Object.assign(Object.assign({}, command), { isSequence, sequence: isSequence ? sequence : [], allowIn: command.allowIn || [], key: keys, id: `${guid++}`, throttle: isNill(command.throttleTime) ? this.throttleTime : command.throttleTime, priority: priority, predicates: predicates }); }); } } /** * @ignore * 2000 ms window to allow between key sequences otherwise * the sequence will reset. */ NgHotkeysService.TIMEOUT_SEQUENCE = 1000; NgHotkeysService.ɵfac = function NgHotkeysService_Factory(t) { return new (t || NgHotkeysService)(i0.ɵɵinject(DOCUMENT)); }; NgHotkeysService.ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: NgHotkeysService, factory: NgHotkeysService.ɵfac, providedIn: "root" }); (function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(NgHotkeysService, [{ type: Injectable, args: [{ providedIn: "root" }] }], function () { return [{ type: undefined, decorators: [{ type: Inject, args: [DOCUMENT] }] }]; }, null); })(); /** * A component to bind global shortcuts, can be used multiple times across the app * will remove registered shortcuts when element is removed from DOM. */ class NgHotkeysComponent { /** * @ignore * @param {NgHotkeysService} keyboard */ constructor(keyboard) { this.keyboard = keyboard; /** * A list of shortcuts. */ this.shortcuts = []; /** * @ignore * list of registered keyboard shortcuts * used for clean up on NgDestroy. */ this.clearIds = []; /** * @ignore */ this._disabled = false; } /** * Disable all shortcuts for this component. */ set disabled(value) { this._disabled = value; if (this.clearIds) { this.keyboard.remove(this.clearIds); this.clearIds = []; } if (value) { return; } this.clearIds = this.keyboard.add(this.shortcuts); } /** * @ignore */ ngOnInit() { } /** * Select a key to listen to, will emit when the selected key is pressed. */ select(key) { return this.keyboard.select(key); } /** * @ignore */ ngAfterViewInit() { } /** * @ignore */ ngOnChanges(changes) { if (!changes.shortcuts || !changes.shortcuts.currentValue) { return; } if (this.clearIds) { this.keyboard.remove(this.clearIds); } if (!this._disabled) { setTimeout(() => (this.clearIds = this.keyboard.add(changes.shortcuts.currentValue))); } } /** * @ignore */ ngOnDestroy() { this.keyboard.remove(this.clearIds); } } NgHotkeysComponent.ɵfac = function NgHotkeysComponent_Factory(t) { return new (t || NgHotkeysComponent)(i0.ɵɵdirectiveInject(NgHotkeysService)); }; NgHotkeysComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: NgHotkeysComponent, selectors: [["ng-hot-keys"]], inputs: { shortcuts: "shortcuts", disabled: "disabled" }, features: [i0.ɵɵNgOnChangesFeature], decls: 0, vars: 0, template: function NgHotkeysComponent_Template(rf, ctx) { }, encapsulation: 2 }); (function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(NgHotkeysComponent, [{ type: Component, args: [{ selector: "ng-hot-keys", template: "" }] }], function () { return [{ type: NgHotkeysService }]; }, { shortcuts: [{ type: Input }], disabled: [{ type: Input }] }); })(); /** * Service to assist showing custom help screen */ class NgHotkeysHelpService { /** * @ignore * @param {NgHotkeysService} keyboard */ constructor(keyboard) { this.keyboard = keyboard; /** * Observable to provide access to all registered shortcuts in the app. * @type {Observable<any>} */ this.shortcuts$ = this.keyboard.shortcuts$.pipe(map(shortcuts => shortcuts .filter(shortcut => Boolean(shortcut.label) && Boolean(shortcut.description)) .map(({ key, label, description }) => ({ key, label, description })))); } } NgHotkeysHelpService.ɵfac = function NgHotkeysHelpService_Factory(t) { return new (t || NgHotkeysHelpService)(i0.ɵɵinject(NgHotkeysService)); }; NgHotkeysHelpService.ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: NgHotkeysHelpService, factory: NgHotkeysHelpService.ɵfac, providedIn: "root" }); (function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(NgHotkeysHelpService, [{ type: Injectable, args: [{ providedIn: "root" }] }], function () { return [{ type: NgHotkeysService }]; }, null); })(); /** * Use this service to listen to a specific keyboards events using Rxjs. * The shortcut must be declared in the app for the select to work. */ class NgHotkeysSelectService { constructor(keyboardService) { this.keyboardService = keyboardService; } /** * Returns an observable of keyboard shortcut filtered by a specific key. * @param key - the key to filter the observable by. */ select(key) { return this.keyboardService.select(key); } } NgHotkeysSelectService.ɵfac = function NgHotkeysSelectService_Factory(t) { return new (t || NgHotkeysSelectService)(i0.ɵɵinject(NgHotkeysService)); }; NgHotkeysSelectService.ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: NgHotkeysSelectService, factory: NgHotkeysSelectService.ɵfac, providedIn: "root" }); (function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(NgHotkeysSelectService, [{ type: Injectable, args: [{ providedIn: "root" }] }], function () { return [{ type: NgHotkeysService }]; }, null); })(); var AllowIn; (function (AllowIn) { AllowIn["Textarea"] = "TEXTAREA"; AllowIn["Input"] = "INPUT"; AllowIn["Select"] = "SELECT"; })(AllowIn || (AllowIn = {})); /** * A directive to be used with "focusable" elements, like: * textarea, input, select. */ class NgHotkeysDirective { /** * @ignore * @param {NgHotkeysService} keyboard * @param {ElementRef} el */ constructor(keyboard, el) { this.keyboard = keyboard; this.el = el; /** * @ignore * @type {boolean} * @private */ this._disabled = false; } /** * whether to disable the shortcuts for this directive * @param value */ set disabled(value) { this._disabled = value; if (this.clearIds) { this.keyboard.remove(this.clearIds); } setTimeout(() => { if (value === false && this.ngHotKeys) { this.clearIds = this.keyboard.add(this.transformInput(this.ngHotKeys)); } }); } /** * @ignore * @param {Shortcut[]} shortcuts * @returns {any} */ transformInput(shortcuts) { return shortcuts.map(shortcut => (Object.assign(Object.assign({}, shortcut), { target: this.el.nativeElement, allowIn: [AllowIn.Select, AllowIn.Input, AllowIn.Textarea] }))); } /** * @ignore */ ngOnDestroy() { if (!this.clearIds) { return; } this.keyboard.remove(this.clearIds); } /** * @ignore * @param {SimpleChanges} changes */ ngOnChanges(changes) { const { ngKeyboardShortcuts } = changes; if (this.clearIds) { this.keyboard.remove(this.clearIds); } if (!ngKeyboardShortcuts || !ngKeyboardShortcuts.currentValue) { return; } this.clearIds = this.keyboard.add(this.transformInput(ngKeyboardShortcuts.currentValue)); } } NgHotkeysDirective.ɵfac = function NgHotkeysDirective_Factory(t) { return new (t || NgHotkeysDirective)(i0.ɵɵdirectiveInject(NgHotkeysService), i0.ɵɵdirectiveInject(i0.ElementRef)); }; NgHotkeysDirective.ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({ type: NgHotkeysDirective, selectors: [["", "ngHotKeys", ""]], inputs: { ngHotKeys: "ngHotKeys", disabled: "disabled" }, features: [i0.ɵɵNgOnChangesFeature] }); (function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(NgHotkeysDirective, [{ type: Directive, args: [{ selector: "[ngHotKeys]" }] }], function () { return [{ type: NgHotkeysService }, { type: i0.ElementRef }]; }, { ngHotKeys: [{ type: Input }], disabled: [{ type: Input }] }); })(); /** * @ignore * A `Portal` is something that you want to render somewhere else. * It can be attach to / detached from a `PortalOutlet`. */ class Portal { /** Attach this portal to a host. */ attach(host) { if (host == null) { // TODO: add error console.error("null portal error"); } if (host.hasAttached()) { console.error("portal already attached"); // throwPortalAlreadyAttachedError(); } this._attachedHost = host; return host.attach(this); } /** Detach this portal from its host */ detach() { let host = this._attachedHost; if (host == null) { console.error("no portal attached!"); // throwNoPortalAttachedError(); } else { this._attachedHost = null; host.detach(); } } /** Whether this portal is attached to a host. */ get isAttached() { return this._attachedHost != null; } /** * Sets the PortalOutlet reference without performing `attach()`. This is used directly by * the PortalOutlet when it is performing an `attach()` or `detach()`. */ setAttachedHost(host) { this._attachedHost = host; } } /** * @ignore * A `ComponentPortal` is a portal that instantiates some Component upon attachment. */ class ComponentPortal extends Portal { constructor(component, viewContainerRef, injector, componentFactoryResolver) { super(); this.component = component; this.viewContainerRef = viewContainerRef; this.injector = injector; this.componentFactoryResolver = componentFactoryResolver; } } /** * @ignore * A `TemplatePortal` is a portal that represents some embedded template (TemplateRef). */ class TemplatePortal extends Portal { constructor(template, viewContainerRef, context) { super(); this.templateRef = template; this.viewContainerRef = viewContainerRef; this.context = context; } get origin() { return this.templateRef.elementRef; } /** * Attach the portal to the provided `PortalOutlet`. * When a context is provided it will override the `context` property of the `TemplatePortal` * instance. */ attach(host, context = this.context) { this.context = context; return super.attach(host); } detach() { this.context = undefined; return super.detach(); } } /** * @ignore * Partial implementation of PortalOutlet that handles attaching * ComponentPortal and TemplatePortal. */ class BasePortalOutlet { constructor() { /** Whether this host has already been permanently disposed. */ this._isDisposed = false; } /** Whether this host has an attached portal. */ hasAttached() { return !!this._attachedPortal; } /** Attaches a portal. */ attach(portal) { if (!portal) { console.error('null portal!'); // throwNullPortalError(); } if (this.hasAttached()) { console.error('portal already attached'); // throwPortalAlreadyAttachedError(); } if (this._isDisposed) { console.error('portal out already disposed'); // throwPortalOutletAlreadyDisposedError(); } if (portal instanceof ComponentPortal) { this._attachedPortal = portal; return this.attachComponentPortal(portal); } else if (portal instanceof TemplatePortal) { this._attachedPortal = portal; return this.attachTemplatePortal(portal); } console.error('unknown portal type'); // throwUnknownPortalTypeError(); } /** Detaches a previously attached portal. */ detach() { if (this._attachedPortal) { this._attachedPortal.setAttachedHost(null); this._attachedPortal = null; } this._invokeDisposeFn(); } /** Permanently dispose of this portal host. */ dispose() { if (this.hasAttached()) { this.detach(); } this._invokeDisposeFn(); this._isDisposed = true; } /** @docs-private */ setDisposeFn(fn) { this._disposeFn = fn; } _invokeDisposeFn() { if (this._disposeFn) { this._disposeFn(); this._disposeFn = null; } } } /** * @ignore * A PortalOutlet for attaching portals to an arbitrary DOM element outside of the Angular * application context. */ class DomPortalOutlet extends BasePortalOutlet { constructor( /** Element into which the content is projected. */ outletElement, _componentFactoryResolver, _appRef, _defaultInjector) { super(); this.outletElement = outletElement; this._componentFactoryResolver = _componentFactoryResolver; this._appRef = _appRef; this._defaultInjector = _defaultInjector; } /** * Attach the given ComponentPortal to DOM element using the ComponentFactoryResolver. * @param portal Portal to be attached * @returns Reference to the created component. */ attachComponentPortal(portal) { const resolver = portal.componentFactoryResolver || this._componentFactoryResolver; const componentFactory = resolver.resolveComponentFactory(portal.component); let componentRef; // If the portal specifies a ViewContainerRef, we will use that as the attachment point // for the component (in terms of Angular's component tree, not rendering). // When the ViewContainerRef is missing, we use the factory to create the component directly // and then manually attach the view to the application. if (portal.viewContainerRef) { componentRef = portal.viewContainerRef.createComponent(componentFactory, portal.viewContainerRef.length, portal.injector || portal.viewContainerRef.injector); this.setDisposeFn(() => componentRef.destroy()); } else { componentRef = componentFactory.create(portal.injector || this._defaultInjector); this._appRef.attachView(componentRef.hostView); this.setDisposeFn(() => { this._appRef.detachView(componentRef.hostView); componentRef.destroy(); }); } // At this point the component has been instantiated, so we move it to the location in the DOM // where we want it to be rendered. this.outletElement.appendChild(this._getComponentRootNode(componentRef)); return componentRef; } /** * Attaches a template portal to the DOM as an embedded view. * @param portal Portal to be attached. * @returns Reference to the created embedded view. */ attachTemplatePortal(portal) { let viewContainer = portal.viewContainerRef; let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context); viewRef.detectChanges(); // The method `createEmbeddedView` will add the view as a child of the viewContainer. // But for the DomPortalOutlet the view can be added everywhere in the DOM // (e.g Overlay Container) To move the view to the specified host element. We just // re-append the existing root nodes. viewRef.rootNodes.forEach(rootNode => this.outletElement.appendChild(rootNode)); this.setDisposeFn((() => { let index = viewContainer.indexOf(viewRef); if (index !== -1) { viewContainer.remove(index); } })); return viewRef; } /** * Clears out a portal from the DOM. */ dispose() { super.dispose(); if (this.outletElement.parentNode != null) { this.outletElement.parentNode.removeChild(this.outletElement); } } /** Gets the root HTMLElement for an instantiated component. */ _getComponentRootNode(componentRef) { return componentRef.hostView.rootNodes[0]; } } function NgHotkeysHelpItemComponent_div_0_div_5_span_1_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "span", 8); i0.ɵɵtext(1); i0.ɵɵelementEnd(); } if (rf & 2) { const key_r6 = ctx.$implicit; i0.ɵɵadvance(1); i0.ɵɵtextInterpolate(key_r6); } } function NgHotkeysHelpItemComponent_div_0_div_5_span_2_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "span", 9); i0.ɵɵtext(1, " / "); i0.ɵɵelementEnd(); } } function NgHotkeysHelpItemComponent_div_0_div_5_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "div", 5); i0.ɵɵtemplate(1, NgHotkeysHelpItemComponent_div_0_div_5_span_1_Template, 2, 1, "span", 6); i0.ɵɵtemplate(2, NgHotkeysHelpItemComponent_div_0_div_5_span_2_Template, 2, 0, "span", 7); i0.ɵɵelementEnd(); } if (rf & 2) { const sKey_r2 = ctx.$implicit; const i_r3 = ctx.index; const ctx_r1 = i0.ɵɵnextContext(2); i0.ɵɵadvance(1); i0.ɵɵproperty("ngForOf", sKey_r2); i0.ɵɵadvance(1); i0.ɵɵproperty("ngIf", ctx_r1.parsedKeys.length > 1 && i_r3 < ctx_r1.parsedKeys.length - 1); } } function NgHotkeysHelpItemComponent_div_0_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "div", 1)(1, "div", 2)(2, "span"); i0.ɵɵtext(3); i0.ɵɵelementEnd()(); i0.ɵɵelementStart(4, "div", 3); i0.ɵɵtemplate(5, NgHotkeysHelpItemComponent_div_0_div_5_Template, 3, 2, "div", 4); i0.ɵɵelementEnd()(); } if (rf & 2) { const ctx_r0 = i0.ɵɵnextContext(); i0.ɵɵclassProp("item--odd", ctx_r0.index % 2 !== 0); i0.ɵɵadvance(3); i0.ɵɵtextInterpolate(ctx_r0.shortcut.description); i0.ɵɵadvance(2); i0.ɵɵproperty("ngForOf", ctx_r0.parsedKeys); } } /** * @ignore */ class NgHotkeysHelpItemComponent { constructor() { } set shortcut(shortcut) { const key = Array.isArray(shortcut.key) ? shortcut.key : [shortcut.key]; this.parsedKeys = key.map(key => key .split(" ") .filter(identity) .filter(key => key !== "+") .map(key => { if (symbols[key]) { return symbols[key]; } return key; })); this._shortcut = shortcut; } get shortcut() { return this._shortcut; } ngOnInit() { } } NgHotkeysHelpItemComponent.ɵfac = function NgHotkeysHelpItemComponent_Factory(t) { return new (t || NgHotkeysHelpItemComponent)(); }; NgHotkeysHelpItemComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: NgHotkeysHelpItemComponent, selectors: [["ng-hot-keys-help-item"]], inputs: { index: "index", shortcut: "shortcut" }, decls: 1, vars: 1, consts: [["class", "item", 3, "item--odd", 4, "ngIf"], [1, "item"], [1, "description"], [1, "keys"], ["class", "key__container", 4, "ngFor", "ngForOf"], [1, "key__container"], ["class", "key", 4, "ngFor", "ngForOf"], ["class", "separator", 4, "ngIf"], [1, "key"], [1, "separator"]], template: function NgHotkeysHelpItemComponent_Template(rf, ctx) { if (rf & 1) { i0.ɵɵtemplate(0, NgHotkeysHelpItemComponent_div_0_Template, 6, 4, "div", 0); } if (rf & 2) { i0.ɵɵproperty("ngIf", ctx.shortcut.description); } }, dependencies: [i1.NgForOf, i1.NgIf], styles: [".key[_ngcontent-%COMP%]{border:1px solid #CCCCCC;border-radius:4px;padding:5px 12px;margin-right:5px;background-color:#f5f5f5}.key__container[_ngcontent-%COMP%]{display:inline-block}.separator[_ngcontent-%COMP%]{margin-right:5px}.keys[_ngcontent-%COMP%]{float:right}.item[_ngcontent-%COMP%]{background-color:#ebebeb;padding:12px}.description[_ngcontent-%COMP%]{min-width:168px;display:inline-block;color:#333}.item--odd[_ngcontent-%COMP%]{background-color:#fff}"] }); (function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(NgHotkeysHelpItemComponent, [{ type: Component, args: [{ selector: "ng-hot-keys-help-item", template: "<div class=\"item\" [class.item--odd]=\"index % 2 !== 0\" *ngIf=\"shortcut.description\">\n <div class=\"description\">\n <span>{{shortcut.description}}</span>\n </div>\n <div class=\"keys\">\n <div *ngFor=\"let sKey of parsedKeys;let i = index\" class=\"key__container\">\n <span class=\"key\" *ngFor=\"let key of sKey;\">{{key}}</span>\n <span *ngIf=\"parsedKeys.length > 1 && i < parsedKeys.length - 1\" class=\"separator\"> / </span>\n </div>\n </div>\n</div>\n", styles: [".key{border:1px solid #CCCCCC;border-radius:4px;padding:5px 12px;margin-right:5px;background-color:#f5f5f5}.key__container{display:inline-block}.separator{margin-right:5px}.keys{float:right}.item{background-color:#ebebeb;padding:12px}.description{min-width:168px;display:inline-block;color:#333}.item--odd{background-color:#fff}\n"] }] }], function () { return []; }, { index: [{ type: Input }], shortcut: [{ type: Input }] }); })(); function NgHotkeysHelpComponent_ng_template_0_div_1_span_5_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "span"); i0.ɵɵtext(1); i0.ɵɵelementEnd(); } if (rf & 2) { const ctx_r3 = i0.ɵɵnextContext(3); i0.ɵɵadvance(1); i0.ɵɵtextInterpolate1(" ", ctx_r3.emptyMessage, " "); } } function NgHotkeysHelpComponent_ng_template_0_div_1_ul_7_ng_hot_keys_help_item_3_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelement(0, "ng-hot-keys-help-item", 11); } if (rf & 2) { const shortcut_r7 = ctx.$implicit; const i_r8 = ctx.index; i0.ɵɵproperty("shortcut", shortcut_r7)("index", i_r8); } } function NgHotkeysHelpComponent_ng_template_0_div_1_ul_7_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "ul", 8)(1, "h4", 9); i0.ɵɵtext(2); i0.ɵɵelementEnd(); i0.ɵɵtemplate(3, NgHotkeysHelpComponent_ng_template_0_div_1_ul_7_ng_hot_keys_help_item_3_Template, 1, 2, "ng-hot-keys-help-item", 10); i0.ɵɵelementEnd(); } if (rf & 2) { const label_r5 = ctx.$implicit; const ctx_r4 = i0.ɵɵnextContext(3); i0.ɵɵadvance(2); i0.ɵɵtextInterpolate(label_r5); i0.ɵɵadvance(1); i0.ɵɵproperty("ngForOf", ctx_r4.shortcuts[label_r5]); } } function NgHotkeysHelpComponent_ng_template_0_div_1_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "div")(1, "div", 3)(2, "h3", 4); i0.ɵɵtext(3); i0.ɵɵelementEnd()(); i0.ɵɵelementStart(4, "div", 5); i0.ɵɵtemplate(5, NgHotkeysHelpComponent_ng_template_0_div_1_span_5_Template, 2, 1, "span", 6); i0.ɵɵelementStart(6, "div"); i0.ɵɵtemplate(7, NgHotkeysHelpComponent_ng_template_0_div_1_ul_7_Template, 4, 2, "ul", 7); i0.ɵɵelementEnd()()(); } if (rf & 2) { const ctx_r1 = i0.ɵɵnextContext(2); i0.ɵɵclassMap(ctx_r1.className); i0.ɵɵproperty("@enterAnimation", undefined); i0.ɵɵadvance(2); i0.ɵɵpropertyInterpolate1("id", "modal-", ctx_r1.title, ""); i0.ɵɵadvance(1); i0.ɵɵtextInterpolate(ctx_r1.title); i0.ɵɵadvance(2); i0.ɵɵproperty("ngIf", !ctx_r1.labels.length); i0.ɵɵadvance(2); i0.ɵɵproperty("ngForOf", ctx_r1.labels); } } function NgHotkeysHelpComponent_ng_template_0_div_2_Template(rf, ctx) { if (rf & 1) { const _r10 = i0.ɵɵgetCurrentView(); i0.ɵɵelementStart(0, "div", 12); i0.ɵɵlistener("mousedown", function NgHotkeysHelpComponent_ng_template_0_div_2_Template_div_mousedown_0_listener() { i0.ɵɵrestoreView(_r10); const ctx_r9 = i0.ɵɵnextContext(2); return i0.ɵɵresetView(ctx_r9.hide()); }); i0.ɵɵelementEnd(); } if (rf & 2) { i0.ɵɵproperty("@overlayAnimation", undefined); } } function NgHotkeysHelpComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "div", 0); i0.ɵɵtemplate(1, NgHotkeysHelpComponent_ng_template_0_div_1_Template, 8, 8, "div", 1); i0.ɵɵtemplate(2, NgHotkeysHelpComponent_ng_template_0_div_2_Template, 1, 1, "div", 2); i0.ɵɵelementEnd(); } if (rf & 2) { const ctx_r0 = i0.ɵɵnextContext(); i0.ɵɵattribute("aria-labelledby", "modal-" + ctx_r0.title); i0.ɵɵadvance(1); i0.ɵɵproperty("ngIf", ctx_r0.showing); i0.ɵɵadvance(1); i0.ɵɵproperty("ngIf", ctx_r0.showing); } } /** * @ignore */ const scrollAbleKeys = new Map([ [31, 1], [38, 1], [39, 1], [40, 1] ]); /** * @ignore */ const preventDefault = (ignore) => e => { const modal = e.target.closest(ignore); if (modal) { return; } e = e || window.event; if (e.preventDefault) e.preventDefault(); e.returnValue = false; }; /** * @ignore */ const preventDefaultForScrollKeys = e => { if (!scrollAbleKeys.has(e.keyCode)) { return; } preventDefault(e); return false; }; /** * @ignore */ let scrollEvents = [ { name: "wheel", callback: null }, { name: "touchmove", callback: null }, { name: "DOMMouseScroll", callback: null } ]; /** * @ignore */ const disableScroll = (ignore) => { scrollEvents = scrollEvents.map(event => { const callback = preventDefault(ignore); window.addEventListener(event.name, callback, { passive: false }); return Object.assign(Object.assign({}, event), { callback }); }); window.addEventListener("keydown", preventDefaultForScrollKeys); }; /** * @ignore */ const enableScroll = () => { scrollEvents = scrollEvents.map(event => { window.removeEventListener(event.name, event.callback); return Object.assign(Object.assign({}, event), { callback: null }); }); window.removeEventListener("keydown", preventDefaultForScrollKeys); }; /** * A Component to show all registered shortcut in the app * it is shown as a modal */ class NgHotkeysHelpComponent { /** * @ignore */ constructor(componentFactoryResolver, appRef, keyboard, element, keyboardHelp, viewContainer, injector) { this.componentFactoryResolver = componentFactoryResolver; this.appRef = appRef; this.keyboard = keyboard; this.element = element; this.keyboardHelp = keyboardHelp; this.viewContainer = viewContainer; this.injector = injector; /** * Disable scrolling while modal is open */ this.disableScrolling = true; this.className = "help-modal"; /** * The title of the help screen * @default: "Keyboard shortcuts" */ this.title = "Keyboard shortcuts"; /** * What message to show when no shortcuts are available on the page. * @default "No shortcuts available" */ this.emptyMessage = "No shortcuts available"; /** * @ignore */ this.showing = false; this.bodyPortalHost = new DomPortalOutlet(document.body, this.componentFactoryResolver, this.appRef, this.injector); } /** * The shortcut to show/hide the help screen */ set key(value) { this._key = value; if (!value) { return; } if (this.clearIds) { this.keyboard.remove(this.clearIds); } this.clearIds = this.addShortcut({ key: value, preventDefault: true, command: () => this.toggle(), description: this.keyDescription, label: this.keyLabel }); } addShortcut(shortcut) { return this.keyboard.add(shortcut); } set closeKey(value) { this._closeKey = value; if (!value) { return; } if (this.closeKeyIds) { this.keyboard.remove(this.closeKeyIds); } this.closeKeyIds = this.addShortcut({ key: value, preventDefault: true, command: () => this.hide(), description: this.closeKeyDescription, label: this.closeKeyDescription }); } /** * Reveal the help screen manually. */ reveal() { this.hide(); if (this.disableScrolling) { disableScroll(`.${this.className}`); } const portal = new TemplatePortal(this.template, this.viewCon