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.
6 * @fileoverview The section of the history page that shows tabs from sessions
10 ///////////////////////////////////////////////////////////////////////////////
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;
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 = {
22 LINK_RIGHT_CLICKED: 3,
23 SESSION_NAME_RIGHT_CLICKED: 4,
28 LIMIT: 9 // Should always be the last one.
32 * Record an event in the UMA histogram.
33 * @param {number} eventId The id of the event to be recorded.
36 function recordUmaEvent_(eventId) {
37 chrome.send('metricsHandler:recordInHistogram',
38 ['HistoryPage.OtherDevicesMenu', eventId, HISTOGRAM_EVENT.LIMIT]);
41 ///////////////////////////////////////////////////////////////////////////////
42 // DeviceContextMenuController:
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?
52 function DeviceContextMenuController() {
53 this.__proto__ = DeviceContextMenuController.prototype;
56 cr.addSingletonGetter(DeviceContextMenuController);
58 // DeviceContextMenuController, Public: ---------------------------------------
61 * Initialize the context menu for device names in the list of sessions.
63 DeviceContextMenuController.prototype.initialize = function() {
64 var menu = new cr.ui.Menu;
65 cr.ui.decorate(menu, cr.ui.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));
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.
83 DeviceContextMenuController.prototype.setSession = function(session) {
84 this.session_ = session;
85 this.updateMenuItems_();
88 // DeviceContextMenuController, Private: --------------------------------------
91 * Appends a menu item to |this.menu|.
92 * @param {string} textId The ID for the localized string that acts as
94 * @return {Element} The button used for a given menu option.
97 DeviceContextMenuController.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);
106 * Handler for the 'Collapse' and 'Expand' menu items.
107 * @param {Event} e The activation event.
110 DeviceContextMenuController.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.
117 var eventId = this.session_.collapsed ?
118 HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION;
119 recordUmaEvent_(eventId);
123 * Handler for the 'Open all' menu item.
124 * @param {Event} e The activation event.
127 DeviceContextMenuController.prototype.onOpenAll_ = function(e) {
128 chrome.send('openForeignSession', [this.session_.tag]);
129 recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL);
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.
137 DeviceContextMenuController.prototype.updateMenuItems_ = function() {
138 this.collapseItem_.hidden = this.session_.collapsed;
139 this.expandItem_.hidden = !this.session_.collapsed;
143 ///////////////////////////////////////////////////////////////////////////////
147 * Class to hold all the information about a device entry and generate a DOM
149 * @param {Object} session An object containing the device's session data.
150 * @param {DevicesView} view The view object this entry belongs to.
153 function Device(session, view) {
155 this.session_ = session;
156 this.searchText_ = view.getSearchText();
159 // Device, Public: ------------------------------------------------------------
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.
167 Device.prototype.getDOMNode = function(maxNumTabs, row) {
168 var deviceDiv = createElementWithClassName('div', 'device');
174 var heading = document.createElement('h3');
175 heading.textContent = this.session_.name;
176 heading.sessionData_ = this.session_;
177 deviceDiv.appendChild(heading);
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);
185 heading.addEventListener('contextmenu', handleDropDownFocus);
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
194 document.activeElement.blur();
196 dropDownButton.addEventListener('focus', handleDropDownFocus);
197 heading.appendChild(dropDownButton);
199 var timeSpan = createElementWithClassName('div', 'device-timestamp');
200 timeSpan.textContent = this.session_.modifiedTime;
201 heading.appendChild(timeSpan);
203 cr.ui.contextMenuHandler.setContextMenu(
204 heading, DeviceContextMenuController.getInstance().menu);
205 if (!this.session_.collapsed)
206 deviceDiv.appendChild(this.createSessionContents_(maxNumTabs));
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.
215 Device.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) {
229 win.hidden = !foundMatch;
233 // Device, Private ------------------------------------------------------------
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.
241 Device.prototype.createSessionContents_ = function(maxNumTabs) {
242 var contents = createElementWithClassName('div', 'device-contents');
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];
252 // Show a separator between multiple windows in the same session.
253 if (i > 0 && numTabsShown < maxNumTabs)
254 contents.appendChild(document.createElement('hr'));
256 for (var j = 0; j < win.tabs.length; j++) {
257 var tab = win.tabs[j];
261 if (numTabsShown < maxNumTabs) {
263 var a = createElementWithClassName('a', 'device-tab-entry');
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
272 // We need to use this to not lose the ids as we go through other loop
274 function makeClickHandler(sessionTag, windowId, tabId) {
276 recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED);
277 chrome.send('openForeignSession', [sessionTag, windowId, tabId,
278 e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]);
282 a.addEventListener('click', makeClickHandler(sessionTag,
283 String(win.sessionId),
284 String(tab.sessionId)));
285 contents.appendChild(a);
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);
307 * Add child text nodes to a node such that occurrences of this.searchText_ are
309 * @param {Node} node The node under which new text nodes will be made as
311 * @param {string} content Text to be added beneath |node| as one or more
315 Device.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)));
326 endOfPreviousMatch = newMatch + searchTextLenght;
327 // Mark the highlighted text in bold.
328 var b = document.createElement('b');
329 b.textContent = content.substring(newMatch, endOfPreviousMatch);
331 newMatch = lowerContent.indexOf(this.searchText_, endOfPreviousMatch);
334 if (endOfPreviousMatch < content.length) {
335 node.appendChild(document.createTextNode(
336 content.slice(endOfPreviousMatch)));
340 ///////////////////////////////////////////////////////////////////////////////
344 * Functions and state for populating the page with HTML.
347 function 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);
356 // DevicesView, public: -------------------------------------------------------
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.
363 DevicesView.prototype.updateSignInState = function(signedIn) {
365 chrome.send('getForeignSessions');
371 * Resets the view sessions.
372 * @param {Object} sessionList The sessions to add.
374 DevicesView.prototype.setSessionList = function(sessionList) {
376 for (var i = 0; i < sessionList.length; i++)
377 this.devices_.push(new Device(sessionList[i], this));
378 this.displayResults_();
383 * Sets the current search text.
384 * @param {string} searchText The text to search.
386 DevicesView.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_();
396 * @return {string} The current search text.
398 DevicesView.prototype.getSearchText = function() {
399 return this.searchText_;
403 * Clears the DOM content of the view.
405 DevicesView.prototype.clearDOM = function() {
406 while (this.resultDiv_.hasChildNodes()) {
407 this.resultDiv_.removeChild(this.resultDiv_.lastChild);
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.
416 DevicesView.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_();
423 // DevicesView, Private -------------------------------------------------------
426 * Update the page with results.
429 DevicesView.prototype.displayResults_ = function() {
431 var resultsFragment = document.createDocumentFragment();
432 if (this.devices_.length == 0)
435 // We'll increase to 0 as we create the first row.
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.
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');
449 if (rowIndex < this.rowHeights_.length)
450 maxNumTabs = this.rowHeights_[rowIndex];
455 currentRowElement.appendChild(device.getDOMNode(maxNumTabs, rowIndex));
457 if (currentRowElement)
458 resultsFragment.appendChild(currentRowElement);
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)
472 this.resultDiv_.appendChild(
473 createElementWithClassName('div', 'other-devices-bottom'));
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.
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?
485 function setForeignSessions(sessionList, isTabSyncEnabled) {
486 // The other devices is shown iff tab sync is enabled.
487 if (isTabSyncEnabled)
488 devicesView.setSessionList(sessionList);
490 devicesView.clearDOM();
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?
502 function updateLogin(header, subHeader, iconURL, isUserSignedIn) {
504 devicesView.updateSignInState(isUserSignedIn);
507 ///////////////////////////////////////////////////////////////////////////////
508 // Document Functions:
510 * Window onload handler, sets up the other devices view.
513 if (!loadTimeData.getBoolean('isInstantExtendedApiEnabled'))
516 // We must use this namespace to reuse the handler code for foreign session
518 cr.define('ntp', function() {
520 setForeignSessions: setForeignSessions,
521 updateLogin: updateLogin
525 devicesView = new DevicesView();
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);
531 var doSearch = function(e) {
532 devicesView.setSearchText($('search-field').value);
534 $('search-field').addEventListener('search', doSearch);
535 $('search-button').addEventListener('click', doSearch);
538 // Add handlers to HTML elements.
539 document.addEventListener('DOMContentLoaded', load);