| // 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; |
| }; |
| |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // 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'); |
| heading.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.classList.add('drop-down'); |
| dropDownButton.addEventListener('mousedown', handleDropDownFocus); |
| dropDownButton.addEventListener('focus', handleDropDownFocus); |
| heading.appendChild(dropDownButton); |
| |
| var timeSpan = createElementWithClassName('div', 'device-timestamp'); |
| timeSpan.textContent = this.session_.modifiedTime; |
| heading.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('div', '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 moreLinkButton = createElementWithClassName('button', |
| 'device-show-more-tabs link-button'); |
| moreLinkButton.addEventListener('click', this.view_.increaseRowHeight.bind( |
| this.view_, this.row_, numTabsHidden)); |
| var xMore = loadTimeData.getString('xMore'); |
| moreLinkButton.appendChild( |
| document.createTextNode(xMore.replace('$1', numTabsHidden))); |
| contents.appendChild(moreLinkButton); |
| } |
| |
| 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.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', 'devices-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')); |
| }; |
| |
| /** |
| * 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); |