1 // Copyright (c) 2011 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 // How long to wait to open submenu when mouse hovers. 6 var SUBMENU_OPEN_DELAY_MS = 200; 7 // How long to wait to close submenu when mouse left. 8 var SUBMENU_CLOSE_DELAY_MS = 500; 9 // Scroll repeat interval. 10 var SCROLL_INTERVAL_MS = 20; 11 // Scrolling amount in pixel. 12 var SCROLL_TICK_PX = 4; 13 // Regular expression to match/find mnemonic key. 14 var MNEMONIC_REGEXP = /([^&]*)&(.)(.*)/; 15 16 var localStrings = new LocalStrings(); 17 18 /** 19 * Sends 'activate' WebUI message. 20 * @param {number} index The index of menu item to activate in menu model. 21 * @param {string} mode The activation mode, one of 'close_and_activate', or 22 * 'activate_no_close'. 23 * TODO(oshima): change these string to enum numbers once it becomes possible 24 * to pass number to C++. 25 */ 26 function sendActivate(index, mode) { 27 chrome.send('activate', [String(index), mode]); 28 } 29 30 /** 31 * MenuItem class. 32 */ 33 var MenuItem = cr.ui.define('div'); 34 35 MenuItem.prototype = { 36 __proto__: HTMLDivElement.prototype, 37 38 /** 39 * Decorates the menu item element. 40 */ 41 decorate: function() { 42 this.className = 'menu-item'; 43 }, 44 45 /** 46 * Initialize the MenuItem. 47 * @param {Menu} menu A {@code Menu} object to which this menu item 48 * will be added to. 49 * @param {Object} attrs JSON object that represents this menu items 50 * properties. This is created from menu model in C code. See 51 * chromeos/views/native_menu_webui.cc. 52 * @param {Object} model The model object. 53 */ 54 init: function(menu, attrs, model) { 55 // The left icon's width. 0 if no icon. 56 var leftIconWidth = model.maxIconWidth; 57 this.menu_ = menu; 58 this.attrs = attrs; 59 var attrs = this.attrs; 60 if (attrs.type == 'separator') { 61 this.className = 'separator'; 62 } else if (attrs.type == 'command' || 63 attrs.type == 'submenu' || 64 attrs.type == 'check' || 65 attrs.type == 'radio') { 66 this.initMenuItem_(); 67 this.initPadding_(leftIconWidth); 68 } else { 69 // This should not happend. 70 this.classList.add('disabled'); 71 this.textContent = 'unknown'; 72 } 73 74 menu.appendChild(this); 75 if (!attrs.visible) { 76 this.classList.add('hidden'); 77 } 78 }, 79 80 /** 81 * Changes the selection state of the menu item. 82 * @param {boolean} selected True to set the selection, or false 83 * otherwise. 84 */ 85 set selected(selected) { 86 if (selected) { 87 this.classList.add('selected'); 88 this.menu_.selectedItem = this; 89 } else { 90 this.classList.remove('selected'); 91 } 92 }, 93 94 /** 95 * Activate the menu item. 96 */ 97 activate: function() { 98 if (this.attrs.type == 'submenu') { 99 this.menu_.openSubmenu(this); 100 } else if (this.attrs.type != 'separator' && 101 this.className.indexOf('selected') >= 0) { 102 sendActivate(this.menu_.getMenuItemIndexOf(this), 103 'close_and_activate'); 104 } 105 }, 106 107 /** 108 * Sends open_submenu WebUI message. 109 */ 110 sendOpenSubmenuCommand: function() { 111 chrome.send('open_submenu', 112 [String(this.menu_.getMenuItemIndexOf(this)), 113 String(this.getBoundingClientRect().top)]); 114 }, 115 116 /** 117 * Internal method to initiailze the MenuItem. 118 * @private 119 */ 120 initMenuItem_: function() { 121 var attrs = this.attrs; 122 this.className = 'menu-item ' + attrs.type; 123 this.menu_.addHandlers(this, this); 124 var label = document.createElement('div'); 125 126 label.className = 'menu-label'; 127 this.menu_.addLabelTo(this, attrs.label, label, 128 true /* enable mnemonic */); 129 130 if (attrs.font) { 131 label.style.font = attrs.font; 132 } 133 this.appendChild(label); 134 135 136 if (attrs.accel) { 137 var accel = document.createElement('div'); 138 accel.className = 'accelerator'; 139 accel.textContent = attrs.accel; 140 accel.style.font = attrs.font; 141 this.appendChild(accel); 142 } 143 144 if (attrs.type == 'submenu') { 145 // This overrides left-icon's position, but it's OK as submenu 146 // shoudln't have left-icon. 147 this.classList.add('right-icon'); 148 this.style.backgroundImage = 'url(' + this.menu_.config_.arrowUrl + ')'; 149 } 150 }, 151 152 initPadding_: function(leftIconWidth) { 153 if (leftIconWidth <= 0) { 154 this.classList.add('no-icon'); 155 return; 156 } 157 this.classList.add('left-icon'); 158 159 var url; 160 var attrs = this.attrs; 161 if (attrs.type == 'radio') { 162 url = attrs.checked ? 163 this.menu_.config_.radioOnUrl : 164 this.menu_.config_.radioOffUrl; 165 } else if (attrs.icon) { 166 url = attrs.icon; 167 } else if (attrs.type == 'check' && attrs.checked) { 168 url = this.menu_.config_.checkUrl; 169 } 170 if (url) { 171 this.style.backgroundImage = 'url(' + url + ')'; 172 } 173 // TODO(oshima): figure out how to update left padding in rule. 174 // 4 is the padding on left side of icon. 175 var padding = 176 4 + leftIconWidth + this.menu_.config_.icon_to_label_padding; 177 this.style.WebkitPaddingStart = padding + 'px'; 178 }, 179 }; 180 181 /** 182 * Menu class. 183 */ 184 var Menu = cr.ui.define('div'); 185 186 Menu.prototype = { 187 __proto__: HTMLDivElement.prototype, 188 189 /** 190 * Configuration object. 191 * @type {Object} 192 */ 193 config_: null, 194 195 /** 196 * Currently selected menu item. 197 * @type {MenuItem} 198 */ 199 current_: null, 200 201 /** 202 * Timers for opening/closing submenu. 203 * @type {number} 204 */ 205 openSubmenuTimer_: 0, 206 closeSubmenuTimer_: 0, 207 208 /** 209 * Auto scroll timer. 210 * @type {number} 211 */ 212 scrollTimer_: 0, 213 214 /** 215 * Pointer to a submenu currently shown, if any. 216 * @type {MenuItem} 217 */ 218 submenuShown_: null, 219 220 /** 221 * True if this menu is root. 222 * @type {boolean} 223 */ 224 isRoot_: false, 225 226 /** 227 * Total hight of scroll buttons. Used to adjust the height of 228 * viewport in order to show scroll bottons without scrollbar. 229 * @type {number} 230 */ 231 buttonHeight_: 0, 232 233 /** 234 * True to enable scroll button. 235 * @type {boolean} 236 */ 237 scrollEnabled: false, 238 239 /** 240 * Decorates the menu element. 241 */ 242 decorate: function() { 243 this.id = 'viewport'; 244 }, 245 246 /** 247 * Initialize the menu. 248 * @param {Object} config Configuration parameters in JSON format. 249 * See chromeos/views/native_menu_webui.cc for details. 250 */ 251 init: function(config) { 252 // List of menu items 253 this.items_ = []; 254 // Map from mnemonic character to item to activate 255 this.mnemonics_ = {}; 256 257 this.config_ = config; 258 this.addEventListener('mouseout', this.onMouseout_.bind(this)); 259 260 document.addEventListener('keydown', this.onKeydown_.bind(this)); 261 document.addEventListener('keypress', this.onKeypress_.bind(this)); 262 document.addEventListener('mousewheel', this.onMouseWheel_.bind(this)); 263 window.addEventListener('resize', this.onResize_.bind(this)); 264 265 // Setup scroll events. 266 var up = $('scroll-up'); 267 var down = $('scroll-down'); 268 up.addEventListener('mouseout', this.stopScroll_.bind(this)); 269 down.addEventListener('mouseout', this.stopScroll_.bind(this)); 270 var menu = this; 271 up.addEventListener('mouseover', 272 function() { 273 menu.autoScroll_(-SCROLL_TICK_PX); 274 }); 275 down.addEventListener('mouseover', 276 function() { 277 menu.autoScroll_(SCROLL_TICK_PX); 278 }); 279 280 this.buttonHeight_ = 281 up.getBoundingClientRect().height + 282 down.getBoundingClientRect().height; 283 }, 284 285 /** 286 * Adds a label to {@code targetDiv}. A label may contain 287 * mnemonic key, preceded by '&'. 288 * @param {MenuItem} item The menu item to be activated by mnemonic 289 * key. 290 * @param {string} label The label string to be added to 291 * {@code targetDiv}. 292 * @param {HTMLElement} div The div element the label is added to. 293 * @param {boolean} enableMnemonic True to enable mnemonic, or false 294 * to not to interprete mnemonic key. The function removes '&' 295 * from the label in both cases. 296 */ 297 addLabelTo: function(item, label, targetDiv, enableMnemonic) { 298 var mnemonic = MNEMONIC_REGEXP.exec(label); 299 if (mnemonic && enableMnemonic) { 300 var c = mnemonic[2].toLowerCase(); 301 this.mnemonics_[c] = item; 302 } 303 if (!mnemonic) { 304 targetDiv.textContent = label; 305 } else if (enableMnemonic) { 306 targetDiv.appendChild(document.createTextNode(mnemonic[1])); 307 targetDiv.appendChild(document.createElement('span')); 308 targetDiv.appendChild(document.createTextNode(mnemonic[3])); 309 targetDiv.childNodes[1].className = 'mnemonic'; 310 targetDiv.childNodes[1].textContent = mnemonic[2]; 311 } else { 312 targetDiv.textContent = mnemonic.splice(1, 3).join(''); 313 } 314 }, 315 316 /** 317 * @return {number} The index of the {@code item}. 318 */ 319 getMenuItemIndexOf: function(item) { 320 return this.items_.indexOf(item); 321 }, 322 323 /** 324 * A template method to create an item object. It can be a subclass 325 * of MenuItem, or any HTMLElement that implements {@code init}, 326 * {@code activate} methods as well as {@code selected} attribute. 327 * @param {Object} attrs The menu item's properties passed from C++. 328 * @return {MenuItem} The created menu item. 329 */ 330 createMenuItem: function(attrs) { 331 return new MenuItem(); 332 }, 333 334 /** 335 * Update and display the new model. 336 */ 337 updateModel: function(model) { 338 this.isRoot = model.isRoot; 339 this.current_ = null; 340 this.items_ = []; 341 this.mnemonics_ = {}; 342 this.innerHTML = ''; // remove menu items 343 344 for (var i = 0; i < model.items.length; i++) { 345 var attrs = model.items[i]; 346 var item = this.createMenuItem(attrs); 347 item.init(this, attrs, model); 348 this.items_.push(item); 349 } 350 this.onResize_(); 351 }, 352 353 /** 354 * Highlights the currently selected item, or 355 * select the 1st selectable item if none is selected. 356 */ 357 showSelection: function() { 358 if (this.current_) { 359 this.current_.selected = true; 360 } else { 361 this.findNextEnabled_(1).selected = true; 362 } 363 }, 364 365 /** 366 * Add event handlers for the item. 367 */ 368 addHandlers: function(item, target) { 369 var menu = this; 370 target.addEventListener('mouseover', function(event) { 371 menu.onMouseover_(event, item); 372 }); 373 if (item.attrs.enabled) { 374 target.addEventListener('mouseup', function(event) { 375 menu.onClick_(event, item); 376 }); 377 } else { 378 target.classList.add('disabled'); 379 } 380 }, 381 382 /** 383 * Set the selected item. This controls timers to open/close submenus. 384 * 1) If the selected menu is submenu, and that submenu is not yet opeend, 385 * start timer to open. This will not cancel close timer, so 386 * if there is a submenu opened, it will be closed before new submenu is 387 * open. 388 * 2) If the selected menu is submenu, and that submenu is already opened, 389 * cancel both open/close timer. 390 * 3) If the selected menu is not submenu, cancel all timers and start 391 * timer to close submenu. 392 * This prevents from opening/closing menus while you're actively 393 * navigating menus. To open submenu, you need to wait a bit, or click 394 * submenu. 395 * 396 * @param {MenuItem} item The selected item. 397 */ 398 set selectedItem(item) { 399 if (this.current_ != item) { 400 if (this.current_ != null) 401 this.current_.selected = false; 402 this.current_ = item; 403 this.makeSelectedItemVisible_(); 404 } 405 406 var menu = this; 407 if (item.attrs.type == 'submenu') { 408 if (this.submenuShown_ != item) { 409 this.openSubmenuTimer_ = 410 setTimeout( 411 function() { 412 menu.openSubmenu(item); 413 }, 414 SUBMENU_OPEN_DELAY_MS); 415 } else { 416 this.cancelSubmenuTimer_(); 417 } 418 } else if (this.submenuShown_) { 419 this.cancelSubmenuTimer_(); 420 this.closeSubmenuTimer_ = 421 setTimeout( 422 function() { 423 menu.closeSubmenu_(item); 424 }, 425 SUBMENU_CLOSE_DELAY_MS); 426 } 427 }, 428 429 /** 430 * Open submenu {@code item}. It does nothing if the submenu is 431 * already opened. 432 * @param {MenuItem} item The submenu item to open. 433 */ 434 openSubmenu: function(item) { 435 this.cancelSubmenuTimer_(); 436 if (this.submenuShown_ != item) { 437 this.submenuShown_ = item; 438 item.sendOpenSubmenuCommand(); 439 } 440 }, 441 442 /** 443 * Handle keyboard navigation and activation. 444 * @private 445 */ 446 onKeydown_: function(event) { 447 switch (event.keyIdentifier) { 448 case 'Left': 449 this.moveToParent_(); 450 break; 451 case 'Right': 452 this.moveToSubmenu_(); 453 break; 454 case 'Up': 455 this.classList.add('mnemonic-enabled'); 456 this.findNextEnabled_(-1).selected = true; 457 break; 458 case 'Down': 459 this.classList.add('mnemonic-enabled'); 460 this.findNextEnabled_(1).selected = true; 461 break; 462 case 'U+0009': // tab 463 break; 464 case 'U+001B': // escape 465 chrome.send('close_all'); 466 break; 467 case 'Enter': 468 case 'U+0020': // space 469 if (this.current_) { 470 this.current_.activate(); 471 } 472 break; 473 } 474 }, 475 476 /** 477 * Handle mnemonic keys. 478 * @private 479 */ 480 onKeypress_: function(event) { 481 // Handles mnemonic. 482 var c = String.fromCharCode(event.keyCode); 483 var item = this.mnemonics_[c.toLowerCase()]; 484 if (item) { 485 item.selected = true; 486 item.activate(); 487 } 488 }, 489 490 // Mouse Event handlers 491 onClick_: function(event, item) { 492 item.activate(); 493 }, 494 495 onMouseover_: function(event, item) { 496 this.cancelSubmenuTimer_(); 497 // Ignore false mouseover event at (0,0) which is 498 // emitted when opening submenu. 499 if (item.attrs.enabled && event.clientX != 0 && event.clientY != 0) { 500 item.selected = true; 501 } 502 }, 503 504 onMouseout_: function(event) { 505 if (this.current_) { 506 this.current_.selected = false; 507 } 508 }, 509 510 onResize_: function() { 511 var up = $('scroll-up'); 512 var down = $('scroll-down'); 513 // this needs to be < 2 as empty page has height of 1. 514 if (window.innerHeight < 2) { 515 // menu window is not visible yet. just hide buttons. 516 up.classList.add('hidden'); 517 down.classList.add('hidden'); 518 return; 519 } 520 // Do not use screen width to determin if we need scroll buttons 521 // as the max renderer hight can be shorter than actual screen size. 522 // TODO(oshima): Fix this when we implement transparent renderer. 523 if (this.scrollHeight > window.innerHeight && this.scrollEnabled) { 524 this.style.height = (window.innerHeight - this.buttonHeight_) + 'px'; 525 up.classList.remove('hidden'); 526 down.classList.remove('hidden'); 527 } else { 528 this.style.height = ''; 529 up.classList.add('hidden'); 530 down.classList.add('hidden'); 531 } 532 }, 533 534 onMouseWheel_: function(event) { 535 var delta = event.wheelDelta / 5; 536 this.scrollTop -= delta; 537 }, 538 539 /** 540 * Closes the submenu. 541 * a submenu. 542 * @private 543 */ 544 closeSubmenu_: function(item) { 545 this.submenuShown_ = null; 546 this.cancelSubmenuTimer_(); 547 chrome.send('close_submenu'); 548 }, 549 550 /** 551 * Move the selection to parent menu if the current menu is 552 * a submenu. 553 * @private 554 */ 555 moveToParent_: function() { 556 if (!this.isRoot) { 557 if (this.current_) { 558 this.current_.selected = false; 559 } 560 chrome.send('move_to_parent'); 561 } 562 }, 563 564 /** 565 * Move the selection to submenu if the currently selected 566 * menu is a submenu. 567 * @private 568 */ 569 moveToSubmenu_: function() { 570 var current = this.current_; 571 if (current && current.attrs.type == 'submenu') { 572 this.openSubmenu(current); 573 chrome.send('move_to_submenu'); 574 } 575 }, 576 577 /** 578 * Finds the next selectable item. If nothing is selected, the first 579 * selectable item will be chosen. Returns null if nothing is selectable. 580 * @param {number} incr Specifies the direction to search, 1 to 581 * downwards and -1 for upwards. 582 * @private 583 * @return {MenuItem} The next selectable item. 584 */ 585 findNextEnabled_: function(incr) { 586 var len = this.items_.length; 587 var index; 588 if (this.current_) { 589 index = this.getMenuItemIndexOf(this.current_); 590 } else { 591 index = incr > 0 ? -1 : len; 592 } 593 for (var i = 0; i < len; i++) { 594 index = (index + incr + len) % len; 595 var item = this.items_[index]; 596 if (item.attrs.enabled && item.attrs.type != 'separator' && 597 !item.classList.contains('hidden')) 598 return item; 599 } 600 return null; 601 }, 602 603 /** 604 * Cancels timers to open/close submenus. 605 * @private 606 */ 607 cancelSubmenuTimer_: function() { 608 clearTimeout(this.openSubmenuTimer_); 609 this.openSubmenuTimer_ = 0; 610 clearTimeout(this.closeSubmenuTimer_); 611 this.closeSubmenuTimer_ = 0; 612 }, 613 614 /** 615 * Starts auto scroll. 616 * @param {number} tick The number of pixels to scroll. 617 * @private 618 */ 619 autoScroll_: function(tick) { 620 var previous = this.scrollTop; 621 this.scrollTop += tick; 622 var menu = this; 623 this.scrollTimer_ = setTimeout( 624 function() { 625 menu.autoScroll_(tick); 626 }, 627 SCROLL_INTERVAL_MS); 628 }, 629 630 /** 631 * Stops auto scroll. 632 * @private 633 */ 634 stopScroll_: function() { 635 clearTimeout(this.scrollTimer_); 636 this.scrollTimer_ = 0; 637 }, 638 639 /** 640 * Scrolls the viewport to make the selected item visible. 641 * @private 642 */ 643 makeSelectedItemVisible_: function() { 644 this.current_.scrollIntoViewIfNeeded(false); 645 }, 646 }; 647 648 /** 649 * functions to be called from C++. 650 * @param {Object} config The viewport configuration. 651 */ 652 function init(config) { 653 $('viewport').init(config); 654 } 655 656 function selectItem() { 657 $('viewport').showSelection(); 658 } 659 660 function updateModel(model) { 661 $('viewport').updateModel(model); 662 } 663 664 function modelUpdated() { 665 chrome.send('model_updated'); 666 } 667 668 function enableScroll(enabled) { 669 $('viewport').scrollEnabled = enabled; 670 } 671