github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/services/keyboard.js (about)

     1  // @ts-check
     2  import Service from '@ember/service';
     3  import { inject as service } from '@ember/service';
     4  import { timeout, restartableTask } from 'ember-concurrency';
     5  import { tracked } from '@glimmer/tracking';
     6  import { compare } from '@ember/utils';
     7  import { A } from '@ember/array';
     8  // eslint-disable-next-line no-unused-vars
     9  import EmberRouter from '@ember/routing/router';
    10  import { schedule } from '@ember/runloop';
    11  import { action, set } from '@ember/object';
    12  import { guidFor } from '@ember/object/internals';
    13  import { assert } from '@ember/debug';
    14  // eslint-disable-next-line no-unused-vars
    15  import MutableArray from '@ember/array/mutable';
    16  import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
    17  
    18  /**
    19   * @typedef {Object} KeyCommand
    20   * @property {string} label
    21   * @property {string[]} pattern
    22   * @property {any} action
    23   * @property {boolean} [requireModifier]
    24   * @property {boolean} [enumerated]
    25   * @property {boolean} [recording]
    26   * @property {boolean} [custom]
    27   * @property {boolean} [exclusive]
    28   */
    29  
    30  const DEBOUNCE_MS = 750;
    31  // This modifies event.key to a symbol; get the digit equivalent to perform commands
    32  const DIGIT_MAP = {
    33    '!': 1,
    34    '@': 2,
    35    '#': 3,
    36    $: 4,
    37    '%': 5,
    38    '^': 6,
    39    '&': 7,
    40    '*': 8,
    41    '(': 9,
    42    ')': 0,
    43  };
    44  
    45  const DISALLOWED_KEYS = [
    46    'Shift',
    47    'Backspace',
    48    'Delete',
    49    'Meta',
    50    'Alt',
    51    'Control',
    52    'Tab',
    53    'CapsLock',
    54    'Clear',
    55    'ScrollLock',
    56  ];
    57  
    58  export default class KeyboardService extends Service {
    59    /**
    60     * @type {EmberRouter}
    61     */
    62    @service router;
    63  
    64    @service config;
    65  
    66    @tracked shortcutsVisible = false;
    67    @tracked buffer = A([]);
    68    @tracked displayHints = false;
    69  
    70    @localStorageProperty('keyboardNavEnabled', true) enabled;
    71  
    72    defaultPatterns = {
    73      'Go to Jobs': ['g', 'j'],
    74      'Go to Storage': ['g', 'r'],
    75      'Go to Variables': ['g', 'v'],
    76      'Go to Servers': ['g', 's'],
    77      'Go to Clients': ['g', 'c'],
    78      'Go to Topology': ['g', 't'],
    79      'Go to Evaluations': ['g', 'e'],
    80      'Go to Profile': ['g', 'p'],
    81      'Next Subnav': ['Shift+ArrowRight'],
    82      'Previous Subnav': ['Shift+ArrowLeft'],
    83      'Previous Main Section': ['Shift+ArrowUp'],
    84      'Next Main Section': ['Shift+ArrowDown'],
    85      'Show Keyboard Shortcuts': ['Shift+?'],
    86    };
    87  
    88    /**
    89     * @type {MutableArray<KeyCommand>}
    90     */
    91    @tracked
    92    keyCommands = A(
    93      [
    94        {
    95          label: 'Go to Jobs',
    96          action: () => this.router.transitionTo('jobs'),
    97          rebindable: true,
    98        },
    99        {
   100          label: 'Go to Storage',
   101          action: () => this.router.transitionTo('csi.volumes'),
   102          rebindable: true,
   103        },
   104        {
   105          label: 'Go to Variables',
   106          action: () => this.router.transitionTo('variables'),
   107        },
   108        {
   109          label: 'Go to Servers',
   110          action: () => this.router.transitionTo('servers'),
   111          rebindable: true,
   112        },
   113        {
   114          label: 'Go to Clients',
   115          action: () => this.router.transitionTo('clients'),
   116          rebindable: true,
   117        },
   118        {
   119          label: 'Go to Topology',
   120          action: () => this.router.transitionTo('topology'),
   121          rebindable: true,
   122        },
   123        {
   124          label: 'Go to Evaluations',
   125          action: () => this.router.transitionTo('evaluations'),
   126          rebindable: true,
   127        },
   128        {
   129          label: 'Go to Profile',
   130          action: () => this.router.transitionTo('settings.tokens'),
   131          rebindable: true,
   132        },
   133        {
   134          label: 'Next Subnav',
   135          action: () => {
   136            this.traverseLinkList(this.subnavLinks, 1);
   137          },
   138          requireModifier: true,
   139          rebindable: true,
   140        },
   141        {
   142          label: 'Previous Subnav',
   143          action: () => {
   144            this.traverseLinkList(this.subnavLinks, -1);
   145          },
   146          requireModifier: true,
   147          rebindable: true,
   148        },
   149        {
   150          label: 'Previous Main Section',
   151          action: () => {
   152            this.traverseLinkList(this.navLinks, -1);
   153          },
   154          requireModifier: true,
   155          rebindable: true,
   156        },
   157        {
   158          label: 'Next Main Section',
   159          action: () => {
   160            this.traverseLinkList(this.navLinks, 1);
   161          },
   162          requireModifier: true,
   163          rebindable: true,
   164        },
   165        {
   166          label: 'Show Keyboard Shortcuts',
   167          action: () => {
   168            this.shortcutsVisible = true;
   169          },
   170        },
   171      ].map((command) => {
   172        const persistedValue = window.localStorage.getItem(
   173          `keyboard.command.${command.label}`
   174        );
   175        if (persistedValue) {
   176          set(command, 'pattern', JSON.parse(persistedValue));
   177          set(command, 'custom', true);
   178        } else {
   179          set(command, 'pattern', this.defaultPatterns[command.label]);
   180        }
   181        return command;
   182      })
   183    );
   184  
   185    /**
   186     * For Dynamic/iterative keyboard shortcuts, we want to do a couple things to make them more human-friendly:
   187     * 1. Make them 1-based, instead of 0-based
   188     * 2. Prefix numbers 1-9 with "0" to make it so "Shift+10" doesn't trigger "Shift+1" then "0", etc.
   189     * ^--- stops being a good solution with 100+ row lists/tables, but a better UX than waiting for shift key-up otherwise
   190     *
   191     * @param {number} iter
   192     * @returns {string[]}
   193     */
   194    cleanPattern(iter) {
   195      iter = iter + 1; // first item should be Shift+1, not Shift+0
   196      assert('Dynamic keyboard shortcuts only work up to 99 digits', iter < 100);
   197      return [`Shift+${('0' + iter).slice(-2)}`]; // Shift+01, not Shift+1
   198    }
   199  
   200    recomputeEnumeratedCommands() {
   201      this.keyCommands.filterBy('enumerated').forEach((command, iter) => {
   202        command.pattern = this.cleanPattern(iter);
   203      });
   204    }
   205  
   206    addCommands(commands) {
   207      schedule('afterRender', () => {
   208        commands.forEach((command) => {
   209          if (command.exclusive) {
   210            this.removeCommands(
   211              this.keyCommands.filterBy('label', command.label)
   212            );
   213          }
   214          this.keyCommands.pushObject(command);
   215          if (command.enumerated) {
   216            // Recompute enumerated numbers to handle things like sort
   217            this.recomputeEnumeratedCommands();
   218          }
   219        });
   220      });
   221    }
   222  
   223    removeCommands(commands = A([])) {
   224      this.keyCommands.removeObjects(commands);
   225    }
   226  
   227    //#region Nav Traversal
   228  
   229    subnavLinks = [];
   230    navLinks = [];
   231  
   232    /**
   233     * Map over a passed element's links and determine if they're routable
   234     * If so, return them in a transitionTo-able format
   235     *
   236     * @param {HTMLElement} element did-insertable menu container element
   237     * @param {Object} args
   238     * @param {('main' | 'subnav')} args.type determine which traversable list the routes belong to
   239     */
   240    @action
   241    registerNav(element, _, args) {
   242      const { type } = args;
   243      const links = Array.from(element.querySelectorAll('a:not(.loading)'))
   244        .map((link) => {
   245          if (link.getAttribute('href')) {
   246            return {
   247              route: this.router.recognize(link.getAttribute('href'))?.name,
   248              parent: guidFor(element),
   249            };
   250          }
   251        })
   252        .compact();
   253  
   254      if (type === 'main') {
   255        this.navLinks = links;
   256      } else if (type === 'subnav') {
   257        this.subnavLinks = links;
   258      }
   259    }
   260  
   261    /**
   262     * Removes links associated with a specific nav.
   263     * guidFor is necessary because willDestroy runs async;
   264     * it can happen after the next page's did-insert, so we .reject() instead of resetting to [].
   265     *
   266     * @param {HTMLElement} element
   267     */
   268    @action
   269    unregisterSubnav(element) {
   270      this.subnavLinks = this.subnavLinks.reject(
   271        (link) => link.parent === guidFor(element)
   272      );
   273    }
   274  
   275    /**
   276     *
   277     * @param {Array<string>} links - array of root.branch.twig strings
   278     * @param {number} traverseBy - positive or negative number to move along links
   279     */
   280    traverseLinkList(links, traverseBy) {
   281      // afterRender because LinkTos evaluate their href value at render time
   282      schedule('afterRender', () => {
   283        if (links.length) {
   284          let activeLink = links.find((link) => this.router.isActive(link.route));
   285  
   286          // If no activeLink, means we're nested within a primary section.
   287          // Luckily, Ember's RouteInfo.find() gives us access to parents and connected leaves of a route.
   288          // So, if we're on /csi/volumes but the nav link is to /csi, we'll .find() it.
   289          // Similarly, /job/:job/taskgroupid/index will find /job.
   290          if (!activeLink) {
   291            activeLink = links.find((link) => {
   292              return this.router.currentRoute.find((r) => {
   293                return r.name === link.route || `${r.name}.index` === link.route;
   294              });
   295            });
   296          }
   297  
   298          if (activeLink) {
   299            const activeLinkPosition = links.indexOf(activeLink);
   300            const nextPosition = activeLinkPosition + traverseBy;
   301  
   302            // Modulo (%) logic: if the next position is longer than the array, wrap to 0.
   303            // If it's before the beginning, wrap to the end.
   304            const nextLink =
   305              links[((nextPosition % links.length) + links.length) % links.length]
   306                .route;
   307  
   308            this.router.transitionTo(nextLink);
   309          }
   310        }
   311      });
   312    }
   313  
   314    //#endregion Nav Traversal
   315  
   316    /**
   317     *
   318     * @param {("press" | "release")} type
   319     * @param {KeyboardEvent} event
   320     */
   321    recordKeypress(type, event) {
   322      const inputElements = ['input', 'textarea', 'code'];
   323      const disallowedClassNames = [
   324        'ember-basic-dropdown-trigger',
   325        'dropdown-option',
   326      ];
   327      const targetElementName = event.target.nodeName.toLowerCase();
   328      const inputDisallowed =
   329        inputElements.includes(targetElementName) ||
   330        disallowedClassNames.any((className) =>
   331          event.target.classList.contains(className)
   332        );
   333  
   334      // Don't fire keypress events from within an input field
   335      if (!inputDisallowed) {
   336        // Treat Shift like a special modifier key.
   337        // If it's depressed, display shortcuts
   338        const { key } = event;
   339        const shifted = event.getModifierState('Shift');
   340        if (type === 'press') {
   341          if (key === 'Shift') {
   342            this.displayHints = true;
   343          } else {
   344            if (!DISALLOWED_KEYS.includes(key)) {
   345              this.addKeyToBuffer.perform(key, shifted);
   346            }
   347          }
   348        } else if (type === 'release') {
   349          if (key === 'Shift') {
   350            this.displayHints = false;
   351          }
   352        }
   353      }
   354    }
   355  
   356    rebindCommand = (cmd, ele) => {
   357      ele.target.blur(); // keynav ignores on inputs
   358      this.clearBuffer();
   359      set(cmd, 'recording', true);
   360      set(cmd, 'previousPattern', cmd.pattern);
   361      set(cmd, 'pattern', null);
   362    };
   363  
   364    endRebind = (cmd) => {
   365      set(cmd, 'custom', true);
   366      set(cmd, 'recording', false);
   367      set(cmd, 'previousPattern', null);
   368      window.localStorage.setItem(
   369        `keyboard.command.${cmd.label}`,
   370        JSON.stringify([...this.buffer])
   371      );
   372    };
   373  
   374    resetCommandToDefault = (cmd) => {
   375      window.localStorage.removeItem(`keyboard.command.${cmd.label}`);
   376      set(cmd, 'pattern', this.defaultPatterns[cmd.label]);
   377      set(cmd, 'custom', false);
   378    };
   379  
   380    /**
   381     *
   382     * @param {string} key
   383     * @param {boolean} shifted
   384     */
   385    @restartableTask *addKeyToBuffer(key, shifted) {
   386      // Replace key with its unshifted equivalent if it's a number key
   387      if (shifted && key in DIGIT_MAP) {
   388        key = DIGIT_MAP[key];
   389      }
   390      this.buffer.pushObject(shifted ? `Shift+${key}` : key);
   391      let recorder = this.keyCommands.find((c) => c.recording);
   392      if (recorder) {
   393        if (key === 'Escape' || key === '/') {
   394          // Escape cancels recording; slash is reserved for global search
   395          set(recorder, 'recording', false);
   396          set(recorder, 'pattern', recorder.previousPattern);
   397          recorder = null;
   398        } else if (key === 'Enter') {
   399          // Enter finishes recording and removes itself from the buffer
   400          this.buffer = this.buffer.slice(0, -1);
   401          this.endRebind(recorder);
   402          recorder = null;
   403        } else {
   404          set(recorder, 'pattern', [...this.buffer]);
   405        }
   406      } else {
   407        if (this.matchedCommands.length) {
   408          this.matchedCommands.forEach((command) => {
   409            if (
   410              this.enabled ||
   411              command.label === 'Show Keyboard Shortcuts' ||
   412              command.label === 'Hide Keyboard Shortcuts'
   413            ) {
   414              command.action();
   415            }
   416          });
   417          this.clearBuffer();
   418        }
   419      }
   420      yield timeout(DEBOUNCE_MS);
   421      if (recorder) {
   422        this.endRebind(recorder);
   423      }
   424      this.clearBuffer();
   425    }
   426  
   427    get matchedCommands() {
   428      // Shiftless Buffer: handle the case where use is holding shift (to see shortcut hints) and typing a key command
   429      const shiftlessBuffer = this.buffer.map((key) =>
   430        key.replace('Shift+', '').toLowerCase()
   431      );
   432  
   433      // Shift Friendly Buffer: If you hold Shift and type 0 and 1, it'll output as ['Shift+0', 'Shift+1'].
   434      // Instead, translate that to ['Shift+01'] for clearer UX
   435      const shiftFriendlyBuffer = [
   436        `Shift+${this.buffer.map((key) => key.replace('Shift+', '')).join('')}`,
   437      ];
   438  
   439      // Ember Compare: returns 0 if there's no diff between arrays.
   440      const matches = this.keyCommands.filter((command) => {
   441        return (
   442          command.action &&
   443          (!compare(command.pattern, this.buffer) ||
   444            (command.requireModifier
   445              ? false
   446              : !compare(command.pattern, shiftlessBuffer)) ||
   447            (command.requireModifier
   448              ? false
   449              : !compare(command.pattern, shiftFriendlyBuffer)))
   450        );
   451      });
   452      return matches;
   453    }
   454  
   455    clearBuffer() {
   456      this.buffer.clear();
   457    }
   458  
   459    listenForKeypress() {
   460      set(this, '_keyDownHandler', this.recordKeypress.bind(this, 'press'));
   461      document.addEventListener('keydown', this._keyDownHandler);
   462      set(this, '_keyUpHandler', this.recordKeypress.bind(this, 'release'));
   463      document.addEventListener('keyup', this._keyUpHandler);
   464    }
   465  
   466    willDestroy() {
   467      document.removeEventListener('keydown', this._keyDownHandler);
   468      document.removeEventListener('keyup', this._keyUpHandler);
   469    }
   470  }