// Copyright (c) 2013 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. /** * @fileoverview The section of the history page that shows tabs from sessions on other devices. */ /////////////////////////////////////////////////////////////////////////////// // Globals: /** @const */ var MAX_NUM_COLUMNS = 3; /** @const */ var NB_ENTRIES_FIRST_ROW_COLUMN = 6; /** @const */ var NB_ENTRIES_OTHER_ROWS_COLUMN = 0; // Histogram buckets for UMA tracking of menu usage. // Using the same values as the Other Devices button in the NTP. /** @const */ var HISTOGRAM_EVENT = { INITIALIZED: 0, SHOW_MENU: 1, LINK_CLICKED: 2, LINK_RIGHT_CLICKED: 3, SESSION_NAME_RIGHT_CLICKED: 4, SHOW_SESSION_MENU: 5, COLLAPSE_SESSION: 6, EXPAND_SESSION: 7, OPEN_ALL: 8, LIMIT: 9 // Should always be the last one. }; /** * Record an event in the UMA histogram. * @param {number} eventId The id of the event to be recorded. * @private */ function recordUmaEvent_(eventId) { chrome.send('metricsHandler:recordInHistogram', ['HistoryPage.OtherDevicesMenu', eventId, HISTOGRAM_EVENT.LIMIT]); } /////////////////////////////////////////////////////////////////////////////// // DeviceContextMenuController: /** * Controller for the context menu for device names in the list of sessions. * This class is designed to be used as a singleton. Also copied from existing * other devices button in NTP. * TODO(mad): Should we extract/reuse/share with ntp4/other_sessions.js? * * @constructor */ function DeviceContextMenuController() { this.__proto__ = DeviceContextMenuController.prototype; this.initialize(); } cr.addSingletonGetter(DeviceContextMenuController); // DeviceContextMenuController, Public: --------------------------------------- /** * Initialize the context menu for device names in the list of sessions. */ DeviceContextMenuController.prototype.initialize = function() { var menu = new cr.ui.Menu; cr.ui.decorate(menu, cr.ui.Menu); this.menu = menu; this.collapseItem_ = this.appendMenuItem_('collapseSessionMenuItemText'); this.collapseItem_.addEventListener('activate', this.onCollapseOrExpand_.bind(this)); this.expandItem_ = this.appendMenuItem_('expandSessionMenuItemText'); this.expandItem_.addEventListener('activate', this.onCollapseOrExpand_.bind(this)); this.openAllItem_ = this.appendMenuItem_('restoreSessionMenuItemText'); this.openAllItem_.addEventListener('activate', this.onOpenAll_.bind(this)); }; /** * Set the session data for the session the context menu was invoked on. * This should never be called when the menu is visible. * @param {Object} session The model object for the session. */ DeviceContextMenuController.prototype.setSession = function(session) { this.session_ = session; this.updateMenuItems_(); }; // DeviceContextMenuController, Private: -------------------------------------- /** * Appends a menu item to |this.menu|. * @param {string} textId The ID for the localized string that acts as * the item's label. * @return {Element} The button used for a given menu option. * @private */ DeviceContextMenuController.prototype.appendMenuItem_ = function(textId) { var button = document.createElement('button'); this.menu.appendChild(button); cr.ui.decorate(button, cr.ui.MenuItem); button.textContent = loadTimeData.getString(textId); return button; }; /** * Handler for the 'Collapse' and 'Expand' menu items. * @param {Event} e The activation event. * @private */ DeviceContextMenuController.prototype.onCollapseOrExpand_ = function(e) { this.session_.collapsed = !this.session_.collapsed; this.updateMenuItems_(); chrome.send('setForeignSessionCollapsed', [this.session_.tag, this.session_.collapsed]); chrome.send('getForeignSessions'); // Refresh the list. var eventId = this.session_.collapsed ? HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION; recordUmaEvent_(eventId); }; /** * Handler for the 'Open all' menu item. * @param {Event} e The activation event. * @private */ DeviceContextMenuController.prototype.onOpenAll_ = function(e) { chrome.send('openForeignSession', [this.session_.tag]); recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL); }; /** * Set the visibility of the Expand/Collapse menu items based on the state * of the session that this menu is currently associated with. * @private */ DeviceContextMenuController.prototype.updateMenuItems_ = function() { this.collapseItem_.hidden = this.session_.collapsed; this.expandItem_.hidden = !this.session_.collapsed; this.menu.selectedItem = this.menu.querySelector(':not([hidden])'); }; /////////////////////////////////////////////////////////////////////////////// // Device: /** * Class to hold all the information about a device entry and generate a DOM * node for it. * @param {Object} session An object containing the device's session data. * @param {DevicesView} view The view object this entry belongs to. * @constructor */ function Device(session, view) { this.view_ = view; this.session_ = session; this.searchText_ = view.getSearchText(); } // Device, Public: ------------------------------------------------------------ /** * Get the DOM node to display this device. * @param {int} maxNumTabs The maximum number of tabs to display. * @param {int} row The row in which this device is displayed. * @return {Object} A DOM node to draw the device. */ Device.prototype.getDOMNode = function(maxNumTabs, row) { var deviceDiv = createElementWithClassName('div', 'device'); this.row_ = row; if (!this.session_) return deviceDiv; // Name heading var heading = document.createElement('h3'); var name = heading.appendChild( createElementWithClassName('span', 'device-name')); name.textContent = this.session_.name; heading.sessionData_ = this.session_; deviceDiv.appendChild(heading); // Keep track of the drop down that triggered the menu, so we know // which element to apply the command to. var session = this.session_; function handleDropDownFocus(e) { DeviceContextMenuController.getInstance().setSession(session); } heading.addEventListener('contextmenu', handleDropDownFocus); var dropDownButton = new cr.ui.ContextMenuButton; dropDownButton.tabIndex = 0; dropDownButton.classList.add('drop-down'); dropDownButton.title = loadTimeData.getString('actionMenuDescription'); dropDownButton.addEventListener('mousedown', function(event) { handleDropDownFocus(event); // Mousedown handling of cr.ui.MenuButton.handleEvent calls // preventDefault, which prevents blur of the focused element. We need to // do blur manually. document.activeElement.blur(); }); dropDownButton.addEventListener('focus', handleDropDownFocus); heading.appendChild(dropDownButton); var timeSpan = createElementWithClassName('div', 'device-timestamp'); timeSpan.textContent = this.session_.modifiedTime; deviceDiv.appendChild(timeSpan); cr.ui.contextMenuHandler.setContextMenu( heading, DeviceContextMenuController.getInstance().menu); if (!this.session_.collapsed) deviceDiv.appendChild(this.createSessionContents_(maxNumTabs)); return deviceDiv; }; /** * Marks tabs as hidden or not in our session based on the given searchText. * @param {string} searchText The search text used to filter the content. */ Device.prototype.setSearchText = function(searchText) { this.searchText_ = searchText.toLowerCase(); for (var i = 0; i < this.session_.windows.length; i++) { var win = this.session_.windows[i]; var foundMatch = false; for (var j = 0; j < win.tabs.length; j++) { var tab = win.tabs[j]; if (tab.title.toLowerCase().indexOf(this.searchText_) != -1) { foundMatch = true; tab.hidden = false; } else { tab.hidden = true; } } win.hidden = !foundMatch; } }; // Device, Private ------------------------------------------------------------ /** * Create the DOM tree representing the tabs and windows of this device. * @param {int} maxNumTabs The maximum number of tabs to display. * @return {Element} A single div containing the list of tabs & windows. * @private */ Device.prototype.createSessionContents_ = function(maxNumTabs) { var contents = createElementWithClassName('ol', 'device-contents'); var sessionTag = this.session_.tag; var numTabsShown = 0; var numTabsHidden = 0; for (var i = 0; i < this.session_.windows.length; i++) { var win = this.session_.windows[i]; if (win.hidden) continue; // Show a separator between multiple windows in the same session. if (i > 0 && numTabsShown < maxNumTabs) contents.appendChild(document.createElement('hr')); for (var j = 0; j < win.tabs.length; j++) { var tab = win.tabs[j]; if (tab.hidden) continue; if (numTabsShown < maxNumTabs) { numTabsShown++; var a = createElementWithClassName('a', 'device-tab-entry'); a.href = tab.url; a.style.backgroundImage = getFaviconImageSet(tab.url); this.addHighlightedText_(a, tab.title); // Add a tooltip, since it might be ellipsized. The ones that are not // necessary will be removed once added to the document, so we can // compute sizes. a.title = tab.title; // We need to use this to not lose the ids as we go through other loop // turns. function makeClickHandler(sessionTag, windowId, tabId) { return function(e) { recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED); chrome.send('openForeignSession', [sessionTag, windowId, tabId, e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); e.preventDefault(); }; }; a.addEventListener('click', makeClickHandler(sessionTag, String(win.sessionId), String(tab.sessionId))); contents.appendChild(a); } else { numTabsHidden++; } } } if (numTabsHidden > 0) { var moreLink = document.createElement('a', 'action-link'); moreLink.classList.add('device-show-more-tabs'); moreLink.addEventListener('click', this.view_.increaseRowHeight.bind( this.view_, this.row_, numTabsHidden)); moreLink.textContent = loadTimeData.getStringF('xMore', numTabsHidden); contents.appendChild(moreLink); } return contents; }; /** * Add child text nodes to a node such that occurrences of this.searchText_ are * highlighted. * @param {Node} node The node under which new text nodes will be made as * children. * @param {string} content Text to be added beneath |node| as one or more * text nodes. * @private */ Device.prototype.addHighlightedText_ = function(node, content) { var endOfPreviousMatch = 0; if (this.searchText_) { var lowerContent = content.toLowerCase(); var searchTextLenght = this.searchText_.length; var newMatch = lowerContent.indexOf(this.searchText_, 0); while (newMatch != -1) { if (newMatch > endOfPreviousMatch) { node.appendChild(document.createTextNode( content.slice(endOfPreviousMatch, newMatch))); } endOfPreviousMatch = newMatch + searchTextLenght; // Mark the highlighted text in bold. var b = document.createElement('b'); b.textContent = content.substring(newMatch, endOfPreviousMatch); node.appendChild(b); newMatch = lowerContent.indexOf(this.searchText_, endOfPreviousMatch); } } if (endOfPreviousMatch < content.length) { node.appendChild(document.createTextNode( content.slice(endOfPreviousMatch))); } }; /////////////////////////////////////////////////////////////////////////////// // DevicesView: /** * Functions and state for populating the page with HTML. * @constructor */ function DevicesView() { this.devices_ = []; // List of individual devices. this.resultDiv_ = $('other-devices'); this.searchText_ = ''; this.rowHeights_ = [NB_ENTRIES_FIRST_ROW_COLUMN]; this.focusGrids_ = []; this.updateSignInState(loadTimeData.getBoolean('isUserSignedIn')); recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED); } // DevicesView, public: ------------------------------------------------------- /** * Updates our sign in state by clearing the view is not signed in or sending * a request to get the data to display otherwise. * @param {boolean} signedIn Whether the user is signed in or not. */ DevicesView.prototype.updateSignInState = function(signedIn) { if (signedIn) chrome.send('getForeignSessions'); else this.clearDOM(); }; /** * Resets the view sessions. * @param {Object} sessionList The sessions to add. */ DevicesView.prototype.setSessionList = function(sessionList) { this.devices_ = []; for (var i = 0; i < sessionList.length; i++) this.devices_.push(new Device(sessionList[i], this)); this.displayResults_(); }; /** * Sets the current search text. * @param {string} searchText The text to search. */ DevicesView.prototype.setSearchText = function(searchText) { if (this.searchText_ != searchText) { this.searchText_ = searchText; for (var i = 0; i < this.devices_.length; i++) this.devices_[i].setSearchText(searchText); this.displayResults_(); } }; /** * @return {string} The current search text. */ DevicesView.prototype.getSearchText = function() { return this.searchText_; }; /** * Clears the DOM content of the view. */ DevicesView.prototype.clearDOM = function() { while (this.resultDiv_.hasChildNodes()) { this.resultDiv_.removeChild(this.resultDiv_.lastChild); } }; /** * Increase the height of a row by the given amount. * @param {int} row The row number. * @param {int} height The extra height to add to the givent row. */ DevicesView.prototype.increaseRowHeight = function(row, height) { for (var i = this.rowHeights_.length; i <= row; i++) this.rowHeights_.push(NB_ENTRIES_OTHER_ROWS_COLUMN); this.rowHeights_[row] += height; this.displayResults_(); }; // DevicesView, Private ------------------------------------------------------- /** * Update the page with results. * @private */ DevicesView.prototype.displayResults_ = function() { this.clearDOM(); var resultsFragment = document.createDocumentFragment(); if (this.devices_.length == 0) return; // We'll increase to 0 as we create the first row. var rowIndex = -1; // We need to access the last row and device when we get out of the loop. var currentRowElement; // This is only set when changing rows, yet used on all device columns. var maxNumTabs; for (var i = 0; i < this.devices_.length; i++) { var device = this.devices_[i]; // Should we start a new row? if (i % MAX_NUM_COLUMNS == 0) { if (currentRowElement) resultsFragment.appendChild(currentRowElement); currentRowElement = createElementWithClassName('div', 'device-row'); rowIndex++; if (rowIndex < this.rowHeights_.length) maxNumTabs = this.rowHeights_[rowIndex]; else maxNumTabs = 0; } currentRowElement.appendChild(device.getDOMNode(maxNumTabs, rowIndex)); } if (currentRowElement) resultsFragment.appendChild(currentRowElement); this.resultDiv_.appendChild(resultsFragment); // Remove the tootltip on all lines that don't need it. It's easier to // remove them here, after adding them all above, since we have the data // handy above, but we don't have the width yet. Whereas here, we have the // width, and the nodeValue could contain sub nodes for highlighting, which // makes it harder to extract the text data here. tabs = document.getElementsByClassName('device-tab-entry'); for (var i = 0; i < tabs.length; i++) { if (tabs[i].scrollWidth <= tabs[i].clientWidth) tabs[i].title = ''; } this.resultDiv_.appendChild( createElementWithClassName('div', 'other-devices-bottom')); this.focusGrids_.forEach(function(grid) { grid.destroy(); }); this.focusGrids_.length = 0; var singleColumn = function(e) { return [e]; }; var devices = this.resultDiv_.querySelectorAll('.device-contents'); for (var i = 0; i < devices.length; ++i) { var rows = devices[i].querySelectorAll('.device-tab-entry, button'); if (!rows.length) continue; var grid = new cr.ui.FocusGrid(devices[i]); grid.setGrid(Array.prototype.map.call(rows, singleColumn)); this.focusGrids_.push(grid); } }; /** * Sets the menu model data. An empty list means that either there are no * foreign sessions, or tab sync is disabled for this profile. * |isTabSyncEnabled| makes it possible to distinguish between the cases. * * @param {Array} sessionList Array of objects describing the sessions * from other devices. * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile? */ function setForeignSessions(sessionList, isTabSyncEnabled) { // The other devices is shown iff tab sync is enabled. if (isTabSyncEnabled) devicesView.setSessionList(sessionList); else devicesView.clearDOM(); } /** * Called when this element is initialized, and from the new tab page when * the user's signed in state changes, * @param {string} header The first line of text (unused here). * @param {string} subHeader The second line of text (unused here). * @param {string} iconURL The url for the login status icon. If this is null then the login status icon is hidden (unused here). * @param {boolean} isUserSignedIn Is the user currently signed in? */ function updateLogin(header, subHeader, iconURL, isUserSignedIn) { if (devicesView) devicesView.updateSignInState(isUserSignedIn); } /////////////////////////////////////////////////////////////////////////////// // Document Functions: /** * Window onload handler, sets up the other devices view. */ function load() { if (!loadTimeData.getBoolean('isInstantExtendedApiEnabled')) return; // We must use this namespace to reuse the handler code for foreign session // and login. cr.define('ntp', function() { return { setForeignSessions: setForeignSessions, updateLogin: updateLogin }; }); devicesView = new DevicesView(); // Create the context menu that appears when the user right clicks // on a device name or hit click on the button besides the device name document.body.appendChild(DeviceContextMenuController.getInstance().menu); var doSearch = function(e) { devicesView.setSearchText($('search-field').value); }; $('search-field').addEventListener('search', doSearch); $('search-button').addEventListener('click', doSearch); } // Add handlers to HTML elements. document.addEventListener('DOMContentLoaded', load);