1// Copyright (c) 2012 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/** 6 * @fileoverview The menu that shows tabs from sessions on other devices. 7 */ 8 9cr.define('ntp', function() { 10 'use strict'; 11 12 /** @const */ var ContextMenuButton = cr.ui.ContextMenuButton; 13 /** @const */ var Menu = cr.ui.Menu; 14 /** @const */ var MenuItem = cr.ui.MenuItem; 15 /** @const */ var MenuButton = cr.ui.MenuButton; 16 /** @const */ var OtherSessionsMenuButton = cr.ui.define('button'); 17 18 // Histogram buckets for UMA tracking of menu usage. 19 /** @const */ var HISTOGRAM_EVENT = { 20 INITIALIZED: 0, 21 SHOW_MENU: 1, 22 LINK_CLICKED: 2, 23 LINK_RIGHT_CLICKED: 3, 24 SESSION_NAME_RIGHT_CLICKED: 4, 25 SHOW_SESSION_MENU: 5, 26 COLLAPSE_SESSION: 6, 27 EXPAND_SESSION: 7, 28 OPEN_ALL: 8 29 }; 30 /** @const */ var HISTOGRAM_EVENT_LIMIT = 31 HISTOGRAM_EVENT.OPEN_ALL + 1; 32 33 /** 34 * Record an event in the UMA histogram. 35 * @param {number} eventId The id of the event to be recorded. 36 * @private 37 */ 38 function recordUmaEvent_(eventId) { 39 chrome.send('metricsHandler:recordInHistogram', 40 ['NewTabPage.OtherSessionsMenu', eventId, HISTOGRAM_EVENT_LIMIT]); 41 } 42 43 OtherSessionsMenuButton.prototype = { 44 __proto__: MenuButton.prototype, 45 46 decorate: function() { 47 MenuButton.prototype.decorate.call(this); 48 this.menu = new Menu; 49 cr.ui.decorate(this.menu, Menu); 50 this.menu.menuItemSelector = '[role=menuitem]'; 51 this.menu.classList.add('footer-menu'); 52 this.menu.addEventListener('contextmenu', 53 this.onContextMenu_.bind(this), true); 54 document.body.appendChild(this.menu); 55 56 // Create the context menu that appears when the user right clicks 57 // on a device name. 58 this.deviceContextMenu_ = DeviceContextMenuController.getInstance().menu; 59 document.body.appendChild(this.deviceContextMenu_); 60 61 this.promoMessage_ = $('other-sessions-promo-template').cloneNode(true); 62 this.promoMessage_.removeAttribute('id'); // Prevent a duplicate id. 63 64 this.sessions_ = []; 65 this.anchorType = cr.ui.AnchorType.ABOVE; 66 this.invertLeftRight = true; 67 68 // Initialize the images for the drop-down buttons that appear beside the 69 // session names. 70 MenuButton.createDropDownArrows(); 71 72 recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED); 73 }, 74 75 /** 76 * Initialize this element. 77 * @param {boolean} signedIn Is the current user signed in? 78 */ 79 initialize: function(signedIn) { 80 this.updateSignInState(signedIn); 81 }, 82 83 /** 84 * Handle a context menu event for an object in the menu's DOM subtree. 85 */ 86 onContextMenu_: function(e) { 87 // Only record the action if it occurred in one of the menu items or 88 // on one of the session headings. 89 if (findAncestorByClass(e.target, 'footer-menu-item')) { 90 recordUmaEvent_(HISTOGRAM_EVENT.LINK_RIGHT_CLICKED); 91 } else { 92 var heading = findAncestorByClass(e.target, 'session-heading'); 93 if (heading) { 94 recordUmaEvent_(HISTOGRAM_EVENT.SESSION_NAME_RIGHT_CLICKED); 95 96 // Let the context menu know which session it was invoked on, 97 // since they all share the same instance of the menu. 98 DeviceContextMenuController.getInstance().setSession( 99 heading.sessionData_); 100 } 101 } 102 }, 103 104 /** 105 * Hides the menu. 106 * @override 107 */ 108 hideMenu: function() { 109 // Don't hide if the device context menu is currently showing. 110 if (this.deviceContextMenu_.hidden) 111 MenuButton.prototype.hideMenu.call(this); 112 }, 113 114 /** 115 * Shows the menu, first rebuilding it if necessary. 116 * TODO(estade): the right of the menu should align with the right of the 117 * button. 118 * @override 119 */ 120 showMenu: function(shouldSetFocus) { 121 if (this.sessions_.length == 0) 122 chrome.send('getForeignSessions'); 123 recordUmaEvent_(HISTOGRAM_EVENT.SHOW_MENU); 124 MenuButton.prototype.showMenu.apply(this, arguments); 125 126 // Work around https://bugs.webkit.org/show_bug.cgi?id=85884. 127 this.menu.scrollTop = 0; 128 }, 129 130 /** 131 * Reset the menu contents to the default state. 132 * @private 133 */ 134 resetMenuContents_: function() { 135 this.menu.innerHTML = ''; 136 this.menu.appendChild(this.promoMessage_); 137 }, 138 139 /** 140 * Create a custom click handler for a link, so that clicking on a link 141 * restores the session (including back stack) rather than just opening 142 * the URL. 143 */ 144 makeClickHandler_: function(sessionTag, windowId, tabId) { 145 var self = this; 146 return function(e) { 147 recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED); 148 chrome.send('openForeignSession', [sessionTag, windowId, tabId, 149 e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); 150 e.preventDefault(); 151 }; 152 }, 153 154 /** 155 * Add the UI for a foreign session to the menu. 156 * @param {Object} session Object describing the foreign session. 157 */ 158 addSession_: function(session) { 159 var doc = this.ownerDocument; 160 161 var section = doc.createElement('section'); 162 this.menu.appendChild(section); 163 164 var heading = doc.createElement('h3'); 165 heading.className = 'session-heading'; 166 heading.textContent = session.name; 167 heading.sessionData_ = session; 168 section.appendChild(heading); 169 170 var dropDownButton = new ContextMenuButton; 171 dropDownButton.classList.add('drop-down'); 172 // Keep track of the drop down that triggered the menu, so we know 173 // which element to apply the command to. 174 function handleDropDownFocus(e) { 175 DeviceContextMenuController.getInstance().setSession(session); 176 } 177 dropDownButton.addEventListener('mousedown', handleDropDownFocus); 178 dropDownButton.addEventListener('focus', handleDropDownFocus); 179 heading.appendChild(dropDownButton); 180 181 var timeSpan = doc.createElement('span'); 182 timeSpan.className = 'details'; 183 timeSpan.textContent = session.modifiedTime; 184 heading.appendChild(timeSpan); 185 186 cr.ui.contextMenuHandler.setContextMenu(heading, 187 this.deviceContextMenu_); 188 189 if (!session.collapsed) 190 section.appendChild(this.createSessionContents_(session)); 191 }, 192 193 /** 194 * Create the DOM tree representing the tabs and windows in a session. 195 * @param {Object} session The session model object. 196 * @return {Element} A single div containing the list of tabs & windows. 197 * @private 198 */ 199 createSessionContents_: function(session) { 200 var doc = this.ownerDocument; 201 var contents = doc.createElement('div'); 202 203 for (var i = 0; i < session.windows.length; i++) { 204 var window = session.windows[i]; 205 206 // Show a separator between multiple windows in the same session. 207 if (i > 0) 208 contents.appendChild(doc.createElement('hr')); 209 210 for (var j = 0; j < window.tabs.length; j++) { 211 var tab = window.tabs[j]; 212 var a = doc.createElement('a'); 213 a.className = 'footer-menu-item'; 214 a.textContent = tab.title; 215 a.href = tab.url; 216 a.style.backgroundImage = getFaviconImageSet(tab.url); 217 218 var clickHandler = this.makeClickHandler_( 219 session.tag, String(window.sessionId), String(tab.sessionId)); 220 a.addEventListener('click', clickHandler); 221 contents.appendChild(a); 222 cr.ui.decorate(a, MenuItem); 223 } 224 } 225 226 return contents; 227 }, 228 229 /** 230 * Sets the menu model data. An empty list means that either there are no 231 * foreign sessions, or tab sync is disabled for this profile. 232 * |isTabSyncEnabled| makes it possible to distinguish between the cases. 233 * 234 * @param {Array} sessionList Array of objects describing the sessions 235 * from other devices. 236 * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile? 237 */ 238 setForeignSessions: function(sessionList, isTabSyncEnabled) { 239 this.sessions_ = sessionList; 240 this.resetMenuContents_(); 241 if (sessionList.length > 0) { 242 // Rebuild the menu with the new data. 243 for (var i = 0; i < sessionList.length; i++) { 244 this.addSession_(sessionList[i]); 245 } 246 } 247 248 // The menu button is shown iff tab sync is enabled. 249 this.hidden = !isTabSyncEnabled; 250 }, 251 252 /** 253 * Called when this element is initialized, and from the new tab page when 254 * the user's signed in state changes, 255 * @param {boolean} signedIn Is the user currently signed in? 256 */ 257 updateSignInState: function(signedIn) { 258 if (signedIn) 259 chrome.send('getForeignSessions'); 260 else 261 this.hidden = true; 262 }, 263 }; 264 265 /** 266 * Controller for the context menu for device names in the list of sessions. 267 * This class is designed to be used as a singleton. 268 * 269 * @constructor 270 */ 271 function DeviceContextMenuController() { 272 this.__proto__ = DeviceContextMenuController.prototype; 273 this.initialize(); 274 } 275 cr.addSingletonGetter(DeviceContextMenuController); 276 277 DeviceContextMenuController.prototype = { 278 279 initialize: function() { 280 var menu = new cr.ui.Menu; 281 cr.ui.decorate(menu, cr.ui.Menu); 282 menu.classList.add('device-context-menu'); 283 menu.classList.add('footer-menu-context-menu'); 284 this.menu = menu; 285 this.collapseItem_ = this.appendMenuItem_('collapseSessionMenuItemText'); 286 this.collapseItem_.addEventListener('activate', 287 this.onCollapseOrExpand_.bind(this)); 288 this.expandItem_ = this.appendMenuItem_('expandSessionMenuItemText'); 289 this.expandItem_.addEventListener('activate', 290 this.onCollapseOrExpand_.bind(this)); 291 this.openAllItem_ = this.appendMenuItem_('restoreSessionMenuItemText'); 292 this.openAllItem_.addEventListener('activate', 293 this.onOpenAll_.bind(this)); 294 }, 295 296 /** 297 * Appends a menu item to |this.menu|. 298 * @param {string} textId The ID for the localized string that acts as 299 * the item's label. 300 */ 301 appendMenuItem_: function(textId) { 302 var button = cr.doc.createElement('button'); 303 this.menu.appendChild(button); 304 cr.ui.decorate(button, cr.ui.MenuItem); 305 button.textContent = loadTimeData.getString(textId); 306 return button; 307 }, 308 309 /** 310 * Handler for the 'Collapse' and 'Expand' menu items. 311 * @param {Event} e The activation event. 312 * @private 313 */ 314 onCollapseOrExpand_: function(e) { 315 this.session_.collapsed = !this.session_.collapsed; 316 this.updateMenuItems_(); 317 chrome.send('setForeignSessionCollapsed', 318 [this.session_.tag, this.session_.collapsed]); 319 chrome.send('getForeignSessions'); // Refresh the list. 320 321 var eventId = this.session_.collapsed ? 322 HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION; 323 recordUmaEvent_(eventId); 324 }, 325 326 /** 327 * Handler for the 'Open all' menu item. 328 * @param {Event} e The activation event. 329 * @private 330 */ 331 onOpenAll_: function(e) { 332 chrome.send('openForeignSession', [this.session_.tag]); 333 recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL); 334 }, 335 336 /** 337 * Set the session data for the session the context menu was invoked on. 338 * This should never be called when the menu is visible. 339 * @param {Object} session The model object for the session. 340 */ 341 setSession: function(session) { 342 this.session_ = session; 343 this.updateMenuItems_(); 344 }, 345 346 /** 347 * Set the visibility of the Expand/Collapse menu items based on the state 348 * of the session that this menu is currently associated with. 349 * @private 350 */ 351 updateMenuItems_: function() { 352 this.collapseItem_.hidden = this.session_.collapsed; 353 this.expandItem_.hidden = !this.session_.collapsed; 354 } 355 }; 356 357 return { 358 OtherSessionsMenuButton: OtherSessionsMenuButton, 359 }; 360}); 361