Home | History | Annotate | Download | only in resources
      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    * 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  */
    656 function init(config) {
    657   document.getElementById('viewport').init(config);
    658 }
    659 
    660 function selectItem() {
    661   document.getElementById('viewport').showSelection();
    662 }
    663 
    664 function updateModel(model) {
    665   document.getElementById('viewport').updateModel(model);
    666 }
    667 
    668 function modelUpdated() {
    669   chrome.send('model_updated', []);
    670 }
    671 
    672 function enableScroll(enabled) {
    673   document.getElementById('viewport').scrollEnabled = enabled;
    674 }
    675