blob: 3c5a733c1771651687d91e02368da219df64679e [file] [log] [blame]
<!--
@license
Copyright (C) 2016 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.
-->
<!--
How to Add a Keyboard Shortcut
==============================
A keyboard shortcut is composed of the following parts:
1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
2. Documentation for the keyboard shortcut help dialog
3. A binding between key combos and the semantic identifier
4. A binding between the semantic identifier and a listener
Parts (1) and (2) for all shortcuts are defined in this file. The semantic
identifier is declared in the Shortcut enum near the head of this script:
const Shortcut = {
// ...
TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
// ...
};
Immediately following the Shortcut enum definition, there is a _describe
function defined which is then invoked many times to populate the help dialog.
Add a new invocation here to document the shortcut:
_describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
'Hide/show left diff');
When an attached view binds one or more key combos to this shortcut, the help
dialog will display this text in the given section (in this case, "Diffs"). See
the ShortcutSection enum immediately below for the list of supported sections.
Part (3), the actual key bindings, are declared by gr-app. In the future, this
system may be expanded to allow key binding customizations by plugins or user
preferences. Key bindings are defined in the following forms:
// Ordinary shortcut with a single binding.
this.bindShortcut(
this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
// Ordinary shortcut with multiple bindings.
this.bindShortcut(
this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
// A "go-key" keyboard shortcut, which is combined with a previously and
// continuously pressed "go" key (the go-key is hard-coded as 'g').
this.bindShortcut(
this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
// A "doc-only" keyboard shortcut. This declares the key-binding for help
// dialog purposes, but doesn't actually implement the binding. It is up
// to some element to implement this binding using iron-a11y-keys-behavior's
// keyBindings property.
this.bindShortcut(
this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
Part (4), the listener definitions, are declared by the view or element that
implements the shortcut behavior. This is done by implementing a method named
keyboardShortcuts() in an element that mixes in this behavior, returning an
object that maps semantic identifiers (as property names) to listener method
names, like this:
keyboardShortcuts() {
return {
[this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
};
},
You can implement key bindings in an element that is hosted by a view IF that
element is always attached exactly once under that view (e.g. the search bar in
gr-app). When that is not the case, you will have to define a doc-only binding
in gr-app, declare the shortcut in the view that hosts the element, and use
iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
element. An example of this is in comment threads. A diff view supports actions
on comment threads, but there may be zero or many comment threads attached at
any given point. So the shortcut is declared as doc-only by the diff view and
by gr-app, and actually implemented by gr-comment-thread.
NOTE: doc-only shortcuts will not be customizable in the same way that other
shortcuts are.
-->
<link rel="import" href="/bower_components/polymer/polymer.html">
<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
<script>
(function(window) {
'use strict';
const DOC_ONLY = 'DOC_ONLY';
const GO_KEY = 'GO_KEY';
// The maximum age of a keydown event to be used in a jump navigation. This
// is only for cases when the keyup event is lost.
const GO_KEY_TIMEOUT_MS = 1000;
const ShortcutSection = {
ACTIONS: 'Actions',
DIFFS: 'Diffs',
EVERYWHERE: 'Everywhere',
FILE_LIST: 'File list',
NAVIGATION: 'Navigation',
REPLY_DIALOG: 'Reply dialog',
};
const Shortcut = {
OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
GO_TO_USER_DASHBOARD: 'GO_TO_USER_DASHBOARD',
GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',
GO_TO_WATCHED_CHANGES: 'GO_TO_WATCHED_CHANGES',
CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
OPEN_CHANGE: 'OPEN_CHANGE',
NEXT_PAGE: 'NEXT_PAGE',
PREV_PAGE: 'PREV_PAGE',
TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',
OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
UP_TO_CHANGE: 'UP_TO_CHANGE',
TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
REFRESH_CHANGE: 'REFRESH_CHANGE',
EDIT_TOPIC: 'EDIT_TOPIC',
NEXT_LINE: 'NEXT_LINE',
PREV_LINE: 'PREV_LINE',
NEXT_CHUNK: 'NEXT_CHUNK',
PREV_CHUNK: 'PREV_CHUNK',
EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
LEFT_PANE: 'LEFT_PANE',
RIGHT_PANE: 'RIGHT_PANE',
TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
NEW_COMMENT: 'NEW_COMMENT',
SAVE_COMMENT: 'SAVE_COMMENT',
OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',
NEXT_FILE: 'NEXT_FILE',
PREV_FILE: 'PREV_FILE',
NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
OPEN_FILE: 'OPEN_FILE',
TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
OPEN_LAST_FILE: 'OPEN_LAST_FILE',
SEARCH: 'SEARCH',
SEND_REPLY: 'SEND_REPLY',
EMOJI_DROPDOWN: 'EMOJI_DROPDOWN',
};
const _help = new Map();
function _describe(shortcut, section, text) {
if (!_help.has(section)) {
_help.set(section, []);
}
_help.get(section).push({shortcut, text});
}
_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
_describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
'Show this dialog');
_describe(Shortcut.GO_TO_USER_DASHBOARD, ShortcutSection.EVERYWHERE,
'Go to User Dashboard');
_describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
'Go to Opened Changes');
_describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
'Go to Merged Changes');
_describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
'Go to Abandoned Changes');
_describe(Shortcut.GO_TO_WATCHED_CHANGES, ShortcutSection.EVERYWHERE,
'Go to Watched Changes');
_describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
'Select next change');
_describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
'Select previous change');
_describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
'Show selected change');
_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
_describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
'Open reply dialog to publish comments and add reviewers');
_describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
'Open download overlay');
_describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
'Expand all messages');
_describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
'Collapse all messages');
_describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
'Reload the change at the latest patch');
_describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
'Mark/unmark change as reviewed');
_describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
'Toggle review flag on selected file');
_describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
'Refresh list of changes');
_describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
'Star/unstar change');
_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
'Add a change topic');
_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
'Go to next diff chunk');
_describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
'Go to previous diff chunk');
_describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
'Expand all diff context');
_describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
'Go to next comment thread');
_describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
'Go to previous comment thread');
_describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
'Expand all comment threads');
_describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
'Collapse all comment threads');
_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
_describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
'Hide/show left diff');
_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
_describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
'Show diff preferences');
_describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
'Mark/unmark file as reviewed');
_describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
'Toggle unified/side-by-side diff');
_describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
'Mark file as reviewed and go to next unreviewed file');
_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
_describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
'Select previous file');
_describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
'Select next file that has comments');
_describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
'Select previous file that has comments');
_describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
'Show first file');
_describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
'Show last file');
_describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
'Up to dashboard');
_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
_describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
'Select next file');
_describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
'Select previous file');
_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
'Go to selected file');
_describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
'Show/hide all inline diffs');
_describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
'Show/hide selected inline diff');
_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
_describe(Shortcut.EMOJI_DROPDOWN, ShortcutSection.REPLY_DIALOG,
'Emoji dropdown');
// Must be declared outside behavior implementation to be accessed inside
// behavior functions.
/** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
const getKeyboardEvent = function(e) {
e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
// When e is a keyboardEvent, e.event is not null.
if (e.event) { e = e.event; }
return e;
};
class ShortcutManager {
constructor() {
this.activeHosts = new Map();
this.bindings = new Map();
this.listeners = new Set();
}
bindShortcut(shortcut, ...bindings) {
this.bindings.set(shortcut, bindings);
}
getBindingsForShortcut(shortcut) {
return this.bindings.get(shortcut);
}
attachHost(host) {
if (!host.keyboardShortcuts) { return; }
const shortcuts = host.keyboardShortcuts();
this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
this.notifyListeners();
return shortcuts;
}
detachHost(host) {
if (this.activeHosts.delete(host)) {
this.notifyListeners();
return true;
}
return false;
}
addListener(listener) {
this.listeners.add(listener);
listener(this.directoryView());
}
removeListener(listener) {
return this.listeners.delete(listener);
}
activeShortcutsBySection() {
const activeShortcuts = new Set();
this.activeHosts.forEach(shortcuts => {
shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
});
const activeShortcutsBySection = new Map();
_help.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();
this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
const 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) {
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)));
}
}
}
comboSetDisplayWidth(bindingDesc) {
const bindingSizer = binding => 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) {
const bindings = this.bindings.get(shortcut);
if (!bindings) { return null; }
if (bindings[0] === GO_KEY) {
return [['g'].concat(bindings.slice(1))];
}
return bindings
.filter(binding => binding !== DOC_ONLY)
.map(binding => this.describeBinding(binding));
}
describeBinding(binding) {
if (binding.length === 1) {
return [binding];
}
return binding.split(':')[0].split('+').map(part => {
switch (part) {
case 'shift':
return 'Shift';
case 'meta':
return 'Meta';
case 'ctrl':
return 'Ctrl';
case 'enter':
return 'Enter';
case 'up':
return '↑';
case 'down':
return '↓';
case 'left':
return '←';
case 'right':
return '→';
default:
return part;
}
});
}
notifyListeners() {
const view = this.directoryView();
this.listeners.forEach(listener => listener(view));
}
}
const shortcutManager = new ShortcutManager();
window.Gerrit = window.Gerrit || {};
/** @polymerBehavior Gerrit.KeyboardShortcutBehavior*/
Gerrit.KeyboardShortcutBehavior = [
Polymer.IronA11yKeysBehavior,
{
// Exports for convenience. Note: Closure compiler crashes when
// object-shorthand syntax is used here.
// eslint-disable-next-line object-shorthand
DOC_ONLY: DOC_ONLY,
// eslint-disable-next-line object-shorthand
GO_KEY: GO_KEY,
// eslint-disable-next-line object-shorthand
Shortcut: Shortcut,
properties: {
_shortcut_go_key_last_pressed: {
type: Number,
value: null,
},
_shortcut_go_table: {
type: Array,
value() { return new Map(); },
},
},
modifierPressed(e) {
e = getKeyboardEvent(e);
return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
},
isModifierPressed(e, modifier) {
return getKeyboardEvent(e)[modifier];
},
shouldSuppressKeyboardShortcut(e) {
e = getKeyboardEvent(e);
const tagName = Polymer.dom(e).rootTarget.tagName;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
(e.keyCode === 13 && tagName === 'A')) {
// Suppress shortcuts if the key is 'enter' and target is an anchor.
return true;
}
for (let i = 0; e.path && i < e.path.length; i++) {
if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
}
return false;
},
// Alias for getKeyboardEvent.
/** @return {!Event} */
getKeyboardEvent(e) {
return getKeyboardEvent(e);
},
getRootTarget(e) {
return Polymer.dom(getKeyboardEvent(e)).rootTarget;
},
bindShortcut(shortcut, ...bindings) {
shortcutManager.bindShortcut(shortcut, ...bindings);
},
_addOwnKeyBindings(shortcut, handler) {
const bindings = shortcutManager.getBindingsForShortcut(shortcut);
if (!bindings) {
return;
}
if (bindings[0] === DOC_ONLY) {
return;
}
if (bindings[0] === GO_KEY) {
this._shortcut_go_table.set(bindings[1], handler);
} else {
this.addOwnKeyBinding(bindings.join(' '), handler);
}
},
attached() {
const shortcuts = shortcutManager.attachHost(this);
if (!shortcuts) { return; }
for (const key of Object.keys(shortcuts)) {
this._addOwnKeyBindings(key, shortcuts[key]);
}
// If any of the shortcuts utilized GO_KEY, then they are handled
// directly by this behavior.
if (this._shortcut_go_table.size > 0) {
this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
this._shortcut_go_table.forEach((handler, key) => {
this.addOwnKeyBinding(key, '_handleGoAction');
});
}
},
detached() {
if (shortcutManager.detachHost(this)) {
this.removeOwnKeyBindings();
}
},
keyboardShortcuts() {
return {};
},
addKeyboardShortcutDirectoryListener(listener) {
shortcutManager.addListener(listener);
},
removeKeyboardShortcutDirectoryListener(listener) {
shortcutManager.removeListener(listener);
},
_handleGoKeyDown(e) {
if (this.modifierPressed(e)) { return; }
this._shortcut_go_key_last_pressed = Date.now();
},
_handleGoKeyUp(e) {
this._shortcut_go_key_last_pressed = null;
},
_handleGoAction(e) {
if (!this._shortcut_go_key_last_pressed ||
(Date.now() - this._shortcut_go_key_last_pressed >
GO_KEY_TIMEOUT_MS) ||
!this._shortcut_go_table.has(e.detail.key) ||
this.shouldSuppressKeyboardShortcut(e)) {
return;
}
e.preventDefault();
const handler = this._shortcut_go_table.get(e.detail.key);
this[handler](e);
},
},
];
Gerrit.KeyboardShortcutBinder = {
DOC_ONLY,
GO_KEY,
Shortcut,
ShortcutManager,
ShortcutSection,
bindShortcut(shortcut, ...bindings) {
shortcutManager.bindShortcut(shortcut, ...bindings);
},
};
})(window);
</script>