diff options
author | Paladox none <thomasmulhall410@yahoo.com> | 2018-10-25 21:46:38 +0000 |
---|---|---|
committer | Gerrit Code Review <noreply-gerritcodereview@google.com> | 2018-10-25 21:46:38 +0000 |
commit | e311accd71ec06194ed339d5c4b4ecb7cb8f2429 (patch) | |
tree | cfa02910a5739f2ea79c66405cb355103d34344d | |
parent | 063bdc632d3bc523036fb62029ac37d4cffcc458 (diff) | |
parent | 2c03a906de795d148062ac8c24ce2f6987530d1c (diff) |
Merge "Redesign the keyboard shortcuts system" into stable-2.16
21 files changed, 1627 insertions, 679 deletions
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html index 60eaf1fa64..0a685da2fc 100644 --- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html +++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html @@ -14,6 +14,88 @@ 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-diff-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"> @@ -21,10 +103,188 @@ limitations under the License. (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_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES', + GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES', + GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_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', + + 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', + 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', + }; + + 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_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.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.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_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'); + // Must be declared outside behavior implementation to be accessed inside // behavior functions. - /** @return {!Object} */ + /** @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. @@ -32,44 +292,307 @@ limitations under the License. return e; }; - window.Gerrit = window.Gerrit || {}; + class ShortcutManager { + constructor() { + this.activeHosts = new Map(); + this.bindings = new Map(); + this.listeners = new Set(); + } - /** @polymerBehavior KeyboardShortcutBehavior */ - Gerrit.KeyboardShortcutBehavior = [{ - modifierPressed(e) { - e = getKeyboardEvent(e); - return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; - }, + bindShortcut(shortcut, ...bindings) { + this.bindings.set(shortcut, bindings); + } - isModifierPressed(e, modifier) { - return getKeyboardEvent(e)[modifier]; - }, + getBindingsForShortcut(shortcut) { + return this.bindings.get(shortcut); + } - 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. + 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; } - 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 {!Object} */ - getKeyboardEvent(e) { - return getKeyboardEvent(e); - }, + addListener(listener) { + this.listeners.add(listener); + listener(this.directoryView()); + } - getRootTarget(e) { - return Polymer.dom(getKeyboardEvent(e)).rootTarget; - }, - }, + 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) { + 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 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|PolymerDomApi|PolymerEventApi)} */ + 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> diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html index 04193dde5a..dac90f8606 100644 --- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html +++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html @@ -40,6 +40,8 @@ limitations under the License. <script> suite('keyboard-shortcut-behavior tests', () => { + const kb = window.Gerrit.KeyboardShortcutBinder; + let element; let overlay; let sandbox; @@ -67,6 +69,228 @@ limitations under the License. sandbox.restore(); }); + suite('ShortcutManager', () => { + test('bindings management', () => { + const mgr = new kb.ShortcutManager(); + const {NEXT_FILE} = kb.Shortcut; + + assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE)); + mgr.bindShortcut(NEXT_FILE, ']', '}', 'right'); + assert.deepEqual( + mgr.getBindingsForShortcut(NEXT_FILE), + [']', '}', 'right']); + }); + + suite('binding descriptions', () => { + function mapToObject(m) { + const o = {}; + m.forEach((v, k) => o[k] = v); + return o; + } + + test('single combo description', () => { + const mgr = new kb.ShortcutManager(); + assert.deepEqual(mgr.describeBinding('a'), ['a']); + assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']); + assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']); + assert.deepEqual( + mgr.describeBinding('ctrl+shift+up:keyup'), + ['Ctrl', 'Shift', '↑']); + }); + + test('combo set description', () => { + const {GO_KEY, DOC_ONLY, ShortcutManager} = kb; + const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut; + + const mgr = new ShortcutManager(); + assert.isNull(mgr.describeBindings(NEXT_FILE)); + + mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o'); + assert.deepEqual( + mgr.describeBindings(GO_TO_OPENED_CHANGES), + [['g', 'o']]); + + mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup'); + assert.deepEqual( + mgr.describeBindings(NEXT_FILE), + [[']'], ['Ctrl', 'Shift', '→']]); + + mgr.bindShortcut(PREV_FILE, '['); + assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]); + }); + + test('combo set description width', () => { + const mgr = new kb.ShortcutManager(); + assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1); + assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2); + assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6); + assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4); + assert.strictEqual( + mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]), + 12); + }); + + test('distribute shortcut help', () => { + const mgr = new kb.ShortcutManager(); + assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]); + assert.deepEqual( + mgr.distributeBindingDesc([['g', 'o']]), + [[['g', 'o']]]); + assert.deepEqual( + mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]), + [[['ctrl', 'shift', 'meta', 'enter']]]); + assert.deepEqual( + mgr.distributeBindingDesc([ + ['ctrl', 'shift', 'meta', 'enter'], + ['o'], + ]), + [ + [['ctrl', 'shift', 'meta', 'enter']], + [['o']], + ]); + assert.deepEqual( + mgr.distributeBindingDesc([ + ['ctrl', 'enter'], + ['meta', 'enter'], + ['ctrl', 's'], + ['meta', 's'], + ]), + [ + [['ctrl', 'enter'], ['meta', 'enter']], + [['ctrl', 's'], ['meta', 's']], + ]); + }); + + test('active shortcuts by section', () => { + const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} = + kb.Shortcut; + const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection; + + const mgr = new kb.ShortcutManager(); + mgr.bindShortcut(NEXT_FILE, ']'); + mgr.bindShortcut(NEXT_LINE, 'j'); + mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o'); + mgr.bindShortcut(SEARCH, '/'); + + assert.deepEqual( + mapToObject(mgr.activeShortcutsBySection()), + {}); + + mgr.attachHost({ + keyboardShortcuts() { + return { + [NEXT_FILE]: null, + }; + }, + }); + assert.deepEqual( + mapToObject(mgr.activeShortcutsBySection()), + { + [NAVIGATION]: [ + {shortcut: NEXT_FILE, text: 'Select next file'}, + ], + }); + + mgr.attachHost({ + keyboardShortcuts() { + return { + [NEXT_LINE]: null, + }; + }, + }); + assert.deepEqual( + mapToObject(mgr.activeShortcutsBySection()), + { + [DIFFS]: [ + {shortcut: NEXT_LINE, text: 'Go to next line'}, + ], + [NAVIGATION]: [ + {shortcut: NEXT_FILE, text: 'Select next file'}, + ], + }); + + mgr.attachHost({ + keyboardShortcuts() { + return { + [SEARCH]: null, + [GO_TO_OPENED_CHANGES]: null, + }; + }, + }); + assert.deepEqual( + mapToObject(mgr.activeShortcutsBySection()), + { + [DIFFS]: [ + {shortcut: NEXT_LINE, text: 'Go to next line'}, + ], + [EVERYWHERE]: [ + {shortcut: SEARCH, text: 'Search'}, + { + shortcut: GO_TO_OPENED_CHANGES, + text: 'Go to Opened Changes', + }, + ], + [NAVIGATION]: [ + {shortcut: NEXT_FILE, text: 'Select next file'}, + ], + }); + }); + + test('directory view', () => { + const { + NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH, + SAVE_COMMENT, + } = kb.Shortcut; + const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection; + const {GO_KEY, ShortcutManager} = kb; + + const mgr = new ShortcutManager(); + mgr.bindShortcut(NEXT_FILE, ']'); + mgr.bindShortcut(NEXT_LINE, 'j'); + mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o'); + mgr.bindShortcut(SEARCH, '/'); + mgr.bindShortcut( + SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s'); + + assert.deepEqual(mapToObject(mgr.directoryView()), {}); + + mgr.attachHost({ + keyboardShortcuts() { + return { + [GO_TO_OPENED_CHANGES]: null, + [NEXT_FILE]: null, + [NEXT_LINE]: null, + [SAVE_COMMENT]: null, + [SEARCH]: null, + }; + }, + }); + assert.deepEqual( + mapToObject(mgr.directoryView()), + { + [DIFFS]: [ + {binding: [['j']], text: 'Go to next line'}, + { + binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']], + text: 'Save comment', + }, + { + binding: [['Ctrl', 's'], ['Meta', 's']], + text: 'Save comment', + }, + ], + [EVERYWHERE]: [ + {binding: [['/']], text: 'Search'}, + {binding: [['g', 'o']], text: 'Go to Opened Changes'}, + ], + [NAVIGATION]: [ + {binding: [[']']], text: 'Select next file'}, + ], + }); + }); + }); + }); + test('doesn’t block kb shortcuts for non-whitelisted els', done => { const divEl = document.createElement('div'); element.appendChild(divEl); @@ -160,5 +384,56 @@ limitations under the License. MockInteractions.keyDownOn(element, 75, 'alt', 'k'); assert.isFalse(spy.lastCall.returnValue); }); + + suite('GO_KEY timing', () => { + let handlerStub; + + setup(() => { + element._shortcut_go_table.set('a', '_handleA'); + handlerStub = element._handleA = sinon.stub(); + sandbox.stub(Date, 'now').returns(10000); + }); + + test('success', () => { + const e = {detail: {key: 'a'}, preventDefault: () => {}}; + sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); + element._shortcut_go_key_last_pressed = 9000; + element._handleGoAction(e); + assert.isTrue(handlerStub.calledOnce); + assert.strictEqual(handlerStub.lastCall.args[0], e); + }); + + test('go key not pressed', () => { + const e = {detail: {key: 'a'}, preventDefault: () => {}}; + sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); + element._shortcut_go_key_last_pressed = null; + element._handleGoAction(e); + assert.isFalse(handlerStub.called); + }); + + test('go key pressed too long ago', () => { + const e = {detail: {key: 'a'}, preventDefault: () => {}}; + sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); + element._shortcut_go_key_last_pressed = 3000; + element._handleGoAction(e); + assert.isFalse(handlerStub.called); + }); + + test('should suppress', () => { + const e = {detail: {key: 'a'}, preventDefault: () => {}}; + sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true); + element._shortcut_go_key_last_pressed = 9000; + element._handleGoAction(e); + assert.isFalse(handlerStub.called); + }); + + test('unrecognized key', () => { + const e = {detail: {key: 'f'}, preventDefault: () => {}}; + sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); + element._shortcut_go_key_last_pressed = 9000; + element._handleGoAction(e); + assert.isFalse(handlerStub.called); + }); + }); }); </script> diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js index ba768d9acd..de97e62507 100644 --- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js +++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js @@ -106,17 +106,6 @@ Gerrit.URLEncodingBehavior, ], - keyBindings: { - 'j': '_handleJKey', - 'k': '_handleKKey', - 'n ]': '_handleNKey', - 'o': '_handleOKey', - 'p [': '_handlePKey', - 'r': '_handleRKey', - 'shift+r': '_handleShiftRKey', - 's': '_handleSKey', - }, - listeners: { keydown: '_scopedKeydownHandler', }, @@ -126,6 +115,19 @@ '_computePreferences(account, preferences)', ], + keyboardShortcuts() { + return { + [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange', + [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange', + [this.Shortcut.NEXT_PAGE]: '_nextPage', + [this.Shortcut.PREV_PAGE]: '_prevPage', + [this.Shortcut.OPEN_CHANGE]: '_openChange', + [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed', + [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar', + [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList', + }; + }, + /** * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard * events must be scoped to a component level (e.g. `enter`) in order to not @@ -136,7 +138,7 @@ _scopedKeydownHandler(e) { if (e.keyCode === 13) { // Enter. - this._handleOKey(e); + this._openChange(e); } }, @@ -238,7 +240,7 @@ return account._account_id === change.assignee._account_id; }, - _handleJKey(e) { + _nextChange(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -246,7 +248,7 @@ this.$.cursor.next(); }, - _handleKKey(e) { + _prevChange(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -254,7 +256,7 @@ this.$.cursor.previous(); }, - _handleOKey(e) { + _openChange(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -262,7 +264,7 @@ Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex)); }, - _handleNKey(e) { + _nextPage(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) { return; @@ -272,7 +274,7 @@ this.fire('next-page'); }, - _handlePKey(e) { + _prevPage(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) { return; @@ -282,7 +284,7 @@ this.fire('previous-page'); }, - _handleRKey(e) { + _toggleChangeReviewed(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -300,7 +302,7 @@ changeEl.toggleReviewed(); }, - _handleShiftRKey(e) { + _refreshChangeList(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); @@ -311,7 +313,7 @@ window.location.reload(); }, - _handleSKey(e) { + _toggleChangeStar(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html index bb904b5f1d..d20d40a9b3 100644 --- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html +++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html @@ -42,6 +42,17 @@ limitations under the License. <script> suite('gr-change-list basic tests', () => { + // Define keybindings before attaching other fixtures. + const kb = window.Gerrit.KeyboardShortcutBinder; + kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j'); + kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k'); + kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o'); + kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r'); + kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r'); + kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's'); + kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n'); + kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p'); + let element; let sandbox; diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js index 6391d70065..42aae9cb6a 100644 --- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js +++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js @@ -268,16 +268,20 @@ '_patchNumChanged(_patchRange.patchNum)', ], - keyBindings: { - 'shift+r': '_handleCapitalRKey', - 'a': '_handleAKey', - 'd': '_handleDKey', - 'm': '_handleMKey', - 's': '_handleSKey', - 'u': '_handleUKey', - 'x': '_handleXKey', - 'z': '_handleZKey', - ',': '_handleCommaKey', + keyboardShortcuts() { + return { + [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding + [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange', + [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog', + [this.Shortcut.OPEN_DOWNLOAD_DIALOG]: + '_handleOpenDownloadDialogShortcut', + [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode', + [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar', + [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard', + [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages', + [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages', + [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut', + }; }, attached() { @@ -341,7 +345,7 @@ }); }, - _handleMKey(e) { + _handleToggleDiffMode(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -899,7 +903,7 @@ return label; }, - _handleAKey(e) { + _handleOpenReplyDialog(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; @@ -915,7 +919,7 @@ }); }, - _handleDKey(e) { + _handleOpenDownloadDialogShortcut(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -923,13 +927,13 @@ this.$.downloadOverlay.open(); }, - _handleCapitalRKey(e) { + _handleRefreshChange(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); Gerrit.Nav.navigateToChange(this._change); }, - _handleSKey(e) { + _handleToggleChangeStar(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -937,7 +941,7 @@ this.$.changeStar.toggleStar(); }, - _handleUKey(e) { + _handleUpToDashboard(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -945,7 +949,7 @@ this._determinePageBack(); }, - _handleXKey(e) { + _handleExpandAllMessages(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -953,7 +957,7 @@ this.messagesList.handleExpandCollapse(true); }, - _handleZKey(e) { + _handleCollapseAllMessages(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -961,7 +965,7 @@ this.messagesList.handleExpandCollapse(false); }, - _handleCommaKey(e) { + _handleOpenDiffPrefsShortcut(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html index 4e6cb6efac..ef0a376628 100644 --- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html +++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html @@ -43,6 +43,18 @@ limitations under the License. <script> suite('gr-change-view tests', () => { + const kb = window.Gerrit.KeyboardShortcutBinder; + kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter'); + kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r'); + kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a'); + kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd'); + kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm'); + kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's'); + kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u'); + kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x'); + kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z'); + kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ','); + let element; let sandbox; let navigateToChangeStub; @@ -277,11 +289,11 @@ limitations under the License. flushAsynchronousOperations(); element.viewState.diffMode = 'SIDE_BY_SIDE'; - element._handleMKey(e); + element._handleToggleDiffMode(e); assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF')); element.viewState.diffMode = 'UNIFIED_DIFF'; - element._handleMKey(e); + element._handleToggleDiffMode(e); assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE')); }); }); diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js index f54e0583a8..42c9e88ec0 100644 --- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js +++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js @@ -197,23 +197,33 @@ ], keyBindings: { - 'shift+left': '_handleShiftLeftKey', - 'shift+right': '_handleShiftRightKey', - 'i:keyup': '_handleIKey', - 'shift+i:keyup': '_handleCapitalIKey', - 'down j': '_handleDownKey', - 'up k': '_handleUpKey', - 'c': '_handleCKey', - '[': '_handleLeftBracketKey', - ']': '_handleRightBracketKey', - 'o': '_handleOKey', - 'n': '_handleNKey', - 'p': '_handlePKey', - 'r': '_handleRKey', - 'shift+a': '_handleCapitalAKey', - 'esc': '_handleEscKey', + esc: '_handleEscKey', + }, + + keyboardShortcuts() { + return { + [this.Shortcut.LEFT_PANE]: '_handleLeftPane', + [this.Shortcut.RIGHT_PANE]: '_handleRightPane', + [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff', + [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs', + [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext', + [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev', + [this.Shortcut.NEXT_LINE]: '_handleCursorNext', + [this.Shortcut.PREV_LINE]: '_handleCursorPrev', + [this.Shortcut.NEW_COMMENT]: '_handleNewComment', + [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile', + [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile', + [this.Shortcut.OPEN_FILE]: '_handleOpenFile', + [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk', + [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk', + [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed', + [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane', + + // Final two are actually handled by gr-diff-comment-thread. + [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null, + [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null, + }; }, - listeners: { keydown: '_scopedKeydownHandler', }, @@ -232,7 +242,7 @@ _scopedKeydownHandler(e) { if (e.keyCode === 13) { // Enter. - this._handleOKey(e); + this._handleOpenFile(e); } }, @@ -536,7 +546,7 @@ this._togglePathExpanded(path); }, - _handleShiftLeftKey(e) { + _handleLeftPane(e) { if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) { return; } @@ -545,7 +555,7 @@ this.$.diffCursor.moveLeft(); }, - _handleShiftRightKey(e) { + _handleRightPane(e) { if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) { return; } @@ -554,7 +564,7 @@ this.$.diffCursor.moveRight(); }, - _handleIKey(e) { + _handleToggleInlineDiff(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e) || this.$.fileCursor.index === -1) { return; } @@ -563,14 +573,14 @@ this._togglePathExpandedByIndex(this.$.fileCursor.index); }, - _handleCapitalIKey(e) { + _handleToggleAllInlineDiffs(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); this._toggleInlineDiffs(); }, - _handleDownKey(e) { + _handleCursorNext(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -588,7 +598,7 @@ } }, - _handleUpKey(e) { + _handleCursorPrev(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -606,7 +616,7 @@ } }, - _handleCKey(e) { + _handleNewComment(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -619,7 +629,7 @@ } }, - _handleLeftBracketKey(e) { + _handleOpenLastFile(e) { // Check for meta key to avoid overriding native chrome shortcut. if (this.shouldSuppressKeyboardShortcut(e) || this.getKeyboardEvent(e).metaKey) { return; } @@ -628,7 +638,7 @@ this._openSelectedFile(this._files.length - 1); }, - _handleRightBracketKey(e) { + _handleOpenFirstFile(e) { // Check for meta key to avoid overriding native chrome shortcut. if (this.shouldSuppressKeyboardShortcut(e) || this.getKeyboardEvent(e).metaKey) { return; } @@ -637,7 +647,7 @@ this._openSelectedFile(0); }, - _handleOKey(e) { + _handleOpenFile(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); @@ -650,7 +660,7 @@ this._openSelectedFile(); }, - _handleNKey(e) { + _handleNextChunk(e) { if (this.shouldSuppressKeyboardShortcut(e) || (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) || this._noDiffsExpanded()) { @@ -665,7 +675,7 @@ } }, - _handlePKey(e) { + _handlePrevChunk(e) { if (this.shouldSuppressKeyboardShortcut(e) || (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) || this._noDiffsExpanded()) { @@ -680,7 +690,7 @@ } }, - _handleRKey(e) { + _handleToggleFileReviewed(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -690,7 +700,7 @@ this._reviewFile(this._files[this.$.fileCursor.index].__path); }, - _handleCapitalAKey(e) { + _handleToggleLeftPane(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html index 88b5f66e3f..df92b1e149 100644 --- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html +++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html @@ -49,6 +49,24 @@ limitations under the License. <script> suite('gr-file-list tests', () => { + const kb = window.Gerrit.KeyboardShortcutBinder; + kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left'); + kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right'); + kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup'); + kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup'); + kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down'); + kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up'); + kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down'); + kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up'); + kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c'); + kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '['); + kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']'); + kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o'); + kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n'); + kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p'); + kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r'); + kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a'); + let element; let commentApiWrapper; let sandbox; @@ -668,7 +686,7 @@ limitations under the License. assert.equal(getNumReviewed(), 0); }); - suite('_handleOKey', () => { + suite('_handleOpenFile', () => { let interact; setup(() => { @@ -686,7 +704,7 @@ limitations under the License. const e = new CustomEvent('fake-keyboard-event', opt_payload); sinon.stub(e, 'preventDefault'); - element._handleOKey(e); + element._handleOpenFile(e); assert.isTrue(e.preventDefault.called); const result = {}; if (openCursorStub.called) { @@ -1545,7 +1563,7 @@ limitations under the License. setup(() => { sandbox.stub(element, '_renderInOrder').returns(Promise.resolve()); - nKeySpy = sandbox.spy(element, '_handleNKey'); + nKeySpy = sandbox.spy(element, '_handleNextChunk'); nextCommentStub = sandbox.stub(element.$.diffCursor, 'moveToNextCommentThread'); nextChunkStub = sandbox.stub(element.$.diffCursor, @@ -1632,11 +1650,11 @@ limitations under the License. const mockEvent = {preventDefault() {}}; element._displayLine = false; - element._handleDownKey(mockEvent); + element._handleCursorNext(mockEvent); assert.isTrue(element._displayLine); element._displayLine = false; - element._handleUpKey(mockEvent); + element._handleCursorPrev(mockEvent); assert.isTrue(element._displayLine); element._displayLine = true; diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html new file mode 100644 index 0000000000..2ff7953a37 --- /dev/null +++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html @@ -0,0 +1,48 @@ +<!-- +@license +Copyright (C) 2018 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. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../styles/shared-styles.html"> + +<dom-module id="gr-key-binding-display"> + <template> + <style include="shared-styles"> + .key { + background-color: var(--chip-background-color); + border: 1px solid var(--border-color); + border-radius: 3px; + display: inline-block; + font-weight: var(--font-weight-bold); + padding: .1em .5em; + text-align: center; + } + </style> + <template is="dom-repeat" items="[[binding]]"> + <template is="dom-if" if="[[index]]"> + or + </template> + <template + is="dom-repeat" + items="[[_computeModifiers(item)]]" + as="modifier"> + <span class="key modifier">[[modifier]]</span> + </template> + <span class="key">[[_computeKey(item)]]</span> + </template> + </template> + <script src="gr-key-binding-display.js"></script> +</dom-module> diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js new file mode 100644 index 0000000000..89d1091086 --- /dev/null +++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js @@ -0,0 +1,36 @@ +/** + * @license + * Copyright (C) 2018 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. + */ +(function() { + 'use strict'; + + Polymer({ + is: 'gr-key-binding-display', + + properties: { + /** @type {Array<string>} */ + binding: Array, + }, + + _computeModifiers(binding) { + return binding.slice(0, binding.length - 1); + }, + + _computeKey(binding) { + return binding[binding.length - 1]; + }, + }); +})(); diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html new file mode 100644 index 0000000000..0361d76d93 --- /dev/null +++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<!-- +@license +Copyright (C) 2018 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. +--> +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-key-binding-display</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<link rel="import" href="../../../test/common-test-setup.html"/> +<link rel="import" href="gr-key-binding-display.html"> + +<script>void(0);</script> + +<test-fixture id="basic"> + <template> + <gr-key-binding-display></gr-key-binding-display> + </template> +</test-fixture> + +<script> + suite('gr-key-binding-display tests', () => { + let element; + + setup(() => { + element = fixture('basic'); + }); + + suite('_computeKey', () => { + test('unmodified key', () => { + assert.strictEqual(element._computeKey(['x']), 'x'); + }); + + test('key with modifiers', () => { + assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x'); + assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x'); + }); + }); + + suite('_computeModifiers', () => { + test('single unmodified key', () => { + assert.deepEqual(element._computeModifiers(['x']), []); + }); + + test('key with modifiers', () => { + assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']); + assert.deepEqual( + element._computeModifiers(['Shift', 'Meta', 'x']), + ['Shift', 'Meta']); + }); + }); + }); +</script> diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html index 9fda898f83..e3552ccd25 100644 --- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html +++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html @@ -16,7 +16,9 @@ limitations under the License. --> <link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html"> <link rel="import" href="../../shared/gr-button/gr-button.html"> +<link rel="import" href="../gr-key-binding-display/gr-key-binding-display.html"> <link rel="import" href="../../../styles/shared-styles.html"> <dom-module id="gr-keyboard-shortcuts-dialog"> @@ -54,15 +56,6 @@ limitations under the License. font-weight: var(--font-weight-bold); padding-top: 1em; } - .key { - background-color: var(--chip-background-color); - border: 1px solid var(--border-color); - border-radius: 3px; - display: inline-block; - font-weight: var(--font-weight-bold); - padding: .1em .5em; - text-align: center; - } .modifier { font-weight: normal; } @@ -74,449 +67,42 @@ limitations under the License. <main> <table> <tbody> - <tr> - <td></td><td class="header">Everywhere</td> - </tr> - <tr> - <td><span class="key">/</span></td> - <td>Search</td> - </tr> - <tr> - <td><span class="key">?</span></td> - <td>Show this dialog</td> - </tr> - <tr> - <td> - <span class="key modifier">g</span> - <span class="key">o</span> - </td> - <td>Go to Opened Changes</td> - </tr> - <tr> - <td> - <span class="key modifier">g</span> - <span class="key">m</span> - </td> - <td>Go to Merged Changes</td> - </tr> - <tr> - <td> - <span class="key modifier">g</span> - <span class="key">a</span> - </td> - <td>Go to Abandoned Changes</td> - </tr> - </tbody> - <!-- Change View --> - <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden> - <tr> - <td></td><td class="header">Navigation</td> - </tr> - <tr> - <td><span class="key">]</span></td> - <td>Show first file</td> - </tr> - <tr> - <td><span class="key">[</span></td> - <td>Show last file</td> - </tr> - <tr> - <td><span class="key">u</span></td> - <td>Up to dashboard</td> - </tr> - </tbody> - <!-- Diff View --> - <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden> - <tr> - <td></td><td class="header">Navigation</td> - </tr> - <tr> - <td><span class="key">]</span></td> - <td>Show next file</td> - </tr> - <tr> - <td><span class="key">[</span></td> - <td>Show previous file</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">j</span> - </td> - <td>Show next file that has comments</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">k</span> - </td> - <td>Show previous file that has comments</td> - </tr> - <tr> - <td><span class="key">u</span></td> - <td>Up to change</td> - </tr> - </tbody> - </table> - - <table> - <!-- Change List --> - <tbody hidden$="[[!_computeInView(view, 'search')]]" hidden> - <tr> - <td></td><td class="header">Change list</td> - </tr> - <tr> - <td><span class="key">j</span></td> - <td>Select next change</td> - </tr> - <tr> - <td><span class="key">k</span></td> - <td>Show previous change</td> - </tr> - <tr> - <td><span class="key">n</span> or <span class="key">]</span></td> - <td>Go to next page</td> - </tr> - <tr> - <td><span class="key">p</span> or <span class="key">[</span></td> - <td>Go to previous page</td> - </tr> - <tr> - <td> - <span class="key">Enter</span> or - <span class="key">o</span> - </td> - <td>Show selected change</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">r</span> - </td> - <td>Refresh list of changes</td> - </tr> - <tr> - <td><span class="key">s</span></td> - <td>Star (or unstar) change</td> - </tr> - </tbody> - <!-- Dashboard --> - <tbody hidden$="[[!_computeInView(view, 'dashboard')]]" hidden> - <tr> - <td></td><td class="header">Dashboard</td> - </tr> - <tr> - <td><span class="key">j</span></td> - <td>Select next change</td> - </tr> - <tr> - <td><span class="key">k</span></td> - <td>Show previous change</td> - </tr> - <tr> - <td> - <span class="key">Enter</span> or - <span class="key">o</span> - </td> - <td>Show selected change</td> - </tr> - <tr> - <td><span class="key">r</span></td> - <td>Mark (or unmark) change as reviewed</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">r</span> - </td> - <td>Refresh list of changes</td> - </tr> - <tr> - <td><span class="key">s</span></td> - <td>Star (or unstar) change</td> - </tr> - </tbody> - <!-- Change View --> - <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden> - <tr> - <td></td><td class="header">Actions</td> - </tr> - <tr> - <td><span class="key">a</span></td> - <td>Open reply dialog to publish comments and add reviewers</td> - </tr> - <tr> - <td><span class="key">d</span></td> - <td>Open download overlay</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">r</span> - </td> - <td>Reload the change at the latest patch</td> - </tr> - <tr> - <td><span class="key">s</span></td> - <td>Star (or unstar) change</td> - </tr> - <tr> - <td><span class="key">x</span></td> - <td>Expand all messages</td> - </tr> - <tr> - <td><span class="key">z</span></td> - <td>Collapse all messages</td> - </tr> - <tr> - <td></td><td class="header">Reply dialog</td> - </tr> - <tr> - <td> - <span class="key modifier">Ctrl</span> - <span class="key">Enter</span><br/> - </td> - <td>Send reply</td> - </tr> - <tr> - <td> - <span class="key modifier">Meta</span> - <span class="key">Enter</span> - </td> - <td>Send reply</td> - </tr> - <tr> - <td></td><td class="header">File list</td> - </tr> - <tr> - <td><span class="key">j</span> or <span class="key">↓</span></td> - <td>Select next file</td> - </tr> - <tr> - <td><span class="key">k</span> or <span class="key">↑</span></td> - <td>Select previous file</td> - </tr> - <tr> - <td> - <span class="key">Enter</span> or - <span class="key">o</span> - </td> - <td>Go to selected file</td> - </tr> - <tr> - <td><span class="key">r</span></td> - <td>Toggle review flag on selected file</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">i</span> - </td> - <td>Show/hide all inline diffs</td> - </tr> - <tr> - <td><span class="key">i</span></td> - <td>Show/hide selected inline diff</td> - </tr> - <tr> - <td></td><td class="header">Diffs</td> - </tr> - <tr> - <td><span class="key">j</span> or <span class="key">↓</span></td> - <td>Go to next line</td> - </tr> - <tr> - <td><span class="key">k</span> or <span class="key">↑</span></td> - <td>Go to previous line</td> - </tr> - <tr> - <td><span class="key">n</span></td> - <td>Go to next diff chunk</td> - </tr> - <tr> - <td><span class="key">p</span></td> - <td>Go to previous diff chunk</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">n</span> - </td> - <td>Go to next comment thread</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">p</span> - </td> - <td>Go to previous comment thread</td> - </tr> - <tr> - <td><span class="key">e</span></td> - <td>Expand all comment threads</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">e</span> - </td> - <td>Collapse all comment threads</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">←</span> - </td> - <td>Select left pane</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">→</span> - </td> - <td>Select right pane</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">a</span> - </td> - <td>Hide/show left diff</td> - </tr> - <tr> - <td> - <span class="key">m</span> - </td> - <td>Toggle unified/side-by-side diff</td> - </tr> - <tr> - <td> - <span class="key">c</span> - </td> - <td>Draft new comment</td> - </tr> - </tbody> - <!-- Diff View --> - <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden> - <tr> - <td></td><td class="header">Actions</td> - </tr> - <tr> - <td><span class="key">j</span> or <span class="key">↓</span></td> - <td>Show next line</td> - </tr> - <tr> - <td><span class="key">k</span> or <span class="key">↑</span></td> - <td>Show previous line</td> - </tr> - <tr> - <td><span class="key">n</span></td> - <td>Show next diff chunk</td> - </tr> - <tr> - <td><span class="key">p</span></td> - <td>Show previous diff chunk</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">x</span> - </td> - <td>Expand all diff context</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">n</span> - </td> - <td>Show next comment thread</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">p</span> - </td> - <td>Show previous comment thread</td> - </tr> - <tr> - <td><span class="key">e</span></td> - <td>Expand all comment threads</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">e</span> - </td> - <td>Collapse all comment threads</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">←</span> - </td> - <td>Select left pane</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">→</span> - </td> - <td>Select right pane</td> - </tr> - <tr> - <td> - <span class="key modifier">Shift</span> - <span class="key">a</span> - </td> - <td>Hide/show left diff</td> - </tr> - <tr> - <td> - <span class="key">m</span> - </td> - <td>Toggle unified/side-by-side diff</td> - </tr> - <tr> - <td> - <span class="key">c</span> - </td> - <td>Draft new comment</td> - </tr> - <tr> - <td> - <span class="key modifier">Ctrl</span> - <span class="key">s</span><br/> - </td> - <td>Save comment</td> - </tr> - <tr> - <td> - <span class="key modifier">Ctrl</span> - <span class="key">Enter</span><br/> - </td> - <td>Save comment</td> - </tr> - <tr> - <td> - <span class="key modifier">Meta</span> - <span class="key">Enter</span> - </td> - <td>Save comment</td> - </tr> - <tr> - <td><span class="key">a</span></td> - <td>Open reply dialog to publish comments and add reviewers</td> - </tr> - <tr> - <td><span class="key">,</span></td> - <td>Show diff preferences</td> - </tr> - <tr> - <td><span class="key">r</span></td> - <td>Mark/unmark file as reviewed</td> - </tr> + <template is="dom-repeat" items="[[_left]]"> + <tr> + <td></td><td class="header">[[item.section]]</td> + </tr> + <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut"> + <tr> + <td> + <gr-key-binding-display binding="[[shortcut.binding]]"> + </gr-key-binding-display> + </td> + <td>[[shortcut.text]]</td> + </tr> + </template> + </template> </tbody> </table> + <template is="dom-if" if="[[_right]]"> + <table> + <tbody> + <template is="dom-repeat" items="[[_right]]"> + <tr> + <td></td><td class="header">[[item.section]]</td> + </tr> + <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut"> + <tr> + <td> + <gr-key-binding-display binding="[[shortcut.binding]]"> + </gr-key-binding-display> + </td> + <td>[[shortcut.text]]</td> + </tr> + </template> + </template> + </tbody> + </table> + </template> </main> <footer></footer> </template> diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js index e5dd019eec..5b29972e40 100644 --- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js +++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js @@ -17,6 +17,8 @@ (function() { 'use strict'; + const {ShortcutSection} = window.Gerrit.KeyboardShortcutBinder; + Polymer({ is: 'gr-keyboard-shortcuts-dialog', @@ -27,20 +29,97 @@ */ properties: { - view: String, + _left: Array, + _right: Array, + + _propertyBySection: { + type: Object, + value() { + return { + [ShortcutSection.EVERYWHERE]: '_everywhere', + [ShortcutSection.NAVIGATION]: '_navigation', + [ShortcutSection.DASHBOARD]: '_dashboard', + [ShortcutSection.CHANGE_LIST]: '_changeList', + [ShortcutSection.ACTIONS]: '_actions', + [ShortcutSection.REPLY_DIALOG]: '_replyDialog', + [ShortcutSection.FILE_LIST]: '_fileList', + [ShortcutSection.DIFFS]: '_diffs', + }; + }, + }, }, + behaviors: [ + Gerrit.KeyboardShortcutBehavior, + ], + hostAttributes: { role: 'dialog', }, - _computeInView(currentView, view) { - return view === currentView; + attached() { + this.addKeyboardShortcutDirectoryListener( + this._onDirectoryUpdated.bind(this)); + }, + + detached() { + this.removeKeyboardShortcutDirectoryListener( + this._onDirectoryUpdated.bind(this)); }, _handleCloseTap(e) { e.preventDefault(); this.fire('close', null, {bubbles: false}); }, + + _onDirectoryUpdated(directory) { + const left = []; + const right = []; + + if (directory.has(ShortcutSection.EVERYWHERE)) { + left.push({ + section: ShortcutSection.EVERYWHERE, + shortcuts: directory.get(ShortcutSection.EVERYWHERE), + }); + } + + if (directory.has(ShortcutSection.NAVIGATION)) { + left.push({ + section: ShortcutSection.NAVIGATION, + shortcuts: directory.get(ShortcutSection.NAVIGATION), + }); + } + + if (directory.has(ShortcutSection.ACTIONS)) { + right.push({ + section: ShortcutSection.ACTIONS, + shortcuts: directory.get(ShortcutSection.ACTIONS), + }); + } + + if (directory.has(ShortcutSection.REPLY_DIALOG)) { + right.push({ + section: ShortcutSection.REPLY_DIALOG, + shortcuts: directory.get(ShortcutSection.REPLY_DIALOG), + }); + } + + if (directory.has(ShortcutSection.FILE_LIST)) { + right.push({ + section: ShortcutSection.FILE_LIST, + shortcuts: directory.get(ShortcutSection.FILE_LIST), + }); + } + + if (directory.has(ShortcutSection.DIFFS)) { + right.push({ + section: ShortcutSection.DIFFS, + shortcuts: directory.get(ShortcutSection.DIFFS), + }); + } + + this.set('_left', left); + this.set('_right', right); + }, }); })(); diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html new file mode 100644 index 0000000000..50579dda40 --- /dev/null +++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html @@ -0,0 +1,179 @@ +<!DOCTYPE html> +<!-- +@license +Copyright (C) 2018 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. +--> +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-key-binding-display</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<link rel="import" href="../../../test/common-test-setup.html"/> +<link rel="import" href="gr-keyboard-shortcuts-dialog.html"> + +<script>void(0);</script> + +<test-fixture id="basic"> + <template> + <gr-keyboard-shortcuts-dialog></gr-keyboard-shortcuts-dialog> + </template> +</test-fixture> + +<script> + suite('gr-keyboard-shortcuts-dialog tests', () => { + const kb = window.Gerrit.KeyboardShortcutBinder; + let element; + + setup(() => { + element = fixture('basic'); + }); + + function update(directory) { + element._onDirectoryUpdated(directory); + flushAsynchronousOperations(); + } + + suite('_left and _right contents', () => { + test('empty dialog', () => { + assert.strictEqual(element._left.length, 0); + assert.strictEqual(element._right.length, 0); + }); + + test('everywhere goes on left', () => { + update(new Map([ + [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']], + ])); + assert.deepEqual( + element._left, + [ + { + section: kb.ShortcutSection.EVERYWHERE, + shortcuts: ['everywhere shortcuts'], + }, + ]); + assert.strictEqual(element._right.length, 0); + }); + + test('navigation goes on left', () => { + update(new Map([ + [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']], + ])); + assert.deepEqual( + element._left, + [ + { + section: kb.ShortcutSection.NAVIGATION, + shortcuts: ['navigation shortcuts'], + }, + ]); + assert.strictEqual(element._right.length, 0); + }); + + test('actions go on right', () => { + update(new Map([ + [kb.ShortcutSection.ACTIONS, ['actions shortcuts']], + ])); + assert.deepEqual( + element._right, + [ + { + section: kb.ShortcutSection.ACTIONS, + shortcuts: ['actions shortcuts'], + }, + ]); + assert.strictEqual(element._left.length, 0); + }); + + test('reply dialog goes on right', () => { + update(new Map([ + [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']], + ])); + assert.deepEqual( + element._right, + [ + { + section: kb.ShortcutSection.REPLY_DIALOG, + shortcuts: ['reply dialog shortcuts'], + }, + ]); + assert.strictEqual(element._left.length, 0); + }); + + test('file list goes on right', () => { + update(new Map([ + [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']], + ])); + assert.deepEqual( + element._right, + [ + { + section: kb.ShortcutSection.FILE_LIST, + shortcuts: ['file list shortcuts'], + }, + ]); + assert.strictEqual(element._left.length, 0); + }); + + test('diffs go on right', () => { + update(new Map([ + [kb.ShortcutSection.DIFFS, ['diffs shortcuts']], + ])); + assert.deepEqual( + element._right, + [ + { + section: kb.ShortcutSection.DIFFS, + shortcuts: ['diffs shortcuts'], + }, + ]); + assert.strictEqual(element._left.length, 0); + }); + + test('multiple sections on each side', () => { + update(new Map([ + [kb.ShortcutSection.ACTIONS, ['actions shortcuts']], + [kb.ShortcutSection.DIFFS, ['diffs shortcuts']], + [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']], + [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']], + ])); + assert.deepEqual( + element._left, + [ + { + section: kb.ShortcutSection.EVERYWHERE, + shortcuts: ['everywhere shortcuts'], + }, + { + section: kb.ShortcutSection.NAVIGATION, + shortcuts: ['navigation shortcuts'], + }, + ]); + assert.deepEqual( + element._right, + [ + { + section: kb.ShortcutSection.ACTIONS, + shortcuts: ['actions shortcuts'], + }, + { + section: kb.ShortcutSection.DIFFS, + shortcuts: ['diffs shortcuts'], + }, + ]); + }); + }); + }); +</script> + diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js index 1513a7f343..a81526ceb1 100644 --- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js +++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js @@ -110,10 +110,6 @@ Gerrit.URLEncodingBehavior, ], - keyBindings: { - '/': '_handleForwardSlashKey', - }, - properties: { value: { type: String, @@ -156,6 +152,12 @@ }, }, + keyboardShortcuts() { + return { + [this.Shortcut.SEARCH]: '_handleSearch', + }; + }, + _valueChanged(value) { this._inputVal = value; }, @@ -274,7 +276,7 @@ }); }, - _handleForwardSlashKey(e) { + _handleSearch(e) { const keyboardEvent = this.getKeyboardEvent(e); if (this.shouldSuppressKeyboardShortcut(e) || (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; } diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html index 9a7023e4fa..93e0e30739 100644 --- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html +++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html @@ -37,6 +37,9 @@ limitations under the License. <script> suite('gr-search-bar tests', () => { + const kb = window.Gerrit.KeyboardShortcutBinder; + kb.bindShortcut(kb.Shortcut.SEARCH, '/'); + let element; let sandbox; diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js index b0eb42337b..0f717e7b46 100644 --- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js +++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js @@ -177,22 +177,41 @@ ], keyBindings: { - 'esc': '_handleEscKey', - 'shift+left': '_handleShiftLeftKey', - 'shift+right': '_handleShiftRightKey', - 'up k': '_handleUpKey', - 'down j': '_handleDownKey', - 'c': '_handleCKey', - '[': '_handleLeftBracketKey', - ']': '_handleRightBracketKey', - 'n shift+n': '_handleNKey', - 'p shift+p': '_handlePKey', - 'a shift+a': '_handleAKey', - 'u': '_handleUKey', - ',': '_handleCommaKey', - 'm': '_handleMKey', - 'r': '_handleRKey', - 'shift+x': '_handleShiftXKey', + esc: '_handleEscKey', + }, + + keyboardShortcuts() { + return { + [this.Shortcut.LEFT_PANE]: '_handleLeftPane', + [this.Shortcut.RIGHT_PANE]: '_handleRightPane', + [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments', + [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments', + [this.Shortcut.NEXT_FILE_WITH_COMMENTS]: + '_handleNextLineOrFileWithComments', + [this.Shortcut.PREV_FILE_WITH_COMMENTS]: + '_handlePrevLineOrFileWithComments', + [this.Shortcut.NEW_COMMENT]: '_handleNewComment', + [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding + [this.Shortcut.NEXT_FILE]: '_handleNextFile', + [this.Shortcut.PREV_FILE]: '_handlePrevFile', + [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread', + [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread', + [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread', + [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread', + [this.Shortcut.OPEN_REPLY_DIALOG]: + '_handleOpenReplyDialogOrToggleLeftPane', + [this.Shortcut.TOGGLE_LEFT_PANE]: + '_handleOpenReplyDialogOrToggleLeftPane', + [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange', + [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey', + [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode', + [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed', + [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext', + + // Final two are actually handled by gr-diff-comment-thread. + [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null, + [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null, + }; }, attached() { @@ -263,7 +282,7 @@ this._patchRange.patchNum, this._path, reviewed); }, - _handleRKey(e) { + _handleToggleFileReviewed(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -279,21 +298,21 @@ this.$.diffHost.displayLine = false; }, - _handleShiftLeftKey(e) { + _handleLeftPane(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); this.$.cursor.moveLeft(); }, - _handleShiftRightKey(e) { + _handleRightPane(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); this.$.cursor.moveRight(); }, - _handleUpKey(e) { + _handlePrevLineOrFileWithComments(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (e.detail.keyboardEvent.shiftKey && e.detail.keyboardEvent.keyCode === 75) { // 'K' @@ -307,7 +326,7 @@ this.$.cursor.moveUp(); }, - _handleDownKey(e) { + _handleNextLineOrFileWithComments(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (e.detail.keyboardEvent.shiftKey && e.detail.keyboardEvent.keyCode === 74) { // 'J' @@ -348,7 +367,7 @@ this._patchRange.patchNum, this._patchRange.basePatchNum); }, - _handleCKey(e) { + _handleNewComment(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (this.$.diffHost.isRangeSelected()) { return; } if (this.modifierPressed(e)) { return; } @@ -360,7 +379,7 @@ } }, - _handleLeftBracketKey(e) { + _handlePrevFile(e) { // Check for meta key to avoid overriding native chrome shortcut. if (this.shouldSuppressKeyboardShortcut(e) || this.getKeyboardEvent(e).metaKey) { return; } @@ -369,7 +388,7 @@ this._navToFile(this._path, this._fileList, -1); }, - _handleRightBracketKey(e) { + _handleNextFile(e) { // Check for meta key to avoid overriding native chrome shortcut. if (this.shouldSuppressKeyboardShortcut(e) || this.getKeyboardEvent(e).metaKey) { return; } @@ -378,7 +397,7 @@ this._navToFile(this._path, this._fileList, 1); }, - _handleNKey(e) { + _handleNextChunkOrCommentThread(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); @@ -390,7 +409,7 @@ } }, - _handlePKey(e) { + _handlePrevChunkOrCommentThread(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); @@ -402,7 +421,7 @@ } }, - _handleAKey(e) { + _handleOpenReplyDialogOrToggleLeftPane(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (e.detail.keyboardEvent.shiftKey) { // Hide left diff. @@ -420,7 +439,7 @@ this._navToChangeView(); }, - _handleUKey(e) { + _handleUpToChange(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -436,7 +455,7 @@ this.$.diffPreferences.open(); }, - _handleMKey(e) { + _handleToggleDiffMode(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } @@ -989,7 +1008,7 @@ return ''; }, - _handleShiftXKey(e) { + _handleExpandAllDiffContext(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } this.$.diffHost.expandAllContext(); }, diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html index 00527e4b28..2a0a0f3963 100644 --- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html +++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html @@ -43,6 +43,31 @@ limitations under the License. <script> suite('gr-diff-view tests', () => { + const kb = window.Gerrit.KeyboardShortcutBinder; + kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left'); + kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right'); + kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down'); + kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up'); + kb.bindShortcut(kb.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j'); + kb.bindShortcut(kb.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k'); + kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c'); + kb.bindShortcut(kb.Shortcut.SAVE_COMMENT, 'ctrl+s'); + kb.bindShortcut(kb.Shortcut.NEXT_FILE, ']'); + kb.bindShortcut(kb.Shortcut.PREV_FILE, '['); + kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n'); + kb.bindShortcut(kb.Shortcut.NEXT_COMMENT_THREAD, 'shift+n'); + kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p'); + kb.bindShortcut(kb.Shortcut.PREV_COMMENT_THREAD, 'shift+p'); + kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a'); + kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a'); + kb.bindShortcut(kb.Shortcut.UP_TO_CHANGE, 'u'); + kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ','); + kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm'); + kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r'); + kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x'); + kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e'); + kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e'); + let element; let sandbox; @@ -838,16 +863,16 @@ limitations under the License. assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE'); }); - test('_handleMKey', () => { + test('_handleToggleDiffMode', () => { sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); const e = {preventDefault: () => {}}; // Initial state. assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE'); - element._handleMKey(e); + element._handleToggleDiffMode(e); assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF'); - element._handleMKey(e); + element._handleToggleDiffMode(e); assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE'); }); diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js index 0508608ed2..b0cc514d60 100644 --- a/polygerrit-ui/app/elements/gr-app.js +++ b/polygerrit-ui/app/elements/gr-app.js @@ -17,10 +17,6 @@ (function() { 'use strict'; - // 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 G_KEY_TIMEOUT_MS = 1000; - // Eagerly render Polymer components when backgrounded. (Skips // requestAnimationFrame.) // @see https://github.com/Polymer/polymer/issues/3851 @@ -112,11 +108,17 @@ Gerrit.KeyboardShortcutBehavior, ], - keyBindings: { - '?': '_showKeyboardShortcuts', - 'g:keydown': '_gKeyDown', - 'g:keyup': '_gKeyUp', - 'a m o': '_jumpKeyPressed', + keyboardShortcuts() { + return { + [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts', + [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges', + [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges', + [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges', + }; + }, + + created() { + this._bindKeyboardShortcuts(); }, ready() { @@ -171,6 +173,118 @@ }; }, + _bindKeyboardShortcuts() { + this.bindShortcut(this.Shortcut.SEND_REPLY, + this.DOC_ONLY, 'ctrl+enter', 'meta+enter'); + + this.bindShortcut( + this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?'); + this.bindShortcut( + this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o'); + this.bindShortcut( + this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm'); + this.bindShortcut( + this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a'); + + this.bindShortcut( + this.Shortcut.CURSOR_NEXT_CHANGE, 'j'); + this.bindShortcut( + this.Shortcut.CURSOR_PREV_CHANGE, 'k'); + this.bindShortcut( + this.Shortcut.OPEN_CHANGE, 'o'); + this.bindShortcut( + this.Shortcut.NEXT_PAGE, 'n', ']'); + this.bindShortcut( + this.Shortcut.PREV_PAGE, 'p', '['); + this.bindShortcut( + this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r'); + this.bindShortcut( + this.Shortcut.TOGGLE_CHANGE_STAR, 's'); + this.bindShortcut( + this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r'); + + this.bindShortcut( + this.Shortcut.OPEN_REPLY_DIALOG, 'a'); + this.bindShortcut( + this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd'); + this.bindShortcut( + this.Shortcut.EXPAND_ALL_MESSAGES, 'x'); + this.bindShortcut( + this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z'); + this.bindShortcut( + this.Shortcut.REFRESH_CHANGE, 'shift+r'); + this.bindShortcut( + this.Shortcut.UP_TO_DASHBOARD, 'u'); + this.bindShortcut( + this.Shortcut.UP_TO_CHANGE, 'u'); + this.bindShortcut( + this.Shortcut.TOGGLE_DIFF_MODE, 'm'); + + this.bindShortcut( + this.Shortcut.NEXT_LINE, 'j', 'down'); + this.bindShortcut( + this.Shortcut.PREV_LINE, 'k', 'up'); + this.bindShortcut( + this.Shortcut.NEXT_CHUNK, 'n'); + this.bindShortcut( + this.Shortcut.PREV_CHUNK, 'p'); + this.bindShortcut( + this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x'); + this.bindShortcut( + this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n'); + this.bindShortcut( + this.Shortcut.PREV_COMMENT_THREAD, 'shift+p'); + this.bindShortcut( + this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e'); + this.bindShortcut( + this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, + this.DOC_ONLY, 'shift+e'); + this.bindShortcut( + this.Shortcut.LEFT_PANE, 'shift+left'); + this.bindShortcut( + this.Shortcut.RIGHT_PANE, 'shift+right'); + this.bindShortcut( + this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a'); + this.bindShortcut( + this.Shortcut.NEW_COMMENT, 'c'); + this.bindShortcut( + this.Shortcut.SAVE_COMMENT, + 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s'); + this.bindShortcut( + this.Shortcut.OPEN_DIFF_PREFS, ','); + this.bindShortcut( + this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r'); + + this.bindShortcut( + this.Shortcut.NEXT_FILE, ']'); + this.bindShortcut( + this.Shortcut.PREV_FILE, '['); + this.bindShortcut( + this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j'); + this.bindShortcut( + this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k'); + this.bindShortcut( + this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down'); + this.bindShortcut( + this.Shortcut.CURSOR_PREV_FILE, 'k', 'up'); + this.bindShortcut( + this.Shortcut.OPEN_FILE, 'o', 'enter'); + this.bindShortcut( + this.Shortcut.TOGGLE_FILE_REVIEWED, 'r'); + this.bindShortcut( + this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup'); + this.bindShortcut( + this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup'); + + this.bindShortcut( + this.Shortcut.OPEN_FIRST_FILE, ']'); + this.bindShortcut( + this.Shortcut.OPEN_LAST_FILE, '['); + + this.bindShortcut( + this.Shortcut.SEARCH, '/'); + }, + _accountChanged(account) { if (!account) { return; } @@ -293,32 +407,16 @@ return isShadowDom ? 'shadow' : ''; }, - _gKeyDown(e) { - if (this.modifierPressed(e)) { return; } - this._lastGKeyPressTimestamp = Date.now(); + _goToOpenedChanges() { + Gerrit.Nav.navigateToStatusSearch('open'); }, - _gKeyUp() { - this._lastGKeyPressTimestamp = null; + _goToMergedChanges() { + Gerrit.Nav.navigateToStatusSearch('merged'); }, - _jumpKeyPressed(e) { - if (!this._lastGKeyPressTimestamp || - (Date.now() - this._lastGKeyPressTimestamp > G_KEY_TIMEOUT_MS) || - this.shouldSuppressKeyboardShortcut(e)) { return; } - e.preventDefault(); - - let status = null; - if (e.detail.key === 'a') { - status = 'abandoned'; - } else if (e.detail.key === 'm') { - status = 'merged'; - } else if (e.detail.key === 'o') { - status = 'open'; - } - if (status !== null) { - Gerrit.Nav.navigateToStatusSearch(status); - } + _goToAbandonedChanges() { + Gerrit.Nav.navigateToStatusSearch('abandoned'); }, _computePluginScreenName({plugin, screen}) { diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html index fb1b241c7e..734d2fe96c 100644 --- a/polygerrit-ui/app/elements/gr-app_test.html +++ b/polygerrit-ui/app/elements/gr-app_test.html @@ -114,55 +114,5 @@ limitations under the License. element._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}}); assert.ok(element._lastSearchPage); }); - - suite('_jumpKeyPressed', () => { - let navStub; - - setup(() => { - navStub = sandbox.stub(Gerrit.Nav, 'navigateToStatusSearch'); - sandbox.stub(Date, 'now').returns(10000); - }); - - test('success', () => { - const e = {detail: {key: 'a'}, preventDefault: () => {}}; - sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); - element._lastGKeyPressTimestamp = 9000; - element._jumpKeyPressed(e); - assert.isTrue(navStub.calledOnce); - assert.equal(navStub.lastCall.args[0], 'abandoned'); - }); - - test('no g key', () => { - const e = {detail: {key: 'a'}, preventDefault: () => {}}; - sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); - element._lastGKeyPressTimestamp = null; - element._jumpKeyPressed(e); - assert.isFalse(navStub.called); - }); - - test('g key too long ago', () => { - const e = {detail: {key: 'a'}, preventDefault: () => {}}; - sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); - element._lastGKeyPressTimestamp = 3000; - element._jumpKeyPressed(e); - assert.isFalse(navStub.called); - }); - - test('should suppress', () => { - const e = {detail: {key: 'a'}, preventDefault: () => {}}; - sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true); - element._lastGKeyPressTimestamp = 9000; - element._jumpKeyPressed(e); - assert.isFalse(navStub.called); - }); - - test('unrecognized key', () => { - const e = {detail: {key: 'f'}, preventDefault: () => {}}; - sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false); - element._lastGKeyPressTimestamp = 9000; - element._jumpKeyPressed(e); - assert.isFalse(navStub.called); - }); - }); }); </script> diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html index 08324597f8..5b9ae1576e 100644 --- a/polygerrit-ui/app/test/index.html +++ b/polygerrit-ui/app/test/index.html @@ -92,6 +92,8 @@ limitations under the License. 'core/gr-account-dropdown/gr-account-dropdown_test.html', 'core/gr-error-dialog/gr-error-dialog_test.html', 'core/gr-error-manager/gr-error-manager_test.html', + 'core/gr-key-binding-display/gr-key-binding-display_test.html', + 'core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html', 'core/gr-main-header/gr-main-header_test.html', 'core/gr-navigation/gr-navigation_test.html', 'core/gr-reporting/gr-jank-detector_test.html', |