// Copyright 2006 The Closure Library Authors. All Rights Reserved. // // 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. /** * @fileoverview Generic keyboard shortcut handler. * * @author eae@google.com (Emil A Eklund) * @see ../demos/keyboardshortcuts.html */ goog.provide('goog.ui.KeyboardShortcutEvent'); goog.provide('goog.ui.KeyboardShortcutHandler'); goog.provide('goog.ui.KeyboardShortcutHandler.EventType'); goog.provide('goog.ui.KeyboardShortcutHandler.Modifiers'); goog.require('goog.Timer'); goog.require('goog.array'); goog.require('goog.asserts'); goog.require('goog.dom.TagName'); goog.require('goog.events'); goog.require('goog.events.Event'); goog.require('goog.events.EventTarget'); goog.require('goog.events.EventType'); goog.require('goog.events.KeyCodes'); goog.require('goog.events.KeyNames'); goog.require('goog.events.Keys'); goog.require('goog.object'); goog.require('goog.userAgent'); /** * Component for handling keyboard shortcuts. A shortcut is registered and bound * to a specific identifier. Once the shortcut is triggered an event is fired * with the identifier for the shortcut. This allows keyboard shortcuts to be * customized without modifying the code that listens for them. * * Supports keyboard shortcuts triggered by a single key, a stroke stroke (key * plus at least one modifier) and a sequence of keys or strokes. * * @param {goog.events.EventTarget|EventTarget} keyTarget Event target that the * key event listener is attached to, typically the applications root * container. * @constructor * @extends {goog.events.EventTarget} */ goog.ui.KeyboardShortcutHandler = function(keyTarget) { goog.events.EventTarget.call(this); /** * Registered keyboard shortcuts tree. Stored as a map with the keyCode and * modifier(s) as the key and either a list of further strokes or the shortcut * task identifier as the value. * @type {!goog.ui.KeyboardShortcutHandler.SequenceTree_} * @see #makeStroke_ * @private */ this.shortcuts_ = {}; /** * The currently active shortcut sequence tree, which represents the position * in the complete shortcuts_ tree reached by recent key strokes. * @type {!goog.ui.KeyboardShortcutHandler.SequenceTree_} * @private */ this.currentTree_ = this.shortcuts_; /** * The time (in ms, epoch time) of the last keystroke which made progress in * the shortcut sequence tree (i.e. the time that currentTree_ was last set). * Used for timing out stroke sequences. * @type {number} * @private */ this.lastStrokeTime_ = 0; /** * List of numeric key codes for keys that are safe to always regarded as * shortcuts, even if entered in a textarea or input field. * @type {Object} * @private */ this.globalKeys_ = goog.object.createSet( goog.ui.KeyboardShortcutHandler.DEFAULT_GLOBAL_KEYS_); /** * List of input types that should only accept ENTER as a shortcut. * @type {Object} * @private */ this.textInputs_ = goog.object.createSet( goog.ui.KeyboardShortcutHandler.DEFAULT_TEXT_INPUTS_); /** * Whether to always prevent the default action if a shortcut event is fired. * @type {boolean} * @private */ this.alwaysPreventDefault_ = true; /** * Whether to always stop propagation if a shortcut event is fired. * @type {boolean} * @private */ this.alwaysStopPropagation_ = false; /** * Whether to treat all shortcuts as if they had been passed * to setGlobalKeys(). * @type {boolean} * @private */ this.allShortcutsAreGlobal_ = false; /** * Whether to treat shortcuts with modifiers as if they had been passed * to setGlobalKeys(). Ignored if allShortcutsAreGlobal_ is true. Applies * only to form elements (not content-editable). * @type {boolean} * @private */ this.modifierShortcutsAreGlobal_ = true; /** * Whether to treat space key as a shortcut when the focused element is a * checkbox, radiobutton or button. * @type {boolean} * @private */ this.allowSpaceKeyOnButtons_ = false; /** * Tracks the currently pressed shortcut key, for Firefox. * @type {?number} * @private */ this.activeShortcutKeyForGecko_ = null; this.initializeKeyListener(keyTarget); }; goog.inherits(goog.ui.KeyboardShortcutHandler, goog.events.EventTarget); goog.tagUnsealableClass(goog.ui.KeyboardShortcutHandler); /** * A node in a keyboard shortcut sequence tree. A node is either: * 1. A terminal node with a non-nullable shortcut string which is the * identifier for the shortcut triggered by traversing the tree to that node. * 2. An internal node with a null shortcut string and a * {@code goog.ui.KeyboardShortcutHandler.SequenceTree_} representing the * continued stroke sequences from this node. * For clarity, the static factory methods for creating internal and terminal * nodes below should be used rather than using this constructor directly. * @param {string=} opt_shortcut The shortcut identifier, for terminal nodes. * @constructor * @struct * @private */ goog.ui.KeyboardShortcutHandler.SequenceNode_ = function(opt_shortcut) { /** @const {?string} The shorcut action identifier, for terminal nodes. */ this.shortcut = opt_shortcut || null; /** @const {goog.ui.KeyboardShortcutHandler.SequenceTree_} */ this.next = opt_shortcut ? null : {}; }; /** * Creates a terminal shortcut sequence node for the given shortcut identifier. * @param {string} shortcut The shortcut identifier. * @return {!goog.ui.KeyboardShortcutHandler.SequenceNode_} * @private */ goog.ui.KeyboardShortcutHandler.createTerminalNode_ = function(shortcut) { return new goog.ui.KeyboardShortcutHandler.SequenceNode_(shortcut); }; /** * Creates an internal shortcut sequence node - a non-terminal part of a * keyboard sequence. * @return {!goog.ui.KeyboardShortcutHandler.SequenceNode_} * @private */ goog.ui.KeyboardShortcutHandler.createInternalNode_ = function() { return new goog.ui.KeyboardShortcutHandler.SequenceNode_(); }; /** * A map of strokes (represented as strings) to the nodes reached by those * strokes. * @typedef {Object} * @private */ goog.ui.KeyboardShortcutHandler.SequenceTree_; /** * Maximum allowed delay, in milliseconds, allowed between the first and second * key in a key sequence. * @type {number} */ goog.ui.KeyboardShortcutHandler.MAX_KEY_SEQUENCE_DELAY = 1500; // 1.5 sec /** * Bit values for modifier keys. * @enum {number} */ goog.ui.KeyboardShortcutHandler.Modifiers = { NONE: 0, SHIFT: 1, CTRL: 2, ALT: 4, META: 8 }; /** * Keys marked as global by default. * @type {Array} * @private */ goog.ui.KeyboardShortcutHandler.DEFAULT_GLOBAL_KEYS_ = [ goog.events.KeyCodes.ESC, goog.events.KeyCodes.F1, goog.events.KeyCodes.F2, goog.events.KeyCodes.F3, goog.events.KeyCodes.F4, goog.events.KeyCodes.F5, goog.events.KeyCodes.F6, goog.events.KeyCodes.F7, goog.events.KeyCodes.F8, goog.events.KeyCodes.F9, goog.events.KeyCodes.F10, goog.events.KeyCodes.F11, goog.events.KeyCodes.F12, goog.events.KeyCodes.PAUSE ]; /** * Text input types to allow only ENTER shortcuts. * Web Forms 2.0 for HTML5: Section 4.10.7 from 29 May 2012. * @type {Array} * @private */ goog.ui.KeyboardShortcutHandler.DEFAULT_TEXT_INPUTS_ = [ 'color', 'date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', 'search', 'tel', 'text', 'time', 'url', 'week' ]; /** * Events. * @enum {string} */ goog.ui.KeyboardShortcutHandler.EventType = { SHORTCUT_TRIGGERED: 'shortcut', SHORTCUT_PREFIX: 'shortcut_' }; /** * Cache for name to key code lookup. * @type {Object} * @private */ goog.ui.KeyboardShortcutHandler.nameToKeyCodeCache_; /** * Target on which to listen for key events. * @type {goog.events.EventTarget|EventTarget} * @private */ goog.ui.KeyboardShortcutHandler.prototype.keyTarget_; /** * Due to a bug in the way that Gecko on Mac handles cut/copy/paste key events * using the meta key, it is necessary to fake the keyDown for the action key * (C,V,X) by capturing it on keyUp. * Because users will often release the meta key a slight moment before they * release the action key, we need this variable that will store whether the * meta key has been released recently. * It will be cleared after a short delay in the key handling logic. * @type {boolean} * @private */ goog.ui.KeyboardShortcutHandler.prototype.metaKeyRecentlyReleased_; /** * Whether a key event is a printable-key event. Windows uses ctrl+alt * (alt-graph) keys to type characters on European keyboards. For such keys, we * cannot identify whether these keys are used for typing characters when * receiving keydown events. Therefore, we set this flag when we receive their * respective keypress events and fire shortcut events only when we do not * receive them. * @type {boolean} * @private */ goog.ui.KeyboardShortcutHandler.prototype.isPrintableKey_; /** * Static method for getting the key code for a given key. * @param {string} name Name of key. * @return {number} The key code. */ goog.ui.KeyboardShortcutHandler.getKeyCode = function(name) { // Build reverse lookup object the first time this method is called. if (!goog.ui.KeyboardShortcutHandler.nameToKeyCodeCache_) { var map = {}; for (var key in goog.events.KeyNames) { // Explicitly convert the stringified map keys to numbers and normalize. map[goog.events.KeyNames[key]] = goog.events.KeyCodes.normalizeKeyCode(parseInt(key, 10)); } goog.ui.KeyboardShortcutHandler.nameToKeyCodeCache_ = map; } // Check if key is in cache. return goog.ui.KeyboardShortcutHandler.nameToKeyCodeCache_[name]; }; /** * Sets whether to always prevent the default action when a shortcut event is * fired. If false, the default action is prevented only if preventDefault is * called on either of the corresponding SHORTCUT_TRIGGERED or SHORTCUT_PREFIX * events. If true, the default action is prevented whenever a shortcut event * is fired. The default value is true. * @param {boolean} alwaysPreventDefault Whether to always call preventDefault. */ goog.ui.KeyboardShortcutHandler.prototype.setAlwaysPreventDefault = function( alwaysPreventDefault) { this.alwaysPreventDefault_ = alwaysPreventDefault; }; /** * Returns whether the default action will always be prevented when a shortcut * event is fired. The default value is true. * @see #setAlwaysPreventDefault * @return {boolean} Whether preventDefault will always be called. */ goog.ui.KeyboardShortcutHandler.prototype.getAlwaysPreventDefault = function() { return this.alwaysPreventDefault_; }; /** * Sets whether to always stop propagation for the event when fired. If false, * the propagation is stopped only if stopPropagation is called on either of the * corresponding SHORT_CUT_TRIGGERED or SHORTCUT_PREFIX events. If true, the * event is prevented from propagating beyond its target whenever it is fired. * The default value is false. * @param {boolean} alwaysStopPropagation Whether to always call * stopPropagation. */ goog.ui.KeyboardShortcutHandler.prototype.setAlwaysStopPropagation = function( alwaysStopPropagation) { this.alwaysStopPropagation_ = alwaysStopPropagation; }; /** * Returns whether the event will always be stopped from propagating beyond its * target when a shortcut event is fired. The default value is false. * @see #setAlwaysStopPropagation * @return {boolean} Whether stopPropagation will always be called. */ goog.ui.KeyboardShortcutHandler.prototype.getAlwaysStopPropagation = function() { return this.alwaysStopPropagation_; }; /** * Sets whether to treat all shortcuts (including modifier shortcuts) as if the * keys had been passed to the setGlobalKeys function. * @param {boolean} allShortcutsGlobal Whether to treat all shortcuts as global. */ goog.ui.KeyboardShortcutHandler.prototype.setAllShortcutsAreGlobal = function( allShortcutsGlobal) { this.allShortcutsAreGlobal_ = allShortcutsGlobal; }; /** * Returns whether all shortcuts (including modifier shortcuts) are treated as * if the keys had been passed to the setGlobalKeys function. * @see #setAllShortcutsAreGlobal * @return {boolean} Whether all shortcuts are treated as globals. */ goog.ui.KeyboardShortcutHandler.prototype.getAllShortcutsAreGlobal = function() { return this.allShortcutsAreGlobal_; }; /** * Sets whether to treat shortcuts with modifiers as if the keys had been * passed to the setGlobalKeys function. Ignored if you have called * setAllShortcutsAreGlobal(true). Applies only to form elements (not * content-editable). * @param {boolean} modifierShortcutsGlobal Whether to treat shortcuts with * modifiers as global. */ goog.ui.KeyboardShortcutHandler.prototype.setModifierShortcutsAreGlobal = function(modifierShortcutsGlobal) { this.modifierShortcutsAreGlobal_ = modifierShortcutsGlobal; }; /** * Returns whether shortcuts with modifiers are treated as if the keys had been * passed to the setGlobalKeys function. Ignored if you have called * setAllShortcutsAreGlobal(true). Applies only to form elements (not * content-editable). * @see #setModifierShortcutsAreGlobal * @return {boolean} Whether shortcuts with modifiers are treated as globals. */ goog.ui.KeyboardShortcutHandler.prototype.getModifierShortcutsAreGlobal = function() { return this.modifierShortcutsAreGlobal_; }; /** * Sets whether to treat space key as a shortcut when the focused element is a * checkbox, radiobutton or button. * @param {boolean} allowSpaceKeyOnButtons Whether to treat space key as a * shortcut when the focused element is a checkbox, radiobutton or button. */ goog.ui.KeyboardShortcutHandler.prototype.setAllowSpaceKeyOnButtons = function( allowSpaceKeyOnButtons) { this.allowSpaceKeyOnButtons_ = allowSpaceKeyOnButtons; }; /** * Registers a keyboard shortcut. * @param {string} identifier Identifier for the task performed by the keyboard * combination. Multiple shortcuts can be provided for the same * task by specifying the same identifier. * @param {...(number|string|Array)} var_args See below. * * param {number} keyCode Numeric code for key * param {number=} opt_modifiers Bitmap indicating required modifier keys. * goog.ui.KeyboardShortcutHandler.Modifiers.SHIFT, CTRL, ALT, * or META. * * The last two parameters can be repeated any number of times to create a * shortcut using a sequence of strokes. Instead of varargs the second parameter * could also be an array where each element would be regarded as a parameter. * * A string representation of the shortcut can be supplied instead of the last * two parameters. In that case the method only takes two arguments, the * identifier and the string. * * Examples: * g registerShortcut(str, G_KEYCODE) * Ctrl+g registerShortcut(str, G_KEYCODE, CTRL) * Ctrl+Shift+g registerShortcut(str, G_KEYCODE, CTRL | SHIFT) * Ctrl+g a registerShortcut(str, G_KEYCODE, CTRL, A_KEYCODE) * Ctrl+g Shift+a registerShortcut(str, G_KEYCODE, CTRL, A_KEYCODE, SHIFT) * g a registerShortcut(str, G_KEYCODE, NONE, A_KEYCODE) * * Examples using string representation for shortcuts: * g registerShortcut(str, 'g') * Ctrl+g registerShortcut(str, 'ctrl+g') * Ctrl+Shift+g registerShortcut(str, 'ctrl+shift+g') * Ctrl+g a registerShortcut(str, 'ctrl+g a') * Ctrl+g Shift+a registerShortcut(str, 'ctrl+g shift+a') * g a registerShortcut(str, 'g a'). */ goog.ui.KeyboardShortcutHandler.prototype.registerShortcut = function( identifier, var_args) { // Add shortcut to shortcuts_ tree goog.ui.KeyboardShortcutHandler.setShortcut_( this.shortcuts_, this.interpretStrokes_(1, arguments), identifier); }; /** * Unregisters a keyboard shortcut by keyCode and modifiers or string * representation of sequence. * * param {number} keyCode Numeric code for key * param {number=} opt_modifiers Bitmap indicating required modifier keys. * goog.ui.KeyboardShortcutHandler.Modifiers.SHIFT, CTRL, ALT, * or META. * * The two parameters can be repeated any number of times to create a shortcut * using a sequence of strokes. * * A string representation of the shortcut can be supplied instead see * {@link #registerShortcut} for syntax. In that case the method only takes one * argument. * * @param {...(number|string|Array)} var_args String representation, or * array or list of alternating key codes and modifiers. */ goog.ui.KeyboardShortcutHandler.prototype.unregisterShortcut = function( var_args) { // Remove shortcut from tree. goog.ui.KeyboardShortcutHandler.unsetShortcut_( this.shortcuts_, this.interpretStrokes_(0, arguments)); }; /** * Verifies if a particular keyboard shortcut is registered already. It has * the same interface as the unregistering of shortcuts. * * param {number} keyCode Numeric code for key * param {number=} opt_modifiers Bitmap indicating required modifier keys. * goog.ui.KeyboardShortcutHandler.Modifiers.SHIFT, CTRL, ALT, * or META. * * The two parameters can be repeated any number of times to create a shortcut * using a sequence of strokes. * * A string representation of the shortcut can be supplied instead see * {@link #registerShortcut} for syntax. In that case the method only takes one * argument. * * @param {...(number|string|Array)} var_args String representation, or * array or list of alternating key codes and modifiers. * @return {boolean} Whether the specified keyboard shortcut is registered. */ goog.ui.KeyboardShortcutHandler.prototype.isShortcutRegistered = function( var_args) { return this.checkShortcut_( this.shortcuts_, this.interpretStrokes_(0, arguments)); }; /** * Parses the variable arguments for registerShortcut and unregisterShortcut. * @param {number} initialIndex The first index of "args" to treat as * variable arguments. * @param {Object} args The "arguments" array passed * to registerShortcut or unregisterShortcut. Please see the comments in * registerShortcut for list of allowed forms. * @return {!Array>} The sequence of strokes, * represented as arrays of strings. * @private */ goog.ui.KeyboardShortcutHandler.prototype.interpretStrokes_ = function( initialIndex, args) { var strokes; // Build strokes array from string. if (goog.isString(args[initialIndex])) { strokes = goog.array.map( goog.ui.KeyboardShortcutHandler.parseStringShortcut(args[initialIndex]), function(stroke) { goog.asserts.assertNumber( stroke.keyCode, 'A non-modifier key is needed in each stroke.'); return goog.ui.KeyboardShortcutHandler.makeStroke_( stroke.key || '', stroke.keyCode, stroke.modifiers); }); // Build strokes array from arguments list or from array. } else { var strokesArgs = args, i = initialIndex; if (goog.isArray(args[initialIndex])) { strokesArgs = args[initialIndex]; i = 0; } strokes = []; for (; i < strokesArgs.length; i += 2) { // keyName == '' because this branch is only run on numbers // (corresponding to keyCodes). strokes.push(goog.ui.KeyboardShortcutHandler.makeStroke_( '', strokesArgs[i], strokesArgs[i + 1])); } } return strokes; }; /** * Unregisters all keyboard shortcuts. */ goog.ui.KeyboardShortcutHandler.prototype.unregisterAll = function() { this.shortcuts_ = {}; }; /** * Sets the global keys; keys that are safe to always regarded as shortcuts, * even if entered in a textarea or input field. * @param {Array} keys List of keys. */ goog.ui.KeyboardShortcutHandler.prototype.setGlobalKeys = function(keys) { this.globalKeys_ = goog.object.createSet(keys); }; /** * @return {!Array} The global keys, i.e. keys that are safe to always * regard as shortcuts, even if entered in a textarea or input field. */ goog.ui.KeyboardShortcutHandler.prototype.getGlobalKeys = function() { return goog.object.getKeys(this.globalKeys_); }; /** @override */ goog.ui.KeyboardShortcutHandler.prototype.disposeInternal = function() { goog.ui.KeyboardShortcutHandler.superClass_.disposeInternal.call(this); this.unregisterAll(); this.clearKeyListener(); }; /** * Returns event type for a specific shortcut. * @param {string} identifier Identifier for the shortcut task. * @return {string} The event type. */ goog.ui.KeyboardShortcutHandler.prototype.getEventType = function(identifier) { return goog.ui.KeyboardShortcutHandler.EventType.SHORTCUT_PREFIX + identifier; }; /** * Builds stroke array from string representation of shortcut. * @param {string} s String representation of shortcut. * @return {!Array} The * stroke array. A null keyCode means no non-modifier key was part of the * stroke. */ goog.ui.KeyboardShortcutHandler.parseStringShortcut = function(s) { // Normalize whitespace and force to lower case. s = s.replace(/[ +]*\+[ +]*/g, '+').replace(/[ ]+/g, ' ').toLowerCase(); // Build strokes array from string, space separates strokes, plus separates // individual keys. var groups = s.split(' '); var strokes = []; for (var group, i = 0; group = groups[i]; i++) { var keys = group.split('+'); // Explicitly re-initialize key data (JS does not have block scoping). var keyName = null; var keyCode = null; var modifiers = goog.ui.KeyboardShortcutHandler.Modifiers.NONE; for (var key, j = 0; key = keys[j]; j++) { switch (key) { case 'shift': modifiers |= goog.ui.KeyboardShortcutHandler.Modifiers.SHIFT; continue; case 'ctrl': modifiers |= goog.ui.KeyboardShortcutHandler.Modifiers.CTRL; continue; case 'alt': modifiers |= goog.ui.KeyboardShortcutHandler.Modifiers.ALT; continue; case 'meta': modifiers |= goog.ui.KeyboardShortcutHandler.Modifiers.META; continue; } if (!goog.isNull(keyCode)) { goog.asserts.fail('At most one non-modifier key can be in a stroke.'); } keyCode = goog.ui.KeyboardShortcutHandler.getKeyCode(key); goog.asserts.assertNumber( keyCode, 'Key name not found in goog.events.KeyNames: ' + key); keyName = key; break; } strokes.push({key: keyName, keyCode: keyCode, modifiers: modifiers}); } return strokes; }; /** * Adds a key event listener that triggers {@link #handleKeyDown_} when keys * are pressed. * @param {goog.events.EventTarget|EventTarget} keyTarget Event target that the * event listener should be attached to. * @protected */ goog.ui.KeyboardShortcutHandler.prototype.initializeKeyListener = function( keyTarget) { this.keyTarget_ = keyTarget; goog.events.listen( this.keyTarget_, goog.events.EventType.KEYDOWN, this.handleKeyDown_, undefined /* opt_capture */, this); // Windows uses ctrl+alt keys (a.k.a. alt-graph keys) for typing characters // on European keyboards (e.g. ctrl+alt+e for an an euro sign.) Unfortunately, // Windows browsers do not have any methods except listening to keypress and // keyup events to identify if ctrl+alt keys are really used for inputting // characters. Therefore, we listen to these events and prevent firing // shortcut-key events if ctrl+alt keys are used for typing characters. if (goog.userAgent.WINDOWS) { goog.events.listen( this.keyTarget_, goog.events.EventType.KEYPRESS, this.handleWindowsKeyPress_, undefined /* opt_capture */, this); } goog.events.listen( this.keyTarget_, goog.events.EventType.KEYUP, this.handleKeyUp_, undefined /* opt_capture */, this); }; /** * Handler for when a keyup event is fired. Currently only handled on Windows * (all browsers) or Gecko (all platforms). * @param {!goog.events.BrowserEvent} e The key event. * @private */ goog.ui.KeyboardShortcutHandler.prototype.handleKeyUp_ = function(e) { if (goog.userAgent.GECKO) { this.handleGeckoKeyUp_(e); } if (goog.userAgent.WINDOWS) { this.handleWindowsKeyUp_(e); } }; /** * Handler for when a keyup event is fired in Firefox (Gecko). * @param {!goog.events.BrowserEvent} e The key event. * @private */ goog.ui.KeyboardShortcutHandler.prototype.handleGeckoKeyUp_ = function(e) { // Due to a bug in the way that Gecko on Mac handles cut/copy/paste key events // using the meta key, it is necessary to fake the keyDown for the action keys // (C,V,X) by capturing it on keyUp. // This is because the keyDown events themselves are not fired by the browser // in this case. // Because users will often release the meta key a slight moment before they // release the action key, we need to store whether the meta key has been // released recently to avoid "flaky" cutting/pasting behavior. if (goog.userAgent.MAC) { if (e.keyCode == goog.events.KeyCodes.MAC_FF_META) { this.metaKeyRecentlyReleased_ = true; goog.Timer.callOnce(function() { this.metaKeyRecentlyReleased_ = false; }, 400, this); return; } var metaKey = e.metaKey || this.metaKeyRecentlyReleased_; if ((e.keyCode == goog.events.KeyCodes.C || e.keyCode == goog.events.KeyCodes.X || e.keyCode == goog.events.KeyCodes.V) && metaKey) { e.metaKey = metaKey; this.handleKeyDown_(e); } } // Firefox triggers buttons on space keyUp instead of keyDown. So if space // keyDown activated a shortcut, do NOT also trigger the focused button. if (goog.events.KeyCodes.SPACE == this.activeShortcutKeyForGecko_ && goog.events.KeyCodes.SPACE == e.keyCode) { e.preventDefault(); } this.activeShortcutKeyForGecko_ = null; }; /** * Returns whether this event is possibly used for typing a printable character. * Windows uses ctrl+alt (a.k.a. alt-graph) keys for typing characters on * European keyboards. Since only Firefox provides a method that can identify * whether ctrl+alt keys are used for typing characters, we need to check * whether Windows sends a keypress event to prevent firing shortcut event if * this event is used for typing characters. * @param {!goog.events.BrowserEvent} e The key event. * @return {boolean} Whether this event is a possible printable-key event. * @private */ goog.ui.KeyboardShortcutHandler.prototype.isPossiblePrintableKey_ = function( e) { return goog.userAgent.WINDOWS && e.ctrlKey && e.altKey; }; /** * Handler for when a keypress event is fired on Windows. * @param {!goog.events.BrowserEvent} e The key event. * @private */ goog.ui.KeyboardShortcutHandler.prototype.handleWindowsKeyPress_ = function(e) { // When this keypress event consists of a printable character, set the flag to // prevent firing shortcut key events when we receive the succeeding keyup // event. We accept all Unicode characters except control ones since this // keyCode may be a non-ASCII character. if (e.keyCode > 0x20 && this.isPossiblePrintableKey_(e)) { this.isPrintableKey_ = true; } }; /** * Handler for when a keyup event is fired on Windows. * @param {!goog.events.BrowserEvent} e The key event. * @private */ goog.ui.KeyboardShortcutHandler.prototype.handleWindowsKeyUp_ = function(e) { // For possible printable-key events, try firing a shortcut-key event only // when this event is not used for typing a character. if (!this.isPrintableKey_ && this.isPossiblePrintableKey_(e)) { this.handleKeyDown_(e); } }; /** * Removes the listener that was added by link {@link #initializeKeyListener}. * @protected */ goog.ui.KeyboardShortcutHandler.prototype.clearKeyListener = function() { goog.events.unlisten( this.keyTarget_, goog.events.EventType.KEYDOWN, this.handleKeyDown_, false, this); if (goog.userAgent.WINDOWS) { goog.events.unlisten( this.keyTarget_, goog.events.EventType.KEYPRESS, this.handleWindowsKeyPress_, false, this); } goog.events.unlisten( this.keyTarget_, goog.events.EventType.KEYUP, this.handleKeyUp_, false, this); this.keyTarget_ = null; }; /** * Adds a shortcut stroke sequence to the given sequence tree. Recursive. * @param {!goog.ui.KeyboardShortcutHandler.SequenceTree_} tree The stroke * sequence tree to add to. * @param {Array>} strokes Array of strokes for shortcut. * @param {string} identifier Identifier for the task performed by shortcut. * @private */ goog.ui.KeyboardShortcutHandler.setShortcut_ = function( tree, strokes, identifier) { var stroke = strokes.shift(); goog.array.forEach(stroke, function(s) { var node = tree[s]; if (node && (strokes.length == 0 || node.shortcut)) { // This new shortcut would override an existing shortcut or shortcut // prefix (since the new strokes end at an existing node), or an existing // shortcut would be triggered by the prefix to this new shortcut (since // there is already a terminal node on the path we are trying to create). throw Error('Keyboard shortcut conflicts with existing shortcut'); } }); if (strokes.length) { goog.array.forEach(stroke, function(s) { var node = goog.object.setIfUndefined( tree, s.toString(), goog.ui.KeyboardShortcutHandler.createInternalNode_()); // setShortcut_ modifies strokes var strokesCopy = strokes.slice(0); goog.ui.KeyboardShortcutHandler.setShortcut_( goog.asserts.assert( node.next, 'An internal node must have a next map'), strokesCopy, identifier); }); } else { goog.array.forEach(stroke, function(s) { // Add a terminal node. tree[s] = goog.ui.KeyboardShortcutHandler.createTerminalNode_(identifier); }); } }; /** * Removes a shortcut stroke sequence from the given sequence tree, pruning any * dead branches of the tree. Recursive. * @param {!goog.ui.KeyboardShortcutHandler.SequenceTree_} tree The stroke * sequence tree to remove from. * @param {Array>} strokes Array of strokes for shortcut to * remove. * @private */ goog.ui.KeyboardShortcutHandler.unsetShortcut_ = function(tree, strokes) { var stroke = strokes.shift(); goog.array.forEach(stroke, function(s) { var node = tree[s]; if (!node) { // The given stroke sequence is not in the tree. return; } if (strokes.length == 0) { // Base case - the end of the stroke sequence. if (!node.shortcut) { // The given stroke sequence does not end at a terminal node. return; } delete tree[s]; } else { if (!node.next) { // The given stroke sequence is not in the tree. return; } // Recursively remove the rest of the shortcut sequence from the node.next // subtree. // unsetShortcut_ modifies strokes var strokesCopy = strokes.slice(0); goog.ui.KeyboardShortcutHandler.unsetShortcut_(node.next, strokesCopy); if (goog.object.isEmpty(node.next)) { // The node.next subtree is now empty (the last stroke in it was just // removed), so prune this dead branch of the tree. delete tree[s]; } } }); }; /** * Checks tree for a node matching one of stroke. * @param {!goog.ui.KeyboardShortcutHandler.SequenceTree_} tree The * stroke sequence tree to find the node in. * @param {Array} stroke Stroke to find. * @return {goog.ui.KeyboardShortcutHandler.SequenceNode_|undefined} Node matching stroke. * @private */ goog.ui.KeyboardShortcutHandler.prototype.getNode_ = function(tree, stroke) { for (var i = 0; i < stroke.length; i++) { var node = tree[stroke[i]]; if (!node) { continue; } return node; } return undefined; }; /** * Checks if a particular keyboard shortcut is registered. * @param {goog.ui.KeyboardShortcutHandler.SequenceTree_|null} tree The * stroke sequence tree to find the keyboard shortcut in. * @param {Array>} strokes Strokes array. * @return {boolean} True iff the keyboard shortcut is registred. * @private */ goog.ui.KeyboardShortcutHandler.prototype.checkShortcut_ = function( tree, strokes) { while (strokes.length > 0 && tree) { var stroke = strokes.shift(); var node = this.getNode_(tree, stroke); if (!node) { continue; } if (strokes.length == 0 && node.shortcut) { return true; } // checkShortcut_ modifies strokes var strokesCopy = strokes.slice(0); if (this.checkShortcut_(node.next, strokesCopy)) { return true; } } return false; }; /** * Constructs key identification string from key name, key code and modifiers. * * @param {string} keyName Key name. * @param {number} keyCode Numeric key code. * @param {number} modifiers Required modifiers. * @return {Array} An array of strings identifying the key/modifier * combinations. * @private */ goog.ui.KeyboardShortcutHandler.makeStroke_ = function( keyName, keyCode, modifiers) { var mods = modifiers || 0; // entries must be usable as key in a map var strokes = ['c_' + keyCode + '_' + mods]; if (keyName != '') { strokes.push('n_' + keyName + '_' + mods); } return strokes; }; /** * Keypress handler. * @param {!goog.events.BrowserEvent} event Keypress event. * @private */ goog.ui.KeyboardShortcutHandler.prototype.handleKeyDown_ = function(event) { if (!this.isValidShortcut_(event)) { return; } var keyCode = goog.events.KeyCodes.normalizeKeyCode(event.keyCode); var keyName = event.key; var modifiers = (event.shiftKey ? goog.ui.KeyboardShortcutHandler.Modifiers.SHIFT : 0) | (event.ctrlKey ? goog.ui.KeyboardShortcutHandler.Modifiers.CTRL : 0) | (event.altKey ? goog.ui.KeyboardShortcutHandler.Modifiers.ALT : 0) | (event.metaKey ? goog.ui.KeyboardShortcutHandler.Modifiers.META : 0); var stroke = goog.ui.KeyboardShortcutHandler.makeStroke_(keyName, keyCode, modifiers); var node = this.getNode_(this.currentTree_, stroke); if (!node || this.hasSequenceTimedOut_()) { // Either this stroke does not continue any active sequence, or the // currently active sequence has timed out. Reset shortcut tree progress. this.setCurrentTree_(this.shortcuts_); } node = this.getNode_(this.currentTree_, stroke); if (node && node.next) { // This stroke does not trigger a shortcut, but entered stroke(s) are a part // of a sequence. Progress in the sequence tree and record time to allow the // following stroke(s) to trigger the shortcut. this.setCurrentTree_(node.next); } // For possible printable-key events, we cannot identify whether the events // are used for typing characters until we receive respective keyup events. // Therefore, we handle this event when we receive a succeeding keyup event // to verify this event is not used for typing characters. preventDefault is // not called on the event to avoid disrupting a character input. if (event.type == 'keydown' && this.isPossiblePrintableKey_(event)) { this.isPrintableKey_ = false; return; } else if (!node) { // This stroke does not correspond to a shortcut or continued sequence. return; } else if (node.next) { // Prevent default action so that the rest of the stroke sequence can be // completed. event.preventDefault(); return; } // This stroke triggers a shortcut. Any active sequence has been completed, so // reset the sequence tree. this.setCurrentTree_(this.shortcuts_); // Dispatch the triggered keyboard shortcut event. In addition to the generic // keyboard shortcut event a more specific fine grained one, specific for the // shortcut identifier, is fired. if (this.alwaysPreventDefault_) { event.preventDefault(); } if (this.alwaysStopPropagation_) { event.stopPropagation(); } var shortcut = goog.asserts.assertString( node.shortcut, 'A terminal node must have a string shortcut identifier.'); // Dispatch SHORTCUT_TRIGGERED event var target = /** @type {Node} */ (event.target); var triggerEvent = new goog.ui.KeyboardShortcutEvent( goog.ui.KeyboardShortcutHandler.EventType.SHORTCUT_TRIGGERED, shortcut, target); var retVal = this.dispatchEvent(triggerEvent); // Dispatch SHORTCUT_PREFIX_ event var prefixEvent = new goog.ui.KeyboardShortcutEvent( goog.ui.KeyboardShortcutHandler.EventType.SHORTCUT_PREFIX + shortcut, shortcut, target); retVal &= this.dispatchEvent(prefixEvent); // The default action is prevented if 'preventDefault' was // called on either event, or if a listener returned false. if (!retVal) { event.preventDefault(); } // For Firefox, track which shortcut key was pushed. if (goog.userAgent.GECKO) { this.activeShortcutKeyForGecko_ = keyCode; } }; /** * Checks if a given keypress event may be treated as a shortcut. * @param {!goog.events.BrowserEvent} event Keypress event. * @return {boolean} Whether to attempt to process the event as a shortcut. * @private */ goog.ui.KeyboardShortcutHandler.prototype.isValidShortcut_ = function(event) { // Ignore Ctrl, Shift and ALT var keyCode = event.keyCode; if (event.key != '') { var keyName = event.key; if (keyName == goog.events.Keys.CTRL || keyName == goog.events.Keys.SHIFT || keyName == goog.events.Keys.ALT || keyName == goog.events.Keys.ALTGRAPH) { return false; } } else { if (keyCode == goog.events.KeyCodes.SHIFT || keyCode == goog.events.KeyCodes.CTRL || keyCode == goog.events.KeyCodes.ALT) { return false; } } var el = /** @type {Element} */ (event.target); var isFormElement = el.tagName == goog.dom.TagName.TEXTAREA || el.tagName == goog.dom.TagName.INPUT || el.tagName == goog.dom.TagName.BUTTON || el.tagName == goog.dom.TagName.SELECT; var isContentEditable = !isFormElement && (el.isContentEditable || (el.ownerDocument && el.ownerDocument.designMode == 'on')); if (!isFormElement && !isContentEditable) { return true; } // Always allow keys registered as global to be used (typically Esc, the // F-keys and other keys that are not typically used to manipulate text). if (this.globalKeys_[keyCode] || this.allShortcutsAreGlobal_) { return true; } if (isContentEditable) { // For events originating from an element in editing mode we only let // global key codes through. return false; } // Event target is one of (TEXTAREA, INPUT, BUTTON, SELECT). // Allow modifier shortcuts, unless we shouldn't. if (this.modifierShortcutsAreGlobal_ && (event.altKey || event.ctrlKey || event.metaKey)) { return true; } // Allow ENTER to be used as shortcut for text inputs. if (el.tagName == goog.dom.TagName.INPUT && this.textInputs_[el.type]) { return keyCode == goog.events.KeyCodes.ENTER; } // Checkboxes, radiobuttons and buttons. Allow all but SPACE as shortcut. if (el.tagName == goog.dom.TagName.INPUT || el.tagName == goog.dom.TagName.BUTTON) { // TODO(gboyer): If more flexibility is needed, create protected helper // methods for each case (e.g. button, input, etc). if (this.allowSpaceKeyOnButtons_) { return true; } else { return keyCode != goog.events.KeyCodes.SPACE; } } // Don't allow any additional shortcut keys for textareas or selects. return false; }; /** * @return {boolean} True iff the current stroke sequence has timed out. * @private */ goog.ui.KeyboardShortcutHandler.prototype.hasSequenceTimedOut_ = function() { return goog.now() - this.lastStrokeTime_ >= goog.ui.KeyboardShortcutHandler.MAX_KEY_SEQUENCE_DELAY; }; /** * Sets the current keyboard shortcut sequence tree and updates the last stroke * time. * @param {!goog.ui.KeyboardShortcutHandler.SequenceTree_} tree * @private */ goog.ui.KeyboardShortcutHandler.prototype.setCurrentTree_ = function(tree) { this.currentTree_ = tree; this.lastStrokeTime_ = goog.now(); }; /** * Object representing a keyboard shortcut event. * @param {string} type Event type. * @param {string} identifier Task identifier for the triggered shortcut. * @param {Node|goog.events.EventTarget} target Target the original key press * event originated from. * @extends {goog.events.Event} * @constructor * @final */ goog.ui.KeyboardShortcutEvent = function(type, identifier, target) { goog.events.Event.call(this, type, target); /** * Task identifier for the triggered shortcut * @type {string} */ this.identifier = identifier; }; goog.inherits(goog.ui.KeyboardShortcutEvent, goog.events.Event);