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.
5 /** @typedef {{active: boolean,
6 * command_name: string,
8 * extension_action: boolean,
9 * extension_id: string,
11 * keybinding: string}}
15 cr.define('options', function() {
19 * Creates a new list of extension commands.
20 * @param {Object=} opt_propertyBag Optional properties.
22 * @extends {HTMLDivElement}
24 var ExtensionCommandList = cr.ui.define('div');
26 /** @const */ var keyComma = 188;
27 /** @const */ var keyDel = 46;
28 /** @const */ var keyDown = 40;
29 /** @const */ var keyEnd = 35;
30 /** @const */ var keyEscape = 27;
31 /** @const */ var keyHome = 36;
32 /** @const */ var keyIns = 45;
33 /** @const */ var keyLeft = 37;
34 /** @const */ var keyMediaNextTrack = 176;
35 /** @const */ var keyMediaPlayPause = 179;
36 /** @const */ var keyMediaPrevTrack = 177;
37 /** @const */ var keyMediaStop = 178;
38 /** @const */ var keyPageDown = 34;
39 /** @const */ var keyPageUp = 33;
40 /** @const */ var keyPeriod = 190;
41 /** @const */ var keyRight = 39;
42 /** @const */ var keySpace = 32;
43 /** @const */ var keyTab = 9;
44 /** @const */ var keyUp = 38;
47 * Enum for whether we require modifiers of a keycode.
56 * Returns whether the passed in |keyCode| is a valid extension command
57 * char or not. This is restricted to A-Z and 0-9 (ignoring modifiers) at
59 * @param {number} keyCode The keycode to consider.
60 * @return {boolean} Returns whether the char is valid.
62 function validChar(keyCode) {
63 return keyCode == keyComma ||
70 keyCode == keyMediaNextTrack ||
71 keyCode == keyMediaPlayPause ||
72 keyCode == keyMediaPrevTrack ||
73 keyCode == keyMediaStop ||
74 keyCode == keyPageDown ||
75 keyCode == keyPageUp ||
76 keyCode == keyPeriod ||
77 keyCode == keyRight ||
78 keyCode == keySpace ||
81 (keyCode >= 'A'.charCodeAt(0) && keyCode <= 'Z'.charCodeAt(0)) ||
82 (keyCode >= '0'.charCodeAt(0) && keyCode <= '9'.charCodeAt(0));
86 * Convert a keystroke event to string form, while taking into account
87 * (ignoring) invalid extension commands.
88 * @param {Event} event The keyboard event to convert.
89 * @return {string} The keystroke as a string.
91 function keystrokeToString(event) {
93 if (cr.isMac && event.metaKey)
94 output.push('Command');
95 if (cr.isChromeOS && event.metaKey)
96 output.push('Search');
99 if (!event.ctrlKey && event.altKey)
102 output.push('Shift');
104 var keyCode = event.keyCode;
105 if (validChar(keyCode)) {
106 if ((keyCode >= 'A'.charCodeAt(0) && keyCode <= 'Z'.charCodeAt(0)) ||
107 (keyCode >= '0'.charCodeAt(0) && keyCode <= '9'.charCodeAt(0))) {
108 output.push(String.fromCharCode('A'.charCodeAt(0) + keyCode - 65));
112 output.push('Comma'); break;
114 output.push('Delete'); break;
116 output.push('Down'); break;
118 output.push('End'); break;
120 output.push('Home'); break;
122 output.push('Insert'); break;
124 output.push('Left'); break;
125 case keyMediaNextTrack:
126 output.push('MediaNextTrack'); break;
127 case keyMediaPlayPause:
128 output.push('MediaPlayPause'); break;
129 case keyMediaPrevTrack:
130 output.push('MediaPrevTrack'); break;
132 output.push('MediaStop'); break;
134 output.push('PageDown'); break;
136 output.push('PageUp'); break;
138 output.push('Period'); break;
140 output.push('Right'); break;
142 output.push('Space'); break;
144 output.push('Tab'); break;
146 output.push('Up'); break;
151 return output.join('+');
155 * Returns whether the passed in |keyCode| require modifiers. Currently only
156 * "MediaNextTrack", "MediaPrevTrack", "MediaStop", "MediaPlayPause" are
157 * required to be used without any modifier.
158 * @param {number} keyCode The keycode to consider.
159 * @return {Modifiers} Returns whether the keycode require modifiers.
161 function modifiers(keyCode) {
163 case keyMediaNextTrack:
164 case keyMediaPlayPause:
165 case keyMediaPrevTrack:
167 return Modifiers.ARE_NOT_ALLOWED;
169 return Modifiers.ARE_REQUIRED;
174 * Return true if the specified keyboard event has any one of following
175 * modifiers: "Ctrl", "Alt", "Cmd" on Mac, and "Shift" when the
176 * countShiftAsModifier is true.
177 * @param {Event} event The keyboard event to consider.
178 * @param {boolean} countShiftAsModifier Whether the 'ShiftKey' should be
179 * counted as modifier.
181 function hasModifier(event, countShiftAsModifier) {
182 return event.ctrlKey || event.altKey || (cr.isMac && event.metaKey) ||
183 (cr.isChromeOS && event.metaKey) ||
184 (countShiftAsModifier && event.shiftKey);
187 ExtensionCommandList.prototype = {
188 __proto__: HTMLDivElement.prototype,
191 * While capturing, this records the current (last) keyboard event generated
192 * by the user. Will be |null| after capture and during capture when no
193 * keyboard event has been generated.
194 * @type {KeyboardEvent}.
197 currentKeyEvent_: null,
200 * While capturing, this keeps track of the previous selection so we can
201 * revert back to if no valid assignment is made during capture.
208 * While capturing, this keeps track of which element the user asked to
210 * @type {HTMLElement}.
213 capturingElement_: null,
215 decorate: function() {
216 this.textContent = '';
218 // Iterate over the extension data and add each item to the list.
219 this.data_.commands.forEach(this.createNodeForExtension_.bind(this));
223 * Synthesizes and initializes an HTML element for the extension command
224 * metadata given in |extension|.
225 * @param {Object} extension A dictionary of extension metadata.
228 createNodeForExtension_: function(extension) {
229 var template = $('template-collection-extension-commands').querySelector(
230 '.extension-command-list-extension-item-wrapper');
231 var node = template.cloneNode(true);
233 var title = node.querySelector('.extension-title');
234 title.textContent = extension.name;
236 this.appendChild(node);
238 // Iterate over the commands data within the extension and add each item
240 extension.commands.forEach(this.createNodeForCommand_.bind(this));
244 * Synthesizes and initializes an HTML element for the extension command
245 * metadata given in |command|.
246 * @param {ExtensionCommand} command A dictionary of extension command
250 createNodeForCommand_: function(command) {
251 var template = $('template-collection-extension-commands').querySelector(
252 '.extension-command-list-command-item-wrapper');
253 var node = template.cloneNode(true);
254 node.id = this.createElementId_(
255 'command', command.extension_id, command.command_name);
257 var description = node.querySelector('.command-description');
258 description.textContent = command.description;
260 var shortcutNode = node.querySelector('.command-shortcut-text');
261 shortcutNode.addEventListener('mouseup',
262 this.startCapture_.bind(this));
263 shortcutNode.addEventListener('focus', this.handleFocus_.bind(this));
264 shortcutNode.addEventListener('blur', this.handleBlur_.bind(this));
265 shortcutNode.addEventListener('keydown', this.handleKeyDown_.bind(this));
266 shortcutNode.addEventListener('keyup', this.handleKeyUp_.bind(this));
267 if (!command.active) {
268 shortcutNode.textContent =
269 loadTimeData.getString('extensionCommandsInactive');
271 var commandShortcut = node.querySelector('.command-shortcut');
272 commandShortcut.classList.add('inactive-keybinding');
274 shortcutNode.textContent = command.keybinding;
277 var commandClear = node.querySelector('.command-clear');
278 commandClear.id = this.createElementId_(
279 'clear', command.extension_id, command.command_name);
280 commandClear.title = loadTimeData.getString('extensionCommandsDelete');
281 commandClear.addEventListener('click', this.handleClear_.bind(this));
283 var select = node.querySelector('.command-scope');
284 select.id = this.createElementId_(
285 'setCommandScope', command.extension_id, command.command_name);
286 select.hidden = false;
287 // Add the 'In Chrome' option.
288 var option = document.createElement('option');
289 option.textContent = loadTimeData.getString('extensionCommandsRegular');
290 select.appendChild(option);
291 if (command.extension_action) {
292 // Extension actions cannot be global, so we might as well disable the
293 // combo box, to signify that.
294 select.disabled = true;
296 // Add the 'Global' option.
297 option = document.createElement('option');
298 option.textContent = loadTimeData.getString('extensionCommandsGlobal');
299 select.appendChild(option);
300 select.selectedIndex = command.global ? 1 : 0;
302 select.addEventListener(
303 'change', this.handleSetCommandScope_.bind(this));
306 this.appendChild(node);
310 * Starts keystroke capture to determine which key to use for a particular
312 * @param {Event} event The keyboard event to consider.
315 startCapture_: function(event) {
316 if (this.capturingElement_)
317 return; // Already capturing.
319 chrome.send('setShortcutHandlingSuspended', [true]);
321 var shortcutNode = event.target;
322 this.oldValue_ = shortcutNode.textContent;
323 shortcutNode.textContent =
324 loadTimeData.getString('extensionCommandsStartTyping');
325 shortcutNode.parentElement.classList.add('capturing');
328 shortcutNode.parentElement.querySelector('.command-clear');
329 commandClear.hidden = true;
331 this.capturingElement_ = /** @type {HTMLElement} */(event.target);
335 * Ends keystroke capture and either restores the old value or (if valid
336 * value) sets the new value as active..
337 * @param {Event} event The keyboard event to consider.
340 endCapture_: function(event) {
341 if (!this.capturingElement_)
342 return; // Not capturing.
344 chrome.send('setShortcutHandlingSuspended', [false]);
346 var shortcutNode = this.capturingElement_;
347 var commandShortcut = shortcutNode.parentElement;
349 commandShortcut.classList.remove('capturing');
350 commandShortcut.classList.remove('contains-chars');
352 // When the capture ends, the user may have not given a complete and valid
353 // input (or even no input at all). Only a valid key event followed by a
354 // valid key combination will cause a shortcut selection to be activated.
355 // If no valid selection was made, however, revert back to what the
356 // textbox had before to indicate that the shortcut registration was
358 if (!this.currentKeyEvent_ || !validChar(this.currentKeyEvent_.keyCode))
359 shortcutNode.textContent = this.oldValue_;
361 var commandClear = commandShortcut.querySelector('.command-clear');
362 if (this.oldValue_ == '') {
363 commandShortcut.classList.remove('clearable');
364 commandClear.hidden = true;
366 commandShortcut.classList.add('clearable');
367 commandClear.hidden = false;
371 this.capturingElement_ = null;
372 this.currentKeyEvent_ = null;
376 * Handles focus event and adds visual indication for active shortcut.
377 * @param {Event} event to consider.
380 handleFocus_: function(event) {
381 var commandShortcut = event.target.parentElement;
382 commandShortcut.classList.add('focused');
386 * Handles lost focus event and removes visual indication of active shortcut
387 * also stops capturing on focus lost.
388 * @param {Event} event to consider.
391 handleBlur_: function(event) {
392 this.endCapture_(event);
393 var commandShortcut = event.target.parentElement;
394 commandShortcut.classList.remove('focused');
398 * The KeyDown handler.
399 * @param {Event} event The keyboard event to consider.
402 handleKeyDown_: function(event) {
403 event = /** @type {KeyboardEvent} */(event);
404 if (event.keyCode == keyEscape) {
405 // Escape cancels capturing.
406 this.endCapture_(event);
407 var parsed = this.parseElementId_('clear',
408 event.target.parentElement.querySelector('.command-clear').id);
409 chrome.send('setExtensionCommandShortcut',
410 [parsed.extensionId, parsed.commandName, '']);
411 event.preventDefault();
412 event.stopPropagation();
415 if (event.keyCode == keyTab) {
416 // Allow tab propagation for keyboard navigation.
420 if (!this.capturingElement_)
421 this.startCapture_(event);
423 this.handleKey_(event);
428 * @param {Event} event The keyboard event to consider.
431 handleKeyUp_: function(event) {
432 event = /** @type {KeyboardEvent} */(event);
433 if (event.keyCode == keyTab) {
434 // Allow tab propagation for keyboard navigation.
438 // We want to make it easy to change from Ctrl+Shift+ to just Ctrl+ by
439 // releasing Shift, but we also don't want it to be easy to lose for
440 // example Ctrl+Shift+F to Ctrl+ just because you didn't release Ctrl
441 // as fast as the other two keys. Therefore, we process KeyUp until you
442 // have a valid combination and then stop processing it (meaning that once
443 // you have a valid combination, we won't change it until the next
444 // KeyDown message arrives).
445 if (!this.currentKeyEvent_ || !validChar(this.currentKeyEvent_.keyCode)) {
446 if (!event.ctrlKey && !event.altKey ||
447 ((cr.isMac || cr.isChromeOS) && !event.metaKey)) {
448 // If neither Ctrl nor Alt is pressed then it is not a valid shortcut.
449 // That means we're back at the starting point so we should restart
451 this.endCapture_(event);
452 this.startCapture_(event);
454 this.handleKey_(event);
460 * A general key handler (used for both KeyDown and KeyUp).
461 * @param {KeyboardEvent} event The keyboard event to consider.
464 handleKey_: function(event) {
465 // While capturing, we prevent all events from bubbling, to prevent
466 // shortcuts lacking the right modifier (F3 for example) from activating
467 // and ending capture prematurely.
468 event.preventDefault();
469 event.stopPropagation();
471 if (modifiers(event.keyCode) == Modifiers.ARE_REQUIRED &&
472 !hasModifier(event, false)) {
473 // Ctrl or Alt (or Cmd on Mac) is a must for most shortcuts.
477 if (modifiers(event.keyCode) == Modifiers.ARE_NOT_ALLOWED &&
478 hasModifier(event, true)) {
482 var shortcutNode = this.capturingElement_;
483 var keystroke = keystrokeToString(event);
484 shortcutNode.textContent = keystroke;
485 event.target.classList.add('contains-chars');
486 this.currentKeyEvent_ = event;
488 if (validChar(event.keyCode)) {
489 var node = event.target;
490 while (node && !node.id)
491 node = node.parentElement;
493 this.oldValue_ = keystroke; // Forget what the old value was.
494 var parsed = this.parseElementId_('command', node.id);
496 // Ending the capture must occur before calling
497 // setExtensionCommandShortcut to ensure the shortcut is set.
498 this.endCapture_(event);
499 chrome.send('setExtensionCommandShortcut',
500 [parsed.extensionId, parsed.commandName, keystroke]);
505 * A handler for the delete command button.
506 * @param {Event} event The mouse event to consider.
509 handleClear_: function(event) {
510 var parsed = this.parseElementId_('clear', event.target.id);
511 chrome.send('setExtensionCommandShortcut',
512 [parsed.extensionId, parsed.commandName, '']);
516 * A handler for the setting the scope of the command.
517 * @param {Event} event The mouse event to consider.
520 handleSetCommandScope_: function(event) {
521 var parsed = this.parseElementId_('setCommandScope', event.target.id);
522 var element = document.getElementById(
523 'setCommandScope-' + parsed.extensionId + '-' + parsed.commandName);
524 chrome.send('setCommandScope',
525 [parsed.extensionId, parsed.commandName, element.selectedIndex == 1]);
529 * A utility function to create a unique element id based on a namespace,
530 * extension id and a command name.
531 * @param {string} namespace The namespace to prepend the id with.
532 * @param {string} extensionId The extension ID to use in the id.
533 * @param {string} commandName The command name to append the id with.
536 createElementId_: function(namespace, extensionId, commandName) {
537 return namespace + '-' + extensionId + '-' + commandName;
541 * A utility function to parse a unique element id based on a namespace,
542 * extension id and a command name.
543 * @param {string} namespace The namespace to prepend the id with.
544 * @param {string} id The id to parse.
545 * @return {{extensionId: string, commandName: string}} The parsed id.
548 parseElementId_: function(namespace, id) {
549 var kExtensionIdLength = 32;
551 extensionId: id.substring(namespace.length + 1,
552 namespace.length + 1 + kExtensionIdLength),
553 commandName: id.substring(namespace.length + 1 + kExtensionIdLength + 1)
559 ExtensionCommandList: ExtensionCommandList