blob: 19f12c5475bdb20f283d2be8d521fc44e7d5266e [file] [log] [blame]
/**
* @license
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
config,
Shortcut,
ShortcutHelpItem,
ShortcutSection,
} from './shortcuts-config';
import {disableShortcuts$} from '../user/user-model';
import {
ComboKey,
eventMatchesShortcut,
isElementTarget,
Key,
Modifier,
Binding,
} from '../../utils/dom-util';
import {ReportingService} from '../gr-reporting/gr-reporting';
export type SectionView = Array<{binding: string[][]; text: string}>;
export interface ShortcutListener {
shortcut: Shortcut;
listener: (e: KeyboardEvent) => void;
}
export function listen(
shortcut: Shortcut,
listener: (e: KeyboardEvent) => void
): ShortcutListener {
return {shortcut, listener};
}
/**
* The interface for listener for shortcut events.
*/
export type ShortcutViewListener = (
viewMap?: Map<ShortcutSection, SectionView>
) => void;
function isComboKey(key: string): key is ComboKey {
return Object.values(ComboKey).includes(key as ComboKey);
}
export const COMBO_TIMEOUT_MS = 1000;
/**
* Shortcuts service, holds all hosts, bindings and listeners.
*/
export class ShortcutsService {
/**
* Keeps track of the components that are currently active such that we can
* show a shortcut help dialog that only shows the shortcuts that are
* currently relevant.
*/
private readonly activeShortcuts = new Map<HTMLElement, Shortcut[]>();
/**
* Keeps track of cleanup callbacks (which remove keyboard listeners) that
* have to be invoked when a component unregisters itself.
*/
private readonly cleanupsPerHost = new Map<HTMLElement, (() => void)[]>();
/** Static map built in the constructor by iterating over the config. */
private readonly bindings = new Map<Shortcut, Binding[]>();
private readonly listeners = new Set<ShortcutViewListener>();
/**
* Stores the timestamp of the last combo key being pressed.
* This enabled key combinations like 'g+o' where we can check whether 'g' was
* pressed recently when 'o' is processed. Keys of this map must be items of
* COMBO_KEYS. Values are Date timestamps in milliseconds.
*/
private comboKeyLastPressed: {key?: ComboKey; timestampMs?: number} = {};
/** Keeps track of the corresponding user preference. */
private shortcutsDisabled = false;
constructor(readonly reporting?: ReportingService) {
for (const section of config.keys()) {
const items = config.get(section) ?? [];
for (const item of items) {
this.bindings.set(item.shortcut, item.bindings);
}
}
disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x));
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (!isComboKey(e.key)) return;
if (this.shouldSuppress(e)) return;
this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
});
}
public _testOnly_isEmpty() {
return this.activeShortcuts.size === 0 && this.listeners.size === 0;
}
isInComboKeyMode() {
return Object.values(ComboKey).some(key =>
this.isInSpecificComboKeyMode(key)
);
}
isInSpecificComboKeyMode(comboKey: ComboKey) {
const {key, timestampMs} = this.comboKeyLastPressed;
return (
key === comboKey &&
timestampMs &&
Date.now() - timestampMs < COMBO_TIMEOUT_MS
);
}
/**
* TODO(brohlfs): Reconcile with the addShortcut() function in dom-util.
* Most likely we will just keep this one here, but that is something for a
* follow-up change.
*/
addShortcut(
element: HTMLElement,
shortcut: Binding,
listener: (e: KeyboardEvent) => void
) {
const wrappedListener = (e: KeyboardEvent) => {
if (e.repeat) return;
if (!eventMatchesShortcut(e, shortcut)) return;
if (shortcut.combo) {
if (!this.isInSpecificComboKeyMode(shortcut.combo)) return;
} else {
if (this.isInComboKeyMode()) return;
}
if (this.shouldSuppress(e)) return;
e.preventDefault();
e.stopPropagation();
listener(e);
};
element.addEventListener('keydown', wrappedListener);
return () => element.removeEventListener('keydown', wrappedListener);
}
shouldSuppress(e: KeyboardEvent) {
if (this.shortcutsDisabled) return true;
// Note that when you listen on document, then `e.currentTarget` will be the
// document and `e.target` will be `<gr-app>` due to shadow dom, but by
// using the composedPath() you can actually find the true origin of the
// event.
const rootTarget = e.composedPath()[0];
if (!isElementTarget(rootTarget)) return false;
const tagName = rootTarget.tagName;
const type = rootTarget.getAttribute('type');
if (
// Suppress shortcuts on <input> and <textarea>, but not on
// checkboxes, because we want to enable workflows like 'click
// mark-reviewed and then press ] to go to the next file'.
(tagName === 'INPUT' && type !== 'checkbox') ||
tagName === 'TEXTAREA' ||
// Suppress shortcuts if the key is 'enter'
// and target is an anchor or button or paper-tab.
(e.keyCode === 13 &&
(tagName === 'A' || tagName === 'BUTTON' || tagName === 'PAPER-TAB'))
) {
return true;
}
const path: EventTarget[] = e.composedPath() ?? [];
for (const el of path) {
if (!isElementTarget(el)) continue;
if (el.tagName === 'GR-OVERLAY') return true;
}
// eg: {key: "k:keydown", ..., from: "gr-diff-view"}
let key = `${e.key}:${e.type}`;
if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
if (this.isInSpecificComboKeyMode(ComboKey.V)) key = 'v+' + key;
if (e.shiftKey) key = 'shift+' + key;
if (e.ctrlKey) key = 'ctrl+' + key;
if (e.metaKey) key = 'meta+' + key;
if (e.altKey) key = 'alt+' + key;
let from = 'unknown';
if (isElementTarget(e.currentTarget)) {
from = e.currentTarget.tagName;
}
this.reporting?.reportInteraction('shortcut-triggered', {key, from});
return false;
}
createTitle(shortcutName: Shortcut, section: ShortcutSection) {
const desc = this.getDescription(section, shortcutName);
const shortcut = this.getShortcut(shortcutName);
return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
}
getBindingsForShortcut(shortcut: Shortcut) {
return this.bindings.get(shortcut);
}
attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) {
this.activeShortcuts.set(
host,
shortcuts.map(s => s.shortcut)
);
const cleanups: (() => void)[] = [];
for (const s of shortcuts) {
const bindings = this.getBindingsForShortcut(s.shortcut);
for (const binding of bindings ?? []) {
if (binding.docOnly) continue;
cleanups.push(this.addShortcut(document.body, binding, s.listener));
}
}
this.cleanupsPerHost.set(host, cleanups);
this.notifyViewListeners();
}
detachHost(host: HTMLElement) {
this.activeShortcuts.delete(host);
const cleanups = this.cleanupsPerHost.get(host);
for (const cleanup of cleanups ?? []) cleanup();
this.notifyViewListeners();
return true;
}
addListener(listener: ShortcutViewListener) {
this.listeners.add(listener);
listener(this.directoryView());
}
removeListener(listener: ShortcutViewListener) {
return this.listeners.delete(listener);
}
getDescription(section: ShortcutSection, shortcutName: Shortcut) {
const bindings = config.get(section);
if (!bindings) return '';
const binding = bindings.find(binding => binding.shortcut === shortcutName);
return binding?.text ?? '';
}
getShortcut(shortcutName: Shortcut) {
const bindings = this.bindings.get(shortcutName);
if (!bindings) return '';
return bindings
.map(binding => describeBinding(binding).join('+'))
.join(',');
}
activeShortcutsBySection() {
const activeShortcuts = new Set<Shortcut>();
for (const shortcuts of this.activeShortcuts.values()) {
for (const shortcut of shortcuts) {
activeShortcuts.add(shortcut);
}
}
const activeShortcutsBySection = new Map<
ShortcutSection,
ShortcutHelpItem[]
>();
config.forEach((shortcutList, section) => {
shortcutList.forEach(shortcutHelp => {
if (activeShortcuts.has(shortcutHelp.shortcut)) {
if (!activeShortcutsBySection.has(section)) {
activeShortcutsBySection.set(section, []);
}
activeShortcutsBySection.get(section)!.push(shortcutHelp);
}
});
});
return activeShortcutsBySection;
}
directoryView() {
const view = new Map<ShortcutSection, SectionView>();
this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
const sectionView: SectionView = [];
shortcutHelps.forEach(shortcutHelp => {
const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
if (!bindingDesc) {
return;
}
this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
sectionView.push({
binding: bindingDesc,
text: shortcutHelp.text,
});
});
});
view.set(section, sectionView);
});
return view;
}
distributeBindingDesc(bindingDesc: string[][]): string[][][] {
if (
bindingDesc.length === 1 ||
this.comboSetDisplayWidth(bindingDesc) < 21
) {
return [bindingDesc];
}
// Find the largest prefix of bindings that is under the
// size threshold.
const head = [bindingDesc[0]];
for (let i = 1; i < bindingDesc.length; i++) {
head.push(bindingDesc[i]);
if (this.comboSetDisplayWidth(head) >= 21) {
head.pop();
return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
}
}
return [];
}
comboSetDisplayWidth(bindingDesc: string[][]) {
const bindingSizer = (binding: string[]) =>
binding.reduce((acc, key) => acc + key.length, 0);
// Width is the sum of strings + (n-1) * 2 to account for the word
// "or" joining them.
return (
bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
2 * (bindingDesc.length - 1)
);
}
describeBindings(shortcut: Shortcut): string[][] | null {
const bindings = this.bindings.get(shortcut);
if (!bindings) return null;
return bindings
.filter(binding => !binding.docOnly)
.map(binding => describeBinding(binding));
}
notifyViewListeners() {
const view = this.directoryView();
this.listeners.forEach(listener => listener(view));
}
}
function describeKey(key: string | Key) {
switch (key) {
case Key.UP:
return '\u2191'; // ↑
case Key.DOWN:
return '\u2193'; // ↓
case Key.LEFT:
return '\u2190'; // ←
case Key.RIGHT:
return '\u2192'; // →
default:
return key;
}
}
export function describeBinding(binding: Binding): string[] {
const description: string[] = [];
if (binding.combo === ComboKey.G) {
description.push('g');
}
if (binding.combo === ComboKey.V) {
description.push('v');
}
if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
description.push('Shift');
}
if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
description.push('Alt');
}
if (binding.modifiers?.includes(Modifier.CTRL_KEY)) {
description.push('Ctrl');
}
if (binding.modifiers?.includes(Modifier.META_KEY)) {
description.push('Meta/Cmd');
}
description.push(describeKey(binding.key));
return description;
}