1// Copyright (c) 2013 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 section of the history page that shows tabs from sessions 7 on other devices. 8 */ 9 10/////////////////////////////////////////////////////////////////////////////// 11// Globals: 12/** @const */ var MAX_NUM_COLUMNS = 3; 13/** @const */ var NB_ENTRIES_FIRST_ROW_COLUMN = 6; 14/** @const */ var NB_ENTRIES_OTHER_ROWS_COLUMN = 0; 15 16// Histogram buckets for UMA tracking of menu usage. 17// Using the same values as the Other Devices button in the NTP. 18/** @const */ var HISTOGRAM_EVENT = { 19 INITIALIZED: 0, 20 SHOW_MENU: 1, 21 LINK_CLICKED: 2, 22 LINK_RIGHT_CLICKED: 3, 23 SESSION_NAME_RIGHT_CLICKED: 4, 24 SHOW_SESSION_MENU: 5, 25 COLLAPSE_SESSION: 6, 26 EXPAND_SESSION: 7, 27 OPEN_ALL: 8, 28 LIMIT: 9 // Should always be the last one. 29}; 30 31/** 32 * Record an event in the UMA histogram. 33 * @param {number} eventId The id of the event to be recorded. 34 * @private 35 */ 36function recordUmaEvent_(eventId) { 37 chrome.send('metricsHandler:recordInHistogram', 38 ['HistoryPage.OtherDevicesMenu', eventId, HISTOGRAM_EVENT.LIMIT]); 39} 40 41/////////////////////////////////////////////////////////////////////////////// 42// DeviceContextMenuController: 43 44/** 45 * Controller for the context menu for device names in the list of sessions. 46 * This class is designed to be used as a singleton. Also copied from existing 47 * other devices button in NTP. 48 * TODO(mad): Should we extract/reuse/share with ntp4/other_sessions.js? 49 * 50 * @constructor 51 */ 52function DeviceContextMenuController() { 53 this.__proto__ = DeviceContextMenuController.prototype; 54 this.initialize(); 55} 56cr.addSingletonGetter(DeviceContextMenuController); 57 58// DeviceContextMenuController, Public: --------------------------------------- 59 60/** 61 * Initialize the context menu for device names in the list of sessions. 62 */ 63DeviceContextMenuController.prototype.initialize = function() { 64 var menu = new cr.ui.Menu; 65 cr.ui.decorate(menu, cr.ui.Menu); 66 this.menu = menu; 67 this.collapseItem_ = this.appendMenuItem_('collapseSessionMenuItemText'); 68 this.collapseItem_.addEventListener('activate', 69 this.onCollapseOrExpand_.bind(this)); 70 this.expandItem_ = this.appendMenuItem_('expandSessionMenuItemText'); 71 this.expandItem_.addEventListener('activate', 72 this.onCollapseOrExpand_.bind(this)); 73 this.openAllItem_ = this.appendMenuItem_('restoreSessionMenuItemText'); 74 this.openAllItem_.addEventListener('activate', 75 this.onOpenAll_.bind(this)); 76}; 77 78/** 79 * Set the session data for the session the context menu was invoked on. 80 * This should never be called when the menu is visible. 81 * @param {Object} session The model object for the session. 82 */ 83DeviceContextMenuController.prototype.setSession = function(session) { 84 this.session_ = session; 85 this.updateMenuItems_(); 86}; 87 88// DeviceContextMenuController, Private: -------------------------------------- 89 90/** 91 * Appends a menu item to |this.menu|. 92 * @param {string} textId The ID for the localized string that acts as 93 * the item's label. 94 * @return {Element} The button used for a given menu option. 95 * @private 96 */ 97DeviceContextMenuController.prototype.appendMenuItem_ = function(textId) { 98 var button = document.createElement('button'); 99 this.menu.appendChild(button); 100 cr.ui.decorate(button, cr.ui.MenuItem); 101 button.textContent = loadTimeData.getString(textId); 102 return button; 103}; 104 105/** 106 * Handler for the 'Collapse' and 'Expand' menu items. 107 * @param {Event} e The activation event. 108 * @private 109 */ 110DeviceContextMenuController.prototype.onCollapseOrExpand_ = function(e) { 111 this.session_.collapsed = !this.session_.collapsed; 112 this.updateMenuItems_(); 113 chrome.send('setForeignSessionCollapsed', 114 [this.session_.tag, this.session_.collapsed]); 115 chrome.send('getForeignSessions'); // Refresh the list. 116 117 var eventId = this.session_.collapsed ? 118 HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION; 119 recordUmaEvent_(eventId); 120}; 121 122/** 123 * Handler for the 'Open all' menu item. 124 * @param {Event} e The activation event. 125 * @private 126 */ 127DeviceContextMenuController.prototype.onOpenAll_ = function(e) { 128 chrome.send('openForeignSession', [this.session_.tag]); 129 recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL); 130}; 131 132/** 133 * Set the visibility of the Expand/Collapse menu items based on the state 134 * of the session that this menu is currently associated with. 135 * @private 136 */ 137DeviceContextMenuController.prototype.updateMenuItems_ = function() { 138 this.collapseItem_.hidden = this.session_.collapsed; 139 this.expandItem_.hidden = !this.session_.collapsed; 140}; 141 142 143/////////////////////////////////////////////////////////////////////////////// 144// Device: 145 146/** 147 * Class to hold all the information about a device entry and generate a DOM 148 * node for it. 149 * @param {Object} session An object containing the device's session data. 150 * @param {DevicesView} view The view object this entry belongs to. 151 * @constructor 152 */ 153function Device(session, view) { 154 this.view_ = view; 155 this.session_ = session; 156 this.searchText_ = view.getSearchText(); 157} 158 159// Device, Public: ------------------------------------------------------------ 160 161/** 162 * Get the DOM node to display this device. 163 * @param {int} maxNumTabs The maximum number of tabs to display. 164 * @param {int} row The row in which this device is displayed. 165 * @return {Object} A DOM node to draw the device. 166 */ 167Device.prototype.getDOMNode = function(maxNumTabs, row) { 168 var deviceDiv = createElementWithClassName('div', 'device'); 169 this.row_ = row; 170 if (!this.session_) 171 return deviceDiv; 172 173 // Name heading 174 var heading = document.createElement('h3'); 175 heading.textContent = this.session_.name; 176 heading.sessionData_ = this.session_; 177 deviceDiv.appendChild(heading); 178 179 // Keep track of the drop down that triggered the menu, so we know 180 // which element to apply the command to. 181 var session = this.session_; 182 function handleDropDownFocus(e) { 183 DeviceContextMenuController.getInstance().setSession(session); 184 } 185 heading.addEventListener('contextmenu', handleDropDownFocus); 186 187 var dropDownButton = new cr.ui.ContextMenuButton; 188 dropDownButton.classList.add('drop-down'); 189 dropDownButton.addEventListener('mousedown', function(event) { 190 handleDropDownFocus(event); 191 // Mousedown handling of cr.ui.MenuButton.handleEvent calls 192 // preventDefault, which prevents blur of the focused element. We need to 193 // do blur manually. 194 document.activeElement.blur(); 195 }); 196 dropDownButton.addEventListener('focus', handleDropDownFocus); 197 heading.appendChild(dropDownButton); 198 199 var timeSpan = createElementWithClassName('div', 'device-timestamp'); 200 timeSpan.textContent = this.session_.modifiedTime; 201 heading.appendChild(timeSpan); 202 203 cr.ui.contextMenuHandler.setContextMenu( 204 heading, DeviceContextMenuController.getInstance().menu); 205 if (!this.session_.collapsed) 206 deviceDiv.appendChild(this.createSessionContents_(maxNumTabs)); 207 208 return deviceDiv; 209}; 210 211/** 212 * Marks tabs as hidden or not in our session based on the given searchText. 213 * @param {string} searchText The search text used to filter the content. 214 */ 215Device.prototype.setSearchText = function(searchText) { 216 this.searchText_ = searchText.toLowerCase(); 217 for (var i = 0; i < this.session_.windows.length; i++) { 218 var win = this.session_.windows[i]; 219 var foundMatch = false; 220 for (var j = 0; j < win.tabs.length; j++) { 221 var tab = win.tabs[j]; 222 if (tab.title.toLowerCase().indexOf(this.searchText_) != -1) { 223 foundMatch = true; 224 tab.hidden = false; 225 } else { 226 tab.hidden = true; 227 } 228 } 229 win.hidden = !foundMatch; 230 } 231}; 232 233// Device, Private ------------------------------------------------------------ 234 235/** 236 * Create the DOM tree representing the tabs and windows of this device. 237 * @param {int} maxNumTabs The maximum number of tabs to display. 238 * @return {Element} A single div containing the list of tabs & windows. 239 * @private 240 */ 241Device.prototype.createSessionContents_ = function(maxNumTabs) { 242 var contents = createElementWithClassName('div', 'device-contents'); 243 244 var sessionTag = this.session_.tag; 245 var numTabsShown = 0; 246 var numTabsHidden = 0; 247 for (var i = 0; i < this.session_.windows.length; i++) { 248 var win = this.session_.windows[i]; 249 if (win.hidden) 250 continue; 251 252 // Show a separator between multiple windows in the same session. 253 if (i > 0 && numTabsShown < maxNumTabs) 254 contents.appendChild(document.createElement('hr')); 255 256 for (var j = 0; j < win.tabs.length; j++) { 257 var tab = win.tabs[j]; 258 if (tab.hidden) 259 continue; 260 261 if (numTabsShown < maxNumTabs) { 262 numTabsShown++; 263 var a = createElementWithClassName('a', 'device-tab-entry'); 264 a.href = tab.url; 265 a.style.backgroundImage = getFaviconImageSet(tab.url); 266 this.addHighlightedText_(a, tab.title); 267 // Add a tooltip, since it might be ellipsized. The ones that are not 268 // necessary will be removed once added to the document, so we can 269 // compute sizes. 270 a.title = tab.title; 271 272 // We need to use this to not lose the ids as we go through other loop 273 // turns. 274 function makeClickHandler(sessionTag, windowId, tabId) { 275 return function(e) { 276 recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED); 277 chrome.send('openForeignSession', [sessionTag, windowId, tabId, 278 e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); 279 e.preventDefault(); 280 }; 281 }; 282 a.addEventListener('click', makeClickHandler(sessionTag, 283 String(win.sessionId), 284 String(tab.sessionId))); 285 contents.appendChild(a); 286 } else { 287 numTabsHidden++; 288 } 289 } 290 } 291 292 if (numTabsHidden > 0) { 293 var moreLinkButton = createElementWithClassName('button', 294 'device-show-more-tabs link-button'); 295 moreLinkButton.addEventListener('click', this.view_.increaseRowHeight.bind( 296 this.view_, this.row_, numTabsHidden)); 297 var xMore = loadTimeData.getString('xMore'); 298 moreLinkButton.appendChild( 299 document.createTextNode(xMore.replace('$1', numTabsHidden))); 300 contents.appendChild(moreLinkButton); 301 } 302 303 return contents; 304}; 305 306/** 307 * Add child text nodes to a node such that occurrences of this.searchText_ are 308 * highlighted. 309 * @param {Node} node The node under which new text nodes will be made as 310 * children. 311 * @param {string} content Text to be added beneath |node| as one or more 312 * text nodes. 313 * @private 314 */ 315Device.prototype.addHighlightedText_ = function(node, content) { 316 var endOfPreviousMatch = 0; 317 if (this.searchText_) { 318 var lowerContent = content.toLowerCase(); 319 var searchTextLenght = this.searchText_.length; 320 var newMatch = lowerContent.indexOf(this.searchText_, 0); 321 while (newMatch != -1) { 322 if (newMatch > endOfPreviousMatch) { 323 node.appendChild(document.createTextNode( 324 content.slice(endOfPreviousMatch, newMatch))); 325 } 326 endOfPreviousMatch = newMatch + searchTextLenght; 327 // Mark the highlighted text in bold. 328 var b = document.createElement('b'); 329 b.textContent = content.substring(newMatch, endOfPreviousMatch); 330 node.appendChild(b); 331 newMatch = lowerContent.indexOf(this.searchText_, endOfPreviousMatch); 332 } 333 } 334 if (endOfPreviousMatch < content.length) { 335 node.appendChild(document.createTextNode( 336 content.slice(endOfPreviousMatch))); 337 } 338}; 339 340/////////////////////////////////////////////////////////////////////////////// 341// DevicesView: 342 343/** 344 * Functions and state for populating the page with HTML. 345 * @constructor 346 */ 347function DevicesView() { 348 this.devices_ = []; // List of individual devices. 349 this.resultDiv_ = $('other-devices'); 350 this.searchText_ = ''; 351 this.rowHeights_ = [NB_ENTRIES_FIRST_ROW_COLUMN]; 352 this.updateSignInState(loadTimeData.getBoolean('isUserSignedIn')); 353 recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED); 354} 355 356// DevicesView, public: ------------------------------------------------------- 357 358/** 359 * Updates our sign in state by clearing the view is not signed in or sending 360 * a request to get the data to display otherwise. 361 * @param {boolean} signedIn Whether the user is signed in or not. 362 */ 363DevicesView.prototype.updateSignInState = function(signedIn) { 364 if (signedIn) 365 chrome.send('getForeignSessions'); 366 else 367 this.clearDOM(); 368}; 369 370/** 371 * Resets the view sessions. 372 * @param {Object} sessionList The sessions to add. 373 */ 374DevicesView.prototype.setSessionList = function(sessionList) { 375 this.devices_ = []; 376 for (var i = 0; i < sessionList.length; i++) 377 this.devices_.push(new Device(sessionList[i], this)); 378 this.displayResults_(); 379}; 380 381 382/** 383 * Sets the current search text. 384 * @param {string} searchText The text to search. 385 */ 386DevicesView.prototype.setSearchText = function(searchText) { 387 if (this.searchText_ != searchText) { 388 this.searchText_ = searchText; 389 for (var i = 0; i < this.devices_.length; i++) 390 this.devices_[i].setSearchText(searchText); 391 this.displayResults_(); 392 } 393}; 394 395/** 396 * @return {string} The current search text. 397 */ 398DevicesView.prototype.getSearchText = function() { 399 return this.searchText_; 400}; 401 402/** 403 * Clears the DOM content of the view. 404 */ 405DevicesView.prototype.clearDOM = function() { 406 while (this.resultDiv_.hasChildNodes()) { 407 this.resultDiv_.removeChild(this.resultDiv_.lastChild); 408 } 409}; 410 411/** 412 * Increase the height of a row by the given amount. 413 * @param {int} row The row number. 414 * @param {int} height The extra height to add to the givent row. 415 */ 416DevicesView.prototype.increaseRowHeight = function(row, height) { 417 for (var i = this.rowHeights_.length; i <= row; i++) 418 this.rowHeights_.push(NB_ENTRIES_OTHER_ROWS_COLUMN); 419 this.rowHeights_[row] += height; 420 this.displayResults_(); 421}; 422 423// DevicesView, Private ------------------------------------------------------- 424 425/** 426 * Update the page with results. 427 * @private 428 */ 429DevicesView.prototype.displayResults_ = function() { 430 this.clearDOM(); 431 var resultsFragment = document.createDocumentFragment(); 432 if (this.devices_.length == 0) 433 return; 434 435 // We'll increase to 0 as we create the first row. 436 var rowIndex = -1; 437 // We need to access the last row and device when we get out of the loop. 438 var currentRowElement; 439 // This is only set when changing rows, yet used on all device columns. 440 var maxNumTabs; 441 for (var i = 0; i < this.devices_.length; i++) { 442 var device = this.devices_[i]; 443 // Should we start a new row? 444 if (i % MAX_NUM_COLUMNS == 0) { 445 if (currentRowElement) 446 resultsFragment.appendChild(currentRowElement); 447 currentRowElement = createElementWithClassName('div', 'devices-row'); 448 rowIndex++; 449 if (rowIndex < this.rowHeights_.length) 450 maxNumTabs = this.rowHeights_[rowIndex]; 451 else 452 maxNumTabs = 0; 453 } 454 455 currentRowElement.appendChild(device.getDOMNode(maxNumTabs, rowIndex)); 456 } 457 if (currentRowElement) 458 resultsFragment.appendChild(currentRowElement); 459 460 this.resultDiv_.appendChild(resultsFragment); 461 // Remove the tootltip on all lines that don't need it. It's easier to 462 // remove them here, after adding them all above, since we have the data 463 // handy above, but we don't have the width yet. Whereas here, we have the 464 // width, and the nodeValue could contain sub nodes for highlighting, which 465 // makes it harder to extract the text data here. 466 tabs = document.getElementsByClassName('device-tab-entry'); 467 for (var i = 0; i < tabs.length; i++) { 468 if (tabs[i].scrollWidth <= tabs[i].clientWidth) 469 tabs[i].title = ''; 470 } 471 472 this.resultDiv_.appendChild( 473 createElementWithClassName('div', 'other-devices-bottom')); 474}; 475 476/** 477 * Sets the menu model data. An empty list means that either there are no 478 * foreign sessions, or tab sync is disabled for this profile. 479 * |isTabSyncEnabled| makes it possible to distinguish between the cases. 480 * 481 * @param {Array} sessionList Array of objects describing the sessions 482 * from other devices. 483 * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile? 484 */ 485function setForeignSessions(sessionList, isTabSyncEnabled) { 486 // The other devices is shown iff tab sync is enabled. 487 if (isTabSyncEnabled) 488 devicesView.setSessionList(sessionList); 489 else 490 devicesView.clearDOM(); 491} 492 493/** 494 * Called when this element is initialized, and from the new tab page when 495 * the user's signed in state changes, 496 * @param {string} header The first line of text (unused here). 497 * @param {string} subHeader The second line of text (unused here). 498 * @param {string} iconURL The url for the login status icon. If this is null 499 then the login status icon is hidden (unused here). 500 * @param {boolean} isUserSignedIn Is the user currently signed in? 501 */ 502function updateLogin(header, subHeader, iconURL, isUserSignedIn) { 503 if (devicesView) 504 devicesView.updateSignInState(isUserSignedIn); 505} 506 507/////////////////////////////////////////////////////////////////////////////// 508// Document Functions: 509/** 510 * Window onload handler, sets up the other devices view. 511 */ 512function load() { 513 if (!loadTimeData.getBoolean('isInstantExtendedApiEnabled')) 514 return; 515 516 // We must use this namespace to reuse the handler code for foreign session 517 // and login. 518 cr.define('ntp', function() { 519 return { 520 setForeignSessions: setForeignSessions, 521 updateLogin: updateLogin 522 }; 523 }); 524 525 devicesView = new DevicesView(); 526 527 // Create the context menu that appears when the user right clicks 528 // on a device name or hit click on the button besides the device name 529 document.body.appendChild(DeviceContextMenuController.getInstance().menu); 530 531 var doSearch = function(e) { 532 devicesView.setSearchText($('search-field').value); 533 }; 534 $('search-field').addEventListener('search', doSearch); 535 $('search-button').addEventListener('click', doSearch); 536} 537 538// Add handlers to HTML elements. 539document.addEventListener('DOMContentLoaded', load); 540