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. 6var SUBMENU_OPEN_DELAY_MS = 200; 7// How long to wait to close submenu when mouse left. 8var SUBMENU_CLOSE_DELAY_MS = 500; 9// Scroll repeat interval. 10var SCROLL_INTERVAL_MS = 20; 11// Scrolling amount in pixel. 12var SCROLL_TICK_PX = 4; 13// Regular expression to match/find mnemonic key. 14var MNEMONIC_REGEXP = /([^&]*)&(.)(.*)/; 15 16var 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 */ 26function sendActivate(index, mode) { 27 chrome.send('activate', [String(index), mode]); 28} 29 30/** 31 * MenuItem class. 32 */ 33var MenuItem = cr.ui.define('div'); 34 35MenuItem.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 */ 184var Menu = cr.ui.define('div'); 185 186Menu.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 * Scrollable Viewport. 228 * @type {HTMLElement} 229 */ 230 viewpotr_ : null, 231 232 /** 233 * Total hight of scroll buttons. Used to adjust the height of 234 * viewport in order to show scroll bottons without scrollbar. 235 * @type {number} 236 */ 237 buttonHeight_ : 0, 238 239 /** 240 * True to enable scroll button. 241 * @type {boolean} 242 */ 243 scrollEnabled : false, 244 245 /** 246 * Decorates the menu element. 247 */ 248 decorate: function() { 249 this.id = 'viewport'; 250 }, 251 252 /** 253 * Initialize the menu. 254 * @param {Object} config Configuration parameters in JSON format. 255 * See chromeos/views/native_menu_webui.cc for details. 256 */ 257 init: function(config) { 258 // List of menu items 259 this.items_ = []; 260 // Map from mnemonic character to item to activate 261 this.mnemonics_ = {}; 262 263 this.config_ = config; 264 this.addEventListener('mouseout', this.onMouseout_.bind(this)); 265 266 document.addEventListener('keydown', this.onKeydown_.bind(this)); 267 document.addEventListener('keypress', this.onKeypress_.bind(this)); 268 document.addEventListener('mousewheel', this.onMouseWheel_.bind(this)); 269 window.addEventListener('resize', this.onResize_.bind(this)); 270 271 // Setup scroll events. 272 var up = document.getElementById('scroll-up'); 273 var down = document.getElementById('scroll-down'); 274 up.addEventListener('mouseout', this.stopScroll_.bind(this)); 275 down.addEventListener('mouseout', this.stopScroll_.bind(this)); 276 var menu = this; 277 up.addEventListener('mouseover', 278 function() { 279 menu.autoScroll_(-SCROLL_TICK_PX); 280 }); 281 down.addEventListener('mouseover', 282 function() { 283 menu.autoScroll_(SCROLL_TICK_PX); 284 }); 285 286 this.buttonHeight_ = 287 up.getBoundingClientRect().height + 288 down.getBoundingClientRect().height; 289 }, 290 291 /** 292 * Adds a label to {@code targetDiv}. A label may contain 293 * mnemonic key, preceded by '&'. 294 * @param {MenuItem} item The menu item to be activated by mnemonic 295 * key. 296 * @param {string} label The label string to be added to 297 * {@code targetDiv}. 298 * @param {HTMLElement} div The div element the label is added to. 299 * @param {boolean} enableMnemonic True to enable mnemonic, or false 300 * to not to interprete mnemonic key. The function removes '&' 301 * from the label in both cases. 302 */ 303 addLabelTo: function(item, label, targetDiv, enableMnemonic) { 304 var mnemonic = MNEMONIC_REGEXP.exec(label); 305 if (mnemonic && enableMnemonic) { 306 var c = mnemonic[2].toLowerCase(); 307 this.mnemonics_[c] = item; 308 } 309 if (!mnemonic) { 310 targetDiv.textContent = label; 311 } else if (enableMnemonic) { 312 targetDiv.appendChild(document.createTextNode(mnemonic[1])); 313 targetDiv.appendChild(document.createElement('span')); 314 targetDiv.appendChild(document.createTextNode(mnemonic[3])); 315 targetDiv.childNodes[1].className = 'mnemonic'; 316 targetDiv.childNodes[1].textContent = mnemonic[2]; 317 } else { 318 targetDiv.textContent = mnemonic.splice(1, 3).join(''); 319 } 320 }, 321 322 /** 323 * Returns the index of the {@code item}. 324 */ 325 getMenuItemIndexOf: function(item) { 326 return this.items_.indexOf(item); 327 }, 328 329 /** 330 * A template method to create an item object. It can be a subclass 331 * of MenuItem, or any HTMLElement that implements {@code init}, 332 * {@code activate} methods as well as {@code selected} attribute. 333 * @param {Object} attrs The menu item's properties passed from C++. 334 */ 335 createMenuItem: function(attrs) { 336 return new MenuItem(); 337 }, 338 339 /** 340 * Update and display the new model. 341 */ 342 updateModel: function(model) { 343 this.isRoot = model.isRoot; 344 this.current_ = null; 345 this.items_ = []; 346 this.mnemonics_ = {}; 347 this.innerHTML = ''; // remove menu items 348 349 for (var i = 0; i < model.items.length; i++) { 350 var attrs = model.items[i]; 351 var item = this.createMenuItem(attrs); 352 item.init(this, attrs, model); 353 this.items_.push(item); 354 } 355 this.onResize_(); 356 }, 357 358 /** 359 * Highlights the currently selected item, or 360 * select the 1st selectable item if none is selected. 361 */ 362 showSelection: function() { 363 if (this.current_) { 364 this.current_.selected = true; 365 } else { 366 this.findNextEnabled_(1).selected = true; 367 } 368 }, 369 370 /** 371 * Add event handlers for the item. 372 */ 373 addHandlers: function(item, target) { 374 var menu = this; 375 target.addEventListener('mouseover', function(event) { 376 menu.onMouseover_(event, item); 377 }); 378 if (item.attrs.enabled) { 379 target.addEventListener('mouseup', function(event) { 380 menu.onClick_(event, item); 381 }); 382 } else { 383 target.classList.add('disabled'); 384 } 385 }, 386 387 /** 388 * Set the selected item. This controls timers to open/close submenus. 389 * 1) If the selected menu is submenu, and that submenu is not yet opeend, 390 * start timer to open. This will not cancel close timer, so 391 * if there is a submenu opened, it will be closed before new submenu is 392 * open. 393 * 2) If the selected menu is submenu, and that submenu is already opened, 394 * cancel both open/close timer. 395 * 3) If the selected menu is not submenu, cancel all timers and start 396 * timer to close submenu. 397 * This prevents from opening/closing menus while you're actively 398 * navigating menus. To open submenu, you need to wait a bit, or click 399 * submenu. 400 * 401 * @param {MenuItem} item The selected item. 402 */ 403 set selectedItem(item) { 404 if (this.current_ != item) { 405 if (this.current_ != null) 406 this.current_.selected = false; 407 this.current_ = item; 408 this.makeSelectedItemVisible_(); 409 } 410 411 var menu = this; 412 if (item.attrs.type == 'submenu') { 413 if (this.submenuShown_ != item) { 414 this.openSubmenuTimer_ = 415 setTimeout( 416 function() { 417 menu.openSubmenu(item); 418 }, 419 SUBMENU_OPEN_DELAY_MS); 420 } else { 421 this.cancelSubmenuTimer_(); 422 } 423 } else if (this.submenuShown_) { 424 this.cancelSubmenuTimer_(); 425 this.closeSubmenuTimer_ = 426 setTimeout( 427 function() { 428 menu.closeSubmenu_(item); 429 }, 430 SUBMENU_CLOSE_DELAY_MS); 431 } 432 }, 433 434 /** 435 * Open submenu {@code item}. It does nothing if the submenu is 436 * already opened. 437 * @param {MenuItem} item The submenu item to open. 438 */ 439 openSubmenu: function(item) { 440 this.cancelSubmenuTimer_(); 441 if (this.submenuShown_ != item) { 442 this.submenuShown_ = item; 443 item.sendOpenSubmenuCommand(); 444 } 445 }, 446 447 /** 448 * Handle keyboard navigation and activation. 449 * @private 450 */ 451 onKeydown_: function(event) { 452 switch (event.keyIdentifier) { 453 case 'Left': 454 this.moveToParent_(); 455 break; 456 case 'Right': 457 this.moveToSubmenu_(); 458 break; 459 case 'Up': 460 this.classList.add('mnemonic-enabled'); 461 this.findNextEnabled_(-1).selected = true; 462 break; 463 case 'Down': 464 this.classList.add('mnemonic-enabled'); 465 this.findNextEnabled_(1).selected = true; 466 break; 467 case 'U+0009': // tab 468 break; 469 case 'U+001B': // escape 470 chrome.send('close_all', []); 471 break; 472 case 'Enter': 473 case 'U+0020': // space 474 if (this.current_) { 475 this.current_.activate(); 476 } 477 break; 478 } 479 }, 480 481 /** 482 * Handle mnemonic keys. 483 * @private 484 */ 485 onKeypress_: function(event) { 486 // Handles mnemonic. 487 var c = String.fromCharCode(event.keyCode); 488 var item = this.mnemonics_[c.toLowerCase()]; 489 if (item) { 490 item.selected = true; 491 item.activate(); 492 } 493 }, 494 495 // Mouse Event handlers 496 onClick_: function(event, item) { 497 item.activate(); 498 }, 499 500 onMouseover_: function(event, item) { 501 this.cancelSubmenuTimer_(); 502 // Ignore false mouseover event at (0,0) which is 503 // emitted when opening submenu. 504 if (item.attrs.enabled && event.clientX != 0 && event.clientY != 0) { 505 item.selected = true; 506 } 507 }, 508 509 onMouseout_: function(event) { 510 if (this.current_) { 511 this.current_.selected = false; 512 } 513 }, 514 515 onResize_: function() { 516 var up = document.getElementById('scroll-up'); 517 var down = document.getElementById('scroll-down'); 518 // this needs to be < 2 as empty page has height of 1. 519 if (window.innerHeight < 2) { 520 // menu window is not visible yet. just hide buttons. 521 up.classList.add('hidden'); 522 down.classList.add('hidden'); 523 return; 524 } 525 // Do not use screen width to determin if we need scroll buttons 526 // as the max renderer hight can be shorter than actual screen size. 527 // TODO(oshima): Fix this when we implement transparent renderer. 528 if (this.scrollHeight > window.innerHeight && this.scrollEnabled) { 529 this.style.height = (window.innerHeight - this.buttonHeight_) + 'px'; 530 up.classList.remove('hidden'); 531 down.classList.remove('hidden'); 532 } else { 533 this.style.height = ''; 534 up.classList.add('hidden'); 535 down.classList.add('hidden'); 536 } 537 }, 538 539 onMouseWheel_: function(event) { 540 var delta = event.wheelDelta / 5; 541 this.scrollTop -= delta; 542 }, 543 544 /** 545 * Closes the submenu. 546 * a submenu. 547 * @private 548 */ 549 closeSubmenu_: function(item) { 550 this.submenuShown_ = null; 551 this.cancelSubmenuTimer_(); 552 chrome.send('close_submenu', []); 553 }, 554 555 /** 556 * Move the selection to parent menu if the current menu is 557 * a submenu. 558 * @private 559 */ 560 moveToParent_: function() { 561 if (!this.isRoot) { 562 if (this.current_) { 563 this.current_.selected = false; 564 } 565 chrome.send('move_to_parent', []); 566 } 567 }, 568 569 /** 570 * Move the selection to submenu if the currently selected 571 * menu is a submenu. 572 * @private 573 */ 574 moveToSubmenu_: function () { 575 var current = this.current_; 576 if (current && current.attrs.type == 'submenu') { 577 this.openSubmenu(current); 578 chrome.send('move_to_submenu', []); 579 } 580 }, 581 582 /** 583 * Find a next selectable item. If nothing is selected, the 1st 584 * selectable item will be chosen. Returns null if nothing is 585 * selectable. 586 * @param {number} incr Specifies the direction to search, 1 to 587 * downwards and -1 for upwards. 588 * @private 589 */ 590 findNextEnabled_: function(incr) { 591 var len = this.items_.length; 592 var index; 593 if (this.current_) { 594 index = this.getMenuItemIndexOf(this.current_); 595 } else { 596 index = incr > 0 ? -1 : len; 597 } 598 for (var i = 0; i < len; i++) { 599 index = (index + incr + len) % len; 600 var item = this.items_[index]; 601 if (item.attrs.enabled && item.attrs.type != 'separator' && 602 !item.classList.contains('hidden')) 603 return item; 604 } 605 return null; 606 }, 607 608 /** 609 * Cancels timers to open/close submenus. 610 * @private 611 */ 612 cancelSubmenuTimer_: function() { 613 clearTimeout(this.openSubmenuTimer_); 614 this.openSubmenuTimer_ = 0; 615 clearTimeout(this.closeSubmenuTimer_); 616 this.closeSubmenuTimer_ = 0; 617 }, 618 619 /** 620 * Starts auto scroll. 621 * @param {number} tick The number of pixels to scroll. 622 * @private 623 */ 624 autoScroll_: function(tick) { 625 var previous = this.scrollTop; 626 this.scrollTop += tick; 627 var menu = this; 628 this.scrollTimer_ = setTimeout( 629 function() { 630 menu.autoScroll_(tick); 631 }, 632 SCROLL_INTERVAL_MS); 633 }, 634 635 /** 636 * Stops auto scroll. 637 * @private 638 */ 639 stopScroll_: function () { 640 clearTimeout(this.scrollTimer_); 641 this.scrollTimer_ = 0; 642 }, 643 644 /** 645 * Scrolls the viewport to make the selected item visible. 646 * @private 647 */ 648 makeSelectedItemVisible_: function(){ 649 this.current_.scrollIntoViewIfNeeded(false); 650 }, 651}; 652 653/** 654 * functions to be called from C++. 655 */ 656function init(config) { 657 document.getElementById('viewport').init(config); 658} 659 660function selectItem() { 661 document.getElementById('viewport').showSelection(); 662} 663 664function updateModel(model) { 665 document.getElementById('viewport').updateModel(model); 666} 667 668function modelUpdated() { 669 chrome.send('model_updated', []); 670} 671 672function enableScroll(enabled) { 673 document.getElementById('viewport').scrollEnabled = enabled; 674} 675