Javascript  |  675行  |  17.74 KB

// Copyright (c) 2011 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// How long to wait to open submenu when mouse hovers.
var SUBMENU_OPEN_DELAY_MS = 200;
// How long to wait to close submenu when mouse left.
var SUBMENU_CLOSE_DELAY_MS = 500;
// Scroll repeat interval.
var SCROLL_INTERVAL_MS = 20;
// Scrolling amount in pixel.
var SCROLL_TICK_PX = 4;
// Regular expression to match/find mnemonic key.
var MNEMONIC_REGEXP = /([^&]*)&(.)(.*)/;

var localStrings = new LocalStrings();

/**
 * Sends 'activate' WebUI message.
 * @param {number} index The index of menu item to activate in menu model.
 * @param {string} mode The activation mode, one of 'close_and_activate', or
 *    'activate_no_close'.
 * TODO(oshima): change these string to enum numbers once it becomes possible
 * to pass number to C++.
 */
function sendActivate(index, mode) {
  chrome.send('activate', [String(index), mode]);
}

/**
 * MenuItem class.
 */
var MenuItem = cr.ui.define('div');

MenuItem.prototype = {
  __proto__ : HTMLDivElement.prototype,

  /**
   * Decorates the menu item element.
   */
  decorate: function() {
    this.className = 'menu-item';
  },

  /**
   * Initialize the MenuItem.
   * @param {Menu} menu A {@code Menu} object to which this menu item
   *    will be added to.
   * @param {Object} attrs JSON object that represents this menu items
   *    properties.  This is created from menu model in C code.  See
   *    chromeos/views/native_menu_webui.cc.
   * @param {Object} model The model object.
   */
  init: function(menu, attrs, model) {
    // The left icon's width. 0 if no icon.
    var leftIconWidth = model.maxIconWidth;
    this.menu_ = menu;
    this.attrs = attrs;
    var attrs = this.attrs;
    if (attrs.type == 'separator') {
      this.className = 'separator';
    } else if (attrs.type == 'command' ||
               attrs.type == 'submenu' ||
               attrs.type == 'check' ||
               attrs.type == 'radio') {
      this.initMenuItem_();
      this.initPadding_(leftIconWidth);
    } else {
      // This should not happend.
      this.classList.add('disabled');
      this.textContent = 'unknown';
    }

    menu.appendChild(this);
    if (!attrs.visible) {
      this.classList.add('hidden');
    }
  },

  /**
   * Changes the selection state of the menu item.
   * @param {boolean} selected True to set the selection, or false
   *     otherwise.
   */
  set selected(selected) {
    if (selected) {
      this.classList.add('selected');
      this.menu_.selectedItem = this;
    } else {
      this.classList.remove('selected');
    }
  },

  /**
   * Activate the menu item.
   */
  activate: function() {
    if (this.attrs.type == 'submenu') {
      this.menu_.openSubmenu(this);
    } else if (this.attrs.type != 'separator' &&
               this.className.indexOf('selected') >= 0) {
      sendActivate(this.menu_.getMenuItemIndexOf(this),
                   'close_and_activate');
    }
  },

  /**
   * Sends open_submenu WebUI message.
   */
  sendOpenSubmenuCommand: function() {
    chrome.send('open_submenu',
                [String(this.menu_.getMenuItemIndexOf(this)),
                 String(this.getBoundingClientRect().top)]);
  },

  /**
   * Internal method to initiailze the MenuItem.
   * @private
   */
  initMenuItem_: function() {
    var attrs = this.attrs;
    this.className = 'menu-item ' + attrs.type;
    this.menu_.addHandlers(this, this);
    var label = document.createElement('div');

    label.className = 'menu-label';
    this.menu_.addLabelTo(this, attrs.label, label,
                          true /* enable mnemonic */);

    if (attrs.font) {
      label.style.font = attrs.font;
    }
    this.appendChild(label);


    if (attrs.accel) {
      var accel = document.createElement('div');
      accel.className = 'accelerator';
      accel.textContent = attrs.accel;
      accel.style.font = attrs.font;
      this.appendChild(accel);
    }

    if (attrs.type == 'submenu') {
      // This overrides left-icon's position, but it's OK as submenu
      // shoudln't have left-icon.
      this.classList.add('right-icon');
      this.style.backgroundImage = 'url(' + this.menu_.config_.arrowUrl + ')';
    }
  },

  initPadding_: function(leftIconWidth) {
    if (leftIconWidth <= 0) {
      this.classList.add('no-icon');
      return;
    }
    this.classList.add('left-icon');

    var url;
    var attrs = this.attrs;
    if (attrs.type == 'radio') {
      url = attrs.checked ?
          this.menu_.config_.radioOnUrl :
          this.menu_.config_.radioOffUrl;
    } else if (attrs.icon) {
      url = attrs.icon;
    } else if (attrs.type == 'check' && attrs.checked) {
      url = this.menu_.config_.checkUrl;
    }
    if (url) {
      this.style.backgroundImage = 'url(' + url + ')';
    }
    // TODO(oshima): figure out how to update left padding in rule.
    // 4 is the padding on left side of icon.
    var padding =
        4 + leftIconWidth + this.menu_.config_.icon_to_label_padding;
    this.style.WebkitPaddingStart = padding + 'px';
  },
};

/**
 * Menu class.
 */
var Menu = cr.ui.define('div');

Menu.prototype = {
  __proto__: HTMLDivElement.prototype,

  /**
   * Configuration object.
   * @type {Object}
   */
  config_ : null,

  /**
   * Currently selected menu item.
   * @type {MenuItem}
   */
  current_ : null,

  /**
   * Timers for opening/closing submenu.
   * @type {number}
   */
  openSubmenuTimer_ : 0,
  closeSubmenuTimer_ : 0,

  /**
   * Auto scroll timer.
   * @type {number}
   */
  scrollTimer_ : 0,

  /**
   * Pointer to a submenu currently shown, if any.
   * @type {MenuItem}
   */
  submenuShown_ : null,

  /**
   * True if this menu is root.
   * @type {boolean}
   */
  isRoot_ : false,

  /**
   * Scrollable Viewport.
   * @type {HTMLElement}
   */
  viewpotr_ : null,

  /**
   * Total hight of scroll buttons. Used to adjust the height of
   * viewport in order to show scroll bottons without scrollbar.
   * @type {number}
   */
  buttonHeight_ : 0,

  /**
   * True to enable scroll button.
   * @type {boolean}
   */
  scrollEnabled : false,

  /**
   * Decorates the menu element.
   */
  decorate: function() {
    this.id = 'viewport';
  },

  /**
   * Initialize the menu.
   * @param {Object} config Configuration parameters in JSON format.
   *  See chromeos/views/native_menu_webui.cc for details.
   */
  init: function(config) {
    // List of menu items
    this.items_ = [];
    // Map from mnemonic character to item to activate
    this.mnemonics_ = {};

    this.config_ = config;
    this.addEventListener('mouseout', this.onMouseout_.bind(this));

    document.addEventListener('keydown', this.onKeydown_.bind(this));
    document.addEventListener('keypress', this.onKeypress_.bind(this));
    document.addEventListener('mousewheel', this.onMouseWheel_.bind(this));
    window.addEventListener('resize', this.onResize_.bind(this));

    // Setup scroll events.
    var up = document.getElementById('scroll-up');
    var down = document.getElementById('scroll-down');
    up.addEventListener('mouseout', this.stopScroll_.bind(this));
    down.addEventListener('mouseout', this.stopScroll_.bind(this));
    var menu = this;
    up.addEventListener('mouseover',
                        function() {
                          menu.autoScroll_(-SCROLL_TICK_PX);
                        });
    down.addEventListener('mouseover',
                          function() {
                            menu.autoScroll_(SCROLL_TICK_PX);
                          });

    this.buttonHeight_ =
        up.getBoundingClientRect().height +
        down.getBoundingClientRect().height;
  },

  /**
   * Adds a label to {@code targetDiv}. A label may contain
   * mnemonic key, preceded by '&'.
   * @param {MenuItem} item The menu item to be activated by mnemonic
   *    key.
   * @param {string} label The label string to be added to
   *    {@code targetDiv}.
   * @param {HTMLElement} div The div element the label is added to.
   * @param {boolean} enableMnemonic True to enable mnemonic, or false
   *    to not to interprete mnemonic key. The function removes '&'
   *    from the label in both cases.
   */
  addLabelTo: function(item, label, targetDiv, enableMnemonic) {
    var mnemonic = MNEMONIC_REGEXP.exec(label);
    if (mnemonic && enableMnemonic) {
      var c = mnemonic[2].toLowerCase();
      this.mnemonics_[c] = item;
    }
    if (!mnemonic) {
      targetDiv.textContent = label;
    } else if (enableMnemonic) {
      targetDiv.appendChild(document.createTextNode(mnemonic[1]));
      targetDiv.appendChild(document.createElement('span'));
      targetDiv.appendChild(document.createTextNode(mnemonic[3]));
      targetDiv.childNodes[1].className = 'mnemonic';
      targetDiv.childNodes[1].textContent = mnemonic[2];
    } else {
      targetDiv.textContent = mnemonic.splice(1, 3).join('');
    }
  },

  /**
   * Returns the index of the {@code item}.
   */
  getMenuItemIndexOf: function(item) {
    return this.items_.indexOf(item);
  },

  /**
   * A template method to create an item object. It can be a subclass
   * of MenuItem, or any HTMLElement that implements {@code init},
   * {@code activate} methods as well as {@code selected} attribute.
   * @param {Object} attrs The menu item's properties passed from C++.
   */
  createMenuItem: function(attrs) {
    return new MenuItem();
  },

  /**
   * Update and display the new model.
   */
  updateModel: function(model) {
    this.isRoot = model.isRoot;
    this.current_ = null;
    this.items_ = [];
    this.mnemonics_ = {};
    this.innerHTML = '';  // remove menu items

    for (var i = 0; i < model.items.length; i++) {
      var attrs = model.items[i];
      var item = this.createMenuItem(attrs);
      item.init(this, attrs, model);
      this.items_.push(item);
    }
    this.onResize_();
  },

  /**
   * Highlights the currently selected item, or
   * select the 1st selectable item if none is selected.
   */
  showSelection: function() {
    if (this.current_) {
      this.current_.selected = true;
    } else  {
      this.findNextEnabled_(1).selected = true;
    }
  },

  /**
   * Add event handlers for the item.
   */
  addHandlers: function(item, target) {
    var menu = this;
    target.addEventListener('mouseover', function(event) {
      menu.onMouseover_(event, item);
    });
    if (item.attrs.enabled) {
      target.addEventListener('mouseup', function(event) {
        menu.onClick_(event, item);
      });
    } else {
      target.classList.add('disabled');
    }
  },

  /**
   * Set the selected item. This controls timers to open/close submenus.
   * 1) If the selected menu is submenu, and that submenu is not yet opeend,
   *    start timer to open. This will not cancel close timer, so
   *    if there is a submenu opened, it will be closed before new submenu is
   *    open.
   * 2) If the selected menu is submenu, and that submenu is already opened,
   *    cancel both open/close timer.
   * 3) If the selected menu is not submenu, cancel all timers and start
   *    timer to close submenu.
   * This prevents from opening/closing menus while you're actively
   * navigating menus. To open submenu, you need to wait a bit, or click
   * submenu.
   *
   * @param {MenuItem} item The selected item.
   */
  set selectedItem(item) {
    if (this.current_ != item) {
      if (this.current_ != null)
        this.current_.selected = false;
      this.current_ = item;
      this.makeSelectedItemVisible_();
    }

    var menu = this;
    if (item.attrs.type == 'submenu') {
      if (this.submenuShown_ != item) {
        this.openSubmenuTimer_ =
            setTimeout(
                function() {
                  menu.openSubmenu(item);
                },
                SUBMENU_OPEN_DELAY_MS);
      } else {
        this.cancelSubmenuTimer_();
      }
    } else if (this.submenuShown_) {
      this.cancelSubmenuTimer_();
      this.closeSubmenuTimer_ =
          setTimeout(
              function() {
                menu.closeSubmenu_(item);
              },
              SUBMENU_CLOSE_DELAY_MS);
    }
  },

  /**
   * Open submenu {@code item}. It does nothing if the submenu is
   * already opened.
   * @param {MenuItem} item The submenu item to open.
   */
  openSubmenu: function(item) {
    this.cancelSubmenuTimer_();
    if (this.submenuShown_ != item) {
      this.submenuShown_ = item;
      item.sendOpenSubmenuCommand();
    }
  },

  /**
   * Handle keyboard navigation and activation.
   * @private
   */
  onKeydown_: function(event) {
    switch (event.keyIdentifier) {
      case 'Left':
        this.moveToParent_();
        break;
      case 'Right':
        this.moveToSubmenu_();
        break;
      case 'Up':
        this.classList.add('mnemonic-enabled');
        this.findNextEnabled_(-1).selected = true;
      break;
      case 'Down':
        this.classList.add('mnemonic-enabled');
        this.findNextEnabled_(1).selected = true;
        break;
      case 'U+0009':  // tab
         break;
      case 'U+001B':  // escape
        chrome.send('close_all', []);
        break;
      case 'Enter':
      case 'U+0020':  // space
        if (this.current_) {
          this.current_.activate();
        }
        break;
    }
  },

  /**
   * Handle mnemonic keys.
   * @private
   */
  onKeypress_: function(event) {
    // Handles mnemonic.
    var c = String.fromCharCode(event.keyCode);
    var item = this.mnemonics_[c.toLowerCase()];
    if (item) {
      item.selected = true;
      item.activate();
    }
  },

  // Mouse Event handlers
  onClick_: function(event, item) {
    item.activate();
  },

  onMouseover_: function(event, item) {
    this.cancelSubmenuTimer_();
    // Ignore false mouseover event at (0,0) which is
    // emitted when opening submenu.
    if (item.attrs.enabled && event.clientX != 0 && event.clientY != 0) {
      item.selected = true;
    }
  },

  onMouseout_: function(event) {
    if (this.current_) {
      this.current_.selected = false;
    }
  },

  onResize_: function() {
    var up = document.getElementById('scroll-up');
    var down = document.getElementById('scroll-down');
    // this needs to be < 2 as empty page has height of 1.
    if (window.innerHeight < 2) {
      // menu window is not visible yet. just hide buttons.
      up.classList.add('hidden');
      down.classList.add('hidden');
      return;
    }
    // Do not use screen width to determin if we need scroll buttons
    // as the max renderer hight can be shorter than actual screen size.
    // TODO(oshima): Fix this when we implement transparent renderer.
    if (this.scrollHeight > window.innerHeight && this.scrollEnabled) {
      this.style.height = (window.innerHeight - this.buttonHeight_) + 'px';
      up.classList.remove('hidden');
      down.classList.remove('hidden');
    } else {
      this.style.height = '';
      up.classList.add('hidden');
      down.classList.add('hidden');
    }
  },

  onMouseWheel_: function(event) {
    var delta = event.wheelDelta / 5;
    this.scrollTop -= delta;
  },

  /**
   * Closes the submenu.
   * a submenu.
   * @private
   */
  closeSubmenu_: function(item) {
    this.submenuShown_ = null;
    this.cancelSubmenuTimer_();
    chrome.send('close_submenu', []);
  },

  /**
   * Move the selection to parent menu if the current menu is
   * a submenu.
   * @private
   */
  moveToParent_: function() {
    if (!this.isRoot) {
      if (this.current_) {
        this.current_.selected = false;
      }
      chrome.send('move_to_parent', []);
    }
  },

  /**
   * Move the selection to submenu if the currently selected
   * menu is a submenu.
   * @private
   */
  moveToSubmenu_: function () {
    var current = this.current_;
    if (current && current.attrs.type == 'submenu') {
      this.openSubmenu(current);
      chrome.send('move_to_submenu', []);
    }
  },

  /**
   * Find a next selectable item. If nothing is selected, the 1st
   * selectable item will be chosen. Returns null if nothing is
   * selectable.
   * @param {number} incr Specifies the direction to search, 1 to
   * downwards and -1 for upwards.
   * @private
   */
  findNextEnabled_: function(incr) {
    var len = this.items_.length;
    var index;
    if (this.current_) {
      index = this.getMenuItemIndexOf(this.current_);
    } else {
      index = incr > 0 ? -1 : len;
    }
    for (var i = 0; i < len; i++) {
      index = (index + incr + len) % len;
      var item = this.items_[index];
      if (item.attrs.enabled && item.attrs.type != 'separator' &&
          !item.classList.contains('hidden'))
        return item;
    }
    return null;
  },

  /**
   * Cancels timers to open/close submenus.
   * @private
   */
  cancelSubmenuTimer_: function() {
    clearTimeout(this.openSubmenuTimer_);
    this.openSubmenuTimer_ = 0;
    clearTimeout(this.closeSubmenuTimer_);
    this.closeSubmenuTimer_ = 0;
  },

  /**
   * Starts auto scroll.
   * @param {number} tick The number of pixels to scroll.
   * @private
   */
  autoScroll_: function(tick) {
    var previous = this.scrollTop;
    this.scrollTop += tick;
    var menu = this;
    this.scrollTimer_ = setTimeout(
        function() {
          menu.autoScroll_(tick);
        },
        SCROLL_INTERVAL_MS);
  },

  /**
   * Stops auto scroll.
   * @private
   */
  stopScroll_: function () {
    clearTimeout(this.scrollTimer_);
    this.scrollTimer_ = 0;
  },

  /**
   * Scrolls the viewport to make the selected item visible.
   * @private
   */
  makeSelectedItemVisible_: function(){
    this.current_.scrollIntoViewIfNeeded(false);
  },
};

/**
 * functions to be called from C++.
 */
function init(config) {
  document.getElementById('viewport').init(config);
}

function selectItem() {
  document.getElementById('viewport').showSelection();
}

function updateModel(model) {
  document.getElementById('viewport').updateModel(model);
}

function modelUpdated() {
  chrome.send('model_updated', []);
}

function enableScroll(enabled) {
  document.getElementById('viewport').scrollEnabled = enabled;
}