1 // Copyright (c) 2012 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 /** 6 * @fileoverview A command is an abstraction of an action a user can do in the 7 * UI. 8 * 9 * When the focus changes in the document for each command a canExecute event 10 * is dispatched on the active element. By listening to this event you can 11 * enable and disable the command by setting the event.canExecute property. 12 * 13 * When a command is executed a command event is dispatched on the active 14 * element. Note that you should stop the propagation after you have handled the 15 * command if there might be other command listeners higher up in the DOM tree. 16 */ 17 18 cr.define('cr.ui', function() { 19 20 /** 21 * This is used to identify keyboard shortcuts. 22 * @param {string} shortcut The text used to describe the keys for this 23 * keyboard shortcut. 24 * @constructor 25 */ 26 function KeyboardShortcut(shortcut) { 27 var mods = {}; 28 var ident = ''; 29 shortcut.split('-').forEach(function(part) { 30 var partLc = part.toLowerCase(); 31 switch (partLc) { 32 case 'alt': 33 case 'ctrl': 34 case 'meta': 35 case 'shift': 36 mods[partLc + 'Key'] = true; 37 break; 38 default: 39 if (ident) 40 throw Error('Invalid shortcut'); 41 ident = part; 42 } 43 }); 44 45 this.ident_ = ident; 46 this.mods_ = mods; 47 } 48 49 KeyboardShortcut.prototype = { 50 /** 51 * Whether the keyboard shortcut object matches a keyboard event. 52 * @param {!Event} e The keyboard event object. 53 * @return {boolean} Whether we found a match or not. 54 */ 55 matchesEvent: function(e) { 56 if (e.keyIdentifier == this.ident_) { 57 // All keyboard modifiers needs to match. 58 var mods = this.mods_; 59 return ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'].every(function(k) { 60 return e[k] == !!mods[k]; 61 }); 62 } 63 return false; 64 } 65 }; 66 67 /** 68 * Creates a new command element. 69 * @constructor 70 * @extends {HTMLElement} 71 */ 72 var Command = cr.ui.define('command'); 73 74 Command.prototype = { 75 __proto__: HTMLElement.prototype, 76 77 /** 78 * Initializes the command. 79 */ 80 decorate: function() { 81 CommandManager.init(this.ownerDocument); 82 83 if (this.hasAttribute('shortcut')) 84 this.shortcut = this.getAttribute('shortcut'); 85 }, 86 87 /** 88 * Executes the command by dispatching a command event on the given element. 89 * If |element| isn't given, the active element is used instead. 90 * If the command is {@code disabled} this does nothing. 91 * @param {HTMLElement=} opt_element Optional element to dispatch event on. 92 */ 93 execute: function(opt_element) { 94 if (this.disabled) 95 return; 96 var doc = this.ownerDocument; 97 if (doc.activeElement) { 98 var e = new Event('command', {bubbles: true}); 99 e.command = this; 100 101 (opt_element || doc.activeElement).dispatchEvent(e); 102 } 103 }, 104 105 /** 106 * Call this when there have been changes that might change whether the 107 * command can be executed or not. 108 * @param {Node=} opt_node Node for which to actuate command state. 109 */ 110 canExecuteChange: function(opt_node) { 111 dispatchCanExecuteEvent(this, 112 opt_node || this.ownerDocument.activeElement); 113 }, 114 115 /** 116 * The keyboard shortcut that triggers the command. This is a string 117 * consisting of a keyIdentifier (as reported by WebKit in keydown) as 118 * well as optional key modifiers joinded with a '-'. 119 * 120 * Multiple keyboard shortcuts can be provided by separating them by 121 * whitespace. 122 * 123 * For example: 124 * "F1" 125 * "U+0008-Meta" for Apple command backspace. 126 * "U+0041-Ctrl" for Control A 127 * "U+007F U+0008-Meta" for Delete and Command Backspace 128 * 129 * @type {string} 130 */ 131 shortcut_: '', 132 get shortcut() { 133 return this.shortcut_; 134 }, 135 set shortcut(shortcut) { 136 var oldShortcut = this.shortcut_; 137 if (shortcut !== oldShortcut) { 138 this.keyboardShortcuts_ = shortcut.split(/\s+/).map(function(shortcut) { 139 return new KeyboardShortcut(shortcut); 140 }); 141 142 // Set this after the keyboardShortcuts_ since that might throw. 143 this.shortcut_ = shortcut; 144 cr.dispatchPropertyChange(this, 'shortcut', this.shortcut_, 145 oldShortcut); 146 } 147 }, 148 149 /** 150 * Whether the event object matches the shortcut for this command. 151 * @param {!Event} e The key event object. 152 * @return {boolean} Whether it matched or not. 153 */ 154 matchesEvent: function(e) { 155 if (!this.keyboardShortcuts_) 156 return false; 157 158 return this.keyboardShortcuts_.some(function(keyboardShortcut) { 159 return keyboardShortcut.matchesEvent(e); 160 }); 161 } 162 }; 163 164 /** 165 * The label of the command. 166 * @type {string} 167 */ 168 cr.defineProperty(Command, 'label', cr.PropertyKind.ATTR); 169 170 /** 171 * Whether the command is disabled or not. 172 * @type {boolean} 173 */ 174 cr.defineProperty(Command, 'disabled', cr.PropertyKind.BOOL_ATTR); 175 176 /** 177 * Whether the command is hidden or not. 178 * @type {boolean} 179 */ 180 cr.defineProperty(Command, 'hidden', cr.PropertyKind.BOOL_ATTR); 181 182 /** 183 * Whether the command is checked or not. 184 * @type {boolean} 185 */ 186 cr.defineProperty(Command, 'checked', cr.PropertyKind.BOOL_ATTR); 187 188 /** 189 * The flag that prevents the shortcut text from being displayed on menu. 190 * 191 * If false, the keyboard shortcut text (eg. "Ctrl+X" for the cut command) 192 * is displayed in menu when the command is assosiated with a menu item. 193 * Otherwise, no text is displayed. 194 * 195 * @type {boolean} 196 */ 197 cr.defineProperty(Command, 'hideShortcutText', cr.PropertyKind.BOOL_ATTR); 198 199 /** 200 * Dispatches a canExecute event on the target. 201 * @param {cr.ui.Command} command The command that we are testing for. 202 * @param {Element} target The target element to dispatch the event on. 203 */ 204 function dispatchCanExecuteEvent(command, target) { 205 var e = new CanExecuteEvent(command, true); 206 target.dispatchEvent(e); 207 command.disabled = !e.canExecute; 208 } 209 210 /** 211 * The command managers for different documents. 212 */ 213 var commandManagers = {}; 214 215 /** 216 * Keeps track of the focused element and updates the commands when the focus 217 * changes. 218 * @param {!Document} doc The document that we are managing the commands for. 219 * @constructor 220 */ 221 function CommandManager(doc) { 222 doc.addEventListener('focus', this.handleFocus_.bind(this), true); 223 // Make sure we add the listener to the bubbling phase so that elements can 224 // prevent the command. 225 doc.addEventListener('keydown', this.handleKeyDown_.bind(this), false); 226 } 227 228 /** 229 * Initializes a command manager for the document as needed. 230 * @param {!Document} doc The document to manage the commands for. 231 */ 232 CommandManager.init = function(doc) { 233 var uid = cr.getUid(doc); 234 if (!(uid in commandManagers)) { 235 commandManagers[uid] = new CommandManager(doc); 236 } 237 }; 238 239 CommandManager.prototype = { 240 241 /** 242 * Handles focus changes on the document. 243 * @param {Event} e The focus event object. 244 * @private 245 */ 246 handleFocus_: function(e) { 247 var target = e.target; 248 249 // Ignore focus on a menu button or command item 250 if (target.menu || target.command) 251 return; 252 253 var commands = Array.prototype.slice.call( 254 target.ownerDocument.querySelectorAll('command')); 255 256 commands.forEach(function(command) { 257 dispatchCanExecuteEvent(command, target); 258 }); 259 }, 260 261 /** 262 * Handles the keydown event and routes it to the right command. 263 * @param {!Event} e The keydown event. 264 */ 265 handleKeyDown_: function(e) { 266 var target = e.target; 267 var commands = Array.prototype.slice.call( 268 target.ownerDocument.querySelectorAll('command')); 269 270 for (var i = 0, command; command = commands[i]; i++) { 271 if (command.matchesEvent(e)) { 272 // When invoking a command via a shortcut, we have to manually check 273 // if it can be executed, since focus might not have been changed 274 // what would have updated the command's state. 275 command.canExecuteChange(); 276 277 if (!command.disabled) { 278 e.preventDefault(); 279 // We do not want any other element to handle this. 280 e.stopPropagation(); 281 command.execute(); 282 return; 283 } 284 } 285 } 286 } 287 }; 288 289 /** 290 * The event type used for canExecute events. 291 * @param {!cr.ui.Command} command The command that we are evaluating. 292 * @extends {Event} 293 * @constructor 294 * @class 295 */ 296 function CanExecuteEvent(command) { 297 var e = new Event('canExecute', {bubbles: true}); 298 e.__proto__ = CanExecuteEvent.prototype; 299 e.command = command; 300 return e; 301 } 302 303 CanExecuteEvent.prototype = { 304 __proto__: Event.prototype, 305 306 /** 307 * The current command 308 * @type {cr.ui.Command} 309 */ 310 command: null, 311 312 /** 313 * Whether the target can execute the command. Setting this also stops the 314 * propagation. 315 * @type {boolean} 316 */ 317 canExecute_: false, 318 get canExecute() { 319 return this.canExecute_; 320 }, 321 set canExecute(canExecute) { 322 this.canExecute_ = !!canExecute; 323 this.stopPropagation(); 324 } 325 }; 326 327 // Export 328 return { 329 Command: Command, 330 CanExecuteEvent: CanExecuteEvent 331 }; 332 }); 333