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