github.com/hernad/nomad@v1.6.112/ui/app/services/keyboard.js (about)

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