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