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 }