| // 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; |
| } |