477 lines
14 KiB
JavaScript
477 lines
14 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: MPL-2.0
|
|
*/
|
|
|
|
// @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 Profile': ['g', 'p'],
|
|
'Next Subnav': ['Shift+ArrowRight'],
|
|
'Previous Subnav': ['Shift+ArrowLeft'],
|
|
'Previous Main Section': ['Shift+ArrowUp'],
|
|
'Next Main Section': ['Shift+ArrowDown'],
|
|
'Show Keyboard Shortcuts': ['Shift+?'],
|
|
};
|
|
|
|
/**
|
|
* @type {MutableArray<KeyCommand>}
|
|
*/
|
|
@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 Profile',
|
|
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<string>} 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, event);
|
|
}
|
|
}
|
|
} 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, event) {
|
|
// 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'
|
|
) {
|
|
event.preventDefault();
|
|
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);
|
|
}
|
|
}
|