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 }