blob: 598e58bfdc3b34bd3ede4eb9548dab0c49b8e853 [file] [log] [blame]
// 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,
/**
* 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 = $('scroll-up');
var down = $('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('');
}
},
/**
* @return {number} 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++.
* @return {MenuItem} The created menu item.
*/
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 = $('scroll-up');
var down = $('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');
}
},
/**
* Finds the next selectable item. If nothing is selected, the first
* 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
* @return {MenuItem} The next selectable item.
*/
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++.
* @param {Object} config The viewport configuration.
*/
function init(config) {
$('viewport').init(config);
}
function selectItem() {
$('viewport').showSelection();
}
function updateModel(model) {
$('viewport').updateModel(model);
}
function modelUpdated() {
chrome.send('model_updated');
}
function enableScroll(enabled) {
$('viewport').scrollEnabled = enabled;
}