// @ts-check import Service from '@ember/service'; import { inject as service } from '@ember/service'; import { timeout, restartableTask } from 'ember-concurrency'; import { tracked } from '@glimmer/tracking'; import { compare } from '@ember/utils'; import { A } from '@ember/array'; // eslint-disable-next-line no-unused-vars import EmberRouter from '@ember/routing/router'; import { schedule } from '@ember/runloop'; import { action, set } from '@ember/object'; import { guidFor } from '@ember/object/internals'; import { assert } from '@ember/debug'; // eslint-disable-next-line no-unused-vars import MutableArray from '@ember/array/mutable'; import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; /** * @typedef {Object} KeyCommand * @property {string} label * @property {string[]} pattern * @property {any} action * @property {boolean} [requireModifier] * @property {boolean} [enumerated] * @property {boolean} [recording] * @property {boolean} [custom] * @property {boolean} [exclusive] */ const DEBOUNCE_MS = 750; // This modifies event.key to a symbol; get the digit equivalent to perform commands const DIGIT_MAP = { '!': 1, '@': 2, '#': 3, $: 4, '%': 5, '^': 6, '&': 7, '*': 8, '(': 9, ')': 0, }; const DISALLOWED_KEYS = [ 'Shift', 'Backspace', 'Delete', 'Meta', 'Alt', 'Control', 'Tab', 'CapsLock', 'Clear', 'ScrollLock', ]; export default class KeyboardService extends Service { /** * @type {EmberRouter} */ @service router; @service config; @tracked shortcutsVisible = false; @tracked buffer = A([]); @tracked displayHints = false; @localStorageProperty('keyboardNavEnabled', true) enabled; defaultPatterns = { 'Go to Jobs': ['g', 'j'], 'Go to Storage': ['g', 'r'], 'Go to Variables': ['g', 'v'], 'Go to Servers': ['g', 's'], 'Go to Clients': ['g', 'c'], 'Go to Topology': ['g', 't'], 'Go to Evaluations': ['g', 'e'], 'Go to ACL Tokens': ['g', 'a'], 'Next Subnav': ['Shift+ArrowRight'], 'Previous Subnav': ['Shift+ArrowLeft'], 'Previous Main Section': ['Shift+ArrowUp'], 'Next Main Section': ['Shift+ArrowDown'], 'Show Keyboard Shortcuts': ['Shift+?'], }; /** * @type {MutableArray} */ @tracked keyCommands = A( [ { label: 'Go to Jobs', action: () => this.router.transitionTo('jobs'), rebindable: true, }, { label: 'Go to Storage', action: () => this.router.transitionTo('csi.volumes'), rebindable: true, }, { label: 'Go to Variables', action: () => this.router.transitionTo('variables'), }, { label: 'Go to Servers', action: () => this.router.transitionTo('servers'), rebindable: true, }, { label: 'Go to Clients', action: () => this.router.transitionTo('clients'), rebindable: true, }, { label: 'Go to Topology', action: () => this.router.transitionTo('topology'), rebindable: true, }, { label: 'Go to Evaluations', action: () => this.router.transitionTo('evaluations'), rebindable: true, }, { label: 'Go to ACL Tokens', action: () => this.router.transitionTo('settings.tokens'), rebindable: true, }, { label: 'Next Subnav', action: () => { this.traverseLinkList(this.subnavLinks, 1); }, requireModifier: true, rebindable: true, }, { label: 'Previous Subnav', action: () => { this.traverseLinkList(this.subnavLinks, -1); }, requireModifier: true, rebindable: true, }, { label: 'Previous Main Section', action: () => { this.traverseLinkList(this.navLinks, -1); }, requireModifier: true, rebindable: true, }, { label: 'Next Main Section', action: () => { this.traverseLinkList(this.navLinks, 1); }, requireModifier: true, rebindable: true, }, { label: 'Show Keyboard Shortcuts', action: () => { this.shortcutsVisible = true; }, }, ].map((command) => { const persistedValue = window.localStorage.getItem( `keyboard.command.${command.label}` ); if (persistedValue) { set(command, 'pattern', JSON.parse(persistedValue)); set(command, 'custom', true); } else { set(command, 'pattern', this.defaultPatterns[command.label]); } return command; }) ); /** * For Dynamic/iterative keyboard shortcuts, we want to do a couple things to make them more human-friendly: * 1. Make them 1-based, instead of 0-based * 2. Prefix numbers 1-9 with "0" to make it so "Shift+10" doesn't trigger "Shift+1" then "0", etc. * ^--- stops being a good solution with 100+ row lists/tables, but a better UX than waiting for shift key-up otherwise * * @param {number} iter * @returns {string[]} */ cleanPattern(iter) { iter = iter + 1; // first item should be Shift+1, not Shift+0 assert('Dynamic keyboard shortcuts only work up to 99 digits', iter < 100); return [`Shift+${('0' + iter).slice(-2)}`]; // Shift+01, not Shift+1 } recomputeEnumeratedCommands() { this.keyCommands.filterBy('enumerated').forEach((command, iter) => { command.pattern = this.cleanPattern(iter); }); } addCommands(commands) { schedule('afterRender', () => { commands.forEach((command) => { if (command.exclusive) { this.removeCommands( this.keyCommands.filterBy('label', command.label) ); } this.keyCommands.pushObject(command); if (command.enumerated) { // Recompute enumerated numbers to handle things like sort this.recomputeEnumeratedCommands(); } }); }); } removeCommands(commands = A([])) { this.keyCommands.removeObjects(commands); } //#region Nav Traversal subnavLinks = []; navLinks = []; /** * Map over a passed element's links and determine if they're routable * If so, return them in a transitionTo-able format * * @param {HTMLElement} element did-insertable menu container element * @param {Object} args * @param {('main' | 'subnav')} args.type determine which traversable list the routes belong to */ @action registerNav(element, _, args) { const { type } = args; const links = Array.from(element.querySelectorAll('a:not(.loading)')) .map((link) => { if (link.getAttribute('href')) { return { route: this.router.recognize(link.getAttribute('href'))?.name, parent: guidFor(element), }; } }) .compact(); if (type === 'main') { this.navLinks = links; } else if (type === 'subnav') { this.subnavLinks = links; } } /** * Removes links associated with a specific nav. * guidFor is necessary because willDestroy runs async; * it can happen after the next page's did-insert, so we .reject() instead of resetting to []. * * @param {HTMLElement} element */ @action unregisterSubnav(element) { this.subnavLinks = this.subnavLinks.reject( (link) => link.parent === guidFor(element) ); } /** * * @param {Array} links - array of root.branch.twig strings * @param {number} traverseBy - positive or negative number to move along links */ traverseLinkList(links, traverseBy) { // afterRender because LinkTos evaluate their href value at render time schedule('afterRender', () => { if (links.length) { let activeLink = links.find((link) => this.router.isActive(link.route)); // If no activeLink, means we're nested within a primary section. // Luckily, Ember's RouteInfo.find() gives us access to parents and connected leaves of a route. // So, if we're on /csi/volumes but the nav link is to /csi, we'll .find() it. // Similarly, /job/:job/taskgroupid/index will find /job. if (!activeLink) { activeLink = links.find((link) => { return this.router.currentRoute.find((r) => { return r.name === link.route || `${r.name}.index` === link.route; }); }); } if (activeLink) { const activeLinkPosition = links.indexOf(activeLink); const nextPosition = activeLinkPosition + traverseBy; // Modulo (%) logic: if the next position is longer than the array, wrap to 0. // If it's before the beginning, wrap to the end. const nextLink = links[((nextPosition % links.length) + links.length) % links.length] .route; this.router.transitionTo(nextLink); } } }); } //#endregion Nav Traversal /** * * @param {("press" | "release")} type * @param {KeyboardEvent} event */ recordKeypress(type, event) { const inputElements = ['input', 'textarea', 'code']; const disallowedClassNames = [ 'ember-basic-dropdown-trigger', 'dropdown-option', ]; const targetElementName = event.target.nodeName.toLowerCase(); const inputDisallowed = inputElements.includes(targetElementName) || disallowedClassNames.any((className) => event.target.classList.contains(className) ); // Don't fire keypress events from within an input field if (!inputDisallowed) { // Treat Shift like a special modifier key. // If it's depressed, display shortcuts const { key } = event; const shifted = event.getModifierState('Shift'); if (type === 'press') { if (key === 'Shift') { this.displayHints = true; } else { if (!DISALLOWED_KEYS.includes(key)) { this.addKeyToBuffer.perform(key, shifted); } } } else if (type === 'release') { if (key === 'Shift') { this.displayHints = false; } } } } rebindCommand = (cmd, ele) => { ele.target.blur(); // keynav ignores on inputs this.clearBuffer(); set(cmd, 'recording', true); set(cmd, 'previousPattern', cmd.pattern); set(cmd, 'pattern', null); }; endRebind = (cmd) => { set(cmd, 'custom', true); set(cmd, 'recording', false); set(cmd, 'previousPattern', null); window.localStorage.setItem( `keyboard.command.${cmd.label}`, JSON.stringify([...this.buffer]) ); }; resetCommandToDefault = (cmd) => { window.localStorage.removeItem(`keyboard.command.${cmd.label}`); set(cmd, 'pattern', this.defaultPatterns[cmd.label]); set(cmd, 'custom', false); }; /** * * @param {string} key * @param {boolean} shifted */ @restartableTask *addKeyToBuffer(key, shifted) { // Replace key with its unshifted equivalent if it's a number key if (shifted && key in DIGIT_MAP) { key = DIGIT_MAP[key]; } this.buffer.pushObject(shifted ? `Shift+${key}` : key); let recorder = this.keyCommands.find((c) => c.recording); if (recorder) { if (key === 'Escape' || key === '/') { // Escape cancels recording; slash is reserved for global search set(recorder, 'recording', false); set(recorder, 'pattern', recorder.previousPattern); recorder = null; } else if (key === 'Enter') { // Enter finishes recording and removes itself from the buffer this.buffer = this.buffer.slice(0, -1); this.endRebind(recorder); recorder = null; } else { set(recorder, 'pattern', [...this.buffer]); } } else { if (this.matchedCommands.length) { this.matchedCommands.forEach((command) => { if ( this.enabled || command.label === 'Show Keyboard Shortcuts' || command.label === 'Hide Keyboard Shortcuts' ) { command.action(); } }); this.clearBuffer(); } } yield timeout(DEBOUNCE_MS); if (recorder) { this.endRebind(recorder); } this.clearBuffer(); } get matchedCommands() { // Shiftless Buffer: handle the case where use is holding shift (to see shortcut hints) and typing a key command const shiftlessBuffer = this.buffer.map((key) => key.replace('Shift+', '').toLowerCase() ); // Shift Friendly Buffer: If you hold Shift and type 0 and 1, it'll output as ['Shift+0', 'Shift+1']. // Instead, translate that to ['Shift+01'] for clearer UX const shiftFriendlyBuffer = [ `Shift+${this.buffer.map((key) => key.replace('Shift+', '')).join('')}`, ]; // Ember Compare: returns 0 if there's no diff between arrays. const matches = this.keyCommands.filter((command) => { return ( command.action && (!compare(command.pattern, this.buffer) || (command.requireModifier ? false : !compare(command.pattern, shiftlessBuffer)) || (command.requireModifier ? false : !compare(command.pattern, shiftFriendlyBuffer))) ); }); return matches; } clearBuffer() { this.buffer.clear(); } listenForKeypress() { set(this, '_keyDownHandler', this.recordKeypress.bind(this, 'press')); document.addEventListener('keydown', this._keyDownHandler); set(this, '_keyUpHandler', this.recordKeypress.bind(this, 'release')); document.addEventListener('keyup', this._keyUpHandler); } willDestroy() { document.removeEventListener('keydown', this._keyDownHandler); document.removeEventListener('keyup', this._keyUpHandler); } }