1// Copyright (c) 2012 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<include src="../uber/uber_utils.js">
6<include src="history_focus_manager.js">
7
8///////////////////////////////////////////////////////////////////////////////
9// Globals:
10/** @const */ var RESULTS_PER_PAGE = 150;
11
12// Amount of time between pageviews that we consider a 'break' in browsing,
13// measured in milliseconds.
14/** @const */ var BROWSING_GAP_TIME = 15 * 60 * 1000;
15
16// The largest bucket value for UMA histogram, based on entry ID. All entries
17// with IDs greater than this will be included in this bucket.
18/** @const */ var UMA_MAX_BUCKET_VALUE = 1000;
19
20// The largest bucket value for a UMA histogram that is a subset of above.
21/** @const */ var UMA_MAX_SUBSET_BUCKET_VALUE = 100;
22
23// TODO(glen): Get rid of these global references, replace with a controller
24//     or just make the classes own more of the page.
25var historyModel;
26var historyView;
27var pageState;
28var selectionAnchor = -1;
29var activeVisit = null;
30
31/** @const */ var Command = cr.ui.Command;
32/** @const */ var Menu = cr.ui.Menu;
33/** @const */ var MenuButton = cr.ui.MenuButton;
34
35/**
36 * Enum that shows the filtering behavior for a host or URL to a supervised
37 * user. Must behave like the FilteringBehavior enum from
38 * supervised_user_url_filter.h.
39 * @enum {number}
40 */
41var SupervisedUserFilteringBehavior = {
42  ALLOW: 0,
43  WARN: 1,
44  BLOCK: 2
45};
46
47/**
48 * The type of the history result object. The definition is based on
49 * chrome/browser/ui/webui/history_ui.cc:
50 *     BrowsingHistoryHandler::HistoryEntry::ToValue()
51 * @typedef {{allTimestamps: Array.<number>,
52 *            blockedVisit: (boolean|undefined),
53 *            dateRelativeDay: (string|undefined),
54 *            dateShort: string,
55 *            dateTimeOfDay: (string|undefined),
56 *            deviceName: string,
57 *            deviceType: string,
58 *            domain: string,
59 *            hostFilteringBehavior: (number|undefined),
60 *            snippet: (string|undefined),
61 *            starred: boolean,
62 *            time: number,
63 *            title: string,
64 *            url: string}}
65 */
66var HistoryEntry;
67
68/**
69 * The type of the history results info object. The definition is based on
70 * chrome/browser/ui/webui/history_ui.cc:
71 *     BrowsingHistoryHandler::QueryComplete()
72 * @typedef {{finished: boolean,
73 *            hasSyncedResults: (boolean|undefined),
74 *            queryEndTime: string,
75 *            queryStartTime: string,
76 *            term: string}}
77 */
78var HistoryQuery;
79
80MenuButton.createDropDownArrows();
81
82/**
83 * Returns true if the mobile (non-desktop) version is being shown.
84 * @return {boolean} true if the mobile version is being shown.
85 */
86function isMobileVersion() {
87  return !document.body.classList.contains('uber-frame');
88}
89
90/**
91 * Record an action in UMA.
92 * @param {string} actionDesc The name of the action to be logged.
93 */
94function recordUmaAction(actionDesc) {
95  chrome.send('metricsHandler:recordAction', [actionDesc]);
96}
97
98/**
99 * Record a histogram value in UMA. If specified value is larger than the max
100 * bucket value, record the value in the largest bucket.
101 * @param {string} histogram The name of the histogram to be recorded in.
102 * @param {number} maxBucketValue The max value for the last histogram bucket.
103 * @param {number} value The value to record in the histogram.
104 */
105function recordUmaHistogram(histogram, maxBucketValue, value) {
106  chrome.send('metricsHandler:recordInHistogram',
107              [histogram,
108              ((value > maxBucketValue) ? maxBucketValue : value),
109              maxBucketValue]);
110}
111
112///////////////////////////////////////////////////////////////////////////////
113// Visit:
114
115/**
116 * Class to hold all the information about an entry in our model.
117 * @param {HistoryEntry} result An object containing the visit's data.
118 * @param {boolean} continued Whether this visit is on the same day as the
119 *     visit before it.
120 * @param {HistoryModel} model The model object this entry belongs to.
121 * @constructor
122 */
123function Visit(result, continued, model) {
124  this.model_ = model;
125  this.title_ = result.title;
126  this.url_ = result.url;
127  this.domain_ = result.domain;
128  this.starred_ = result.starred;
129
130  // These identify the name and type of the device on which this visit
131  // occurred. They will be empty if the visit occurred on the current device.
132  this.deviceName = result.deviceName;
133  this.deviceType = result.deviceType;
134
135  // The ID will be set according to when the visit was displayed, not
136  // received. Set to -1 to show that it has not been set yet.
137  this.id_ = -1;
138
139  this.isRendered = false;  // Has the visit already been rendered on the page?
140
141  // All the date information is public so that owners can compare properties of
142  // two items easily.
143
144  this.date = new Date(result.time);
145
146  // See comment in BrowsingHistoryHandler::QueryComplete - we won't always
147  // get all of these.
148  this.dateRelativeDay = result.dateRelativeDay || '';
149  this.dateTimeOfDay = result.dateTimeOfDay || '';
150  this.dateShort = result.dateShort || '';
151
152  // Shows the filtering behavior for that host (only used for supervised
153  // users).
154  // A value of |SupervisedUserFilteringBehavior.ALLOW| is not displayed so it
155  // is used as the default value.
156  this.hostFilteringBehavior = SupervisedUserFilteringBehavior.ALLOW;
157  if (typeof result.hostFilteringBehavior != 'undefined')
158    this.hostFilteringBehavior = result.hostFilteringBehavior;
159
160  this.blockedVisit = result.blockedVisit || false;
161
162  // Whether this is the continuation of a previous day.
163  this.continued = continued;
164
165  this.allTimestamps = result.allTimestamps;
166}
167
168// Visit, public: -------------------------------------------------------------
169
170/**
171 * Returns a dom structure for a browse page result or a search page result.
172 * @param {Object} propertyBag A bag of configuration properties, false by
173 * default:
174 *  - isSearchResult: Whether or not the result is a search result.
175 *  - addTitleFavicon: Whether or not the favicon should be added.
176 *  - useMonthDate: Whether or not the full date should be inserted (used for
177 * monthly view).
178 * @return {Node} A DOM node to represent the history entry or search result.
179 */
180Visit.prototype.getResultDOM = function(propertyBag) {
181  var isSearchResult = propertyBag.isSearchResult || false;
182  var addTitleFavicon = propertyBag.addTitleFavicon || false;
183  var useMonthDate = propertyBag.useMonthDate || false;
184  var focusless = propertyBag.focusless || false;
185  var node = createElementWithClassName('li', 'entry');
186  var time = createElementWithClassName('label', 'time');
187  var entryBox = createElementWithClassName('div', 'entry-box');
188  var domain = createElementWithClassName('div', 'domain');
189
190  this.id_ = this.model_.nextVisitId_++;
191  var self = this;
192
193  // Only create the checkbox if it can be used either to delete an entry or to
194  // block/allow it.
195  if (this.model_.editingEntriesAllowed) {
196    var checkbox = document.createElement('input');
197    checkbox.type = 'checkbox';
198    checkbox.id = 'checkbox-' + this.id_;
199    checkbox.time = this.date.getTime();
200    checkbox.addEventListener('click', checkboxClicked);
201    time.setAttribute('for', checkbox.id);
202    entryBox.appendChild(checkbox);
203
204    if (focusless)
205      checkbox.tabIndex = -1;
206
207    if (!isMobileVersion()) {
208      // Clicking anywhere in the entryBox will check/uncheck the checkbox.
209      entryBox.setAttribute('for', checkbox.id);
210      entryBox.addEventListener('mousedown', entryBoxMousedown);
211      entryBox.addEventListener('click', entryBoxClick);
212      entryBox.addEventListener('keydown', this.handleKeydown_.bind(this));
213    }
214  }
215
216  // Keep track of the drop down that triggered the menu, so we know
217  // which element to apply the command to.
218  // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it.
219  var setActiveVisit = function(e) {
220    activeVisit = self;
221    var menu = $('action-menu');
222    menu.dataset.devicename = self.deviceName;
223    menu.dataset.devicetype = self.deviceType;
224  };
225  domain.textContent = this.domain_;
226
227  entryBox.appendChild(time);
228
229  var bookmarkSection = createElementWithClassName(
230      'button', 'bookmark-section custom-appearance');
231  if (this.starred_) {
232    bookmarkSection.title = loadTimeData.getString('removeBookmark');
233    bookmarkSection.classList.add('starred');
234    bookmarkSection.addEventListener('click', function f(e) {
235      recordUmaAction('HistoryPage_BookmarkStarClicked');
236      chrome.send('removeBookmark', [self.url_]);
237
238      this.model_.getView().onBeforeUnstarred(this);
239      bookmarkSection.classList.remove('starred');
240      this.model_.getView().onAfterUnstarred(this);
241
242      bookmarkSection.removeEventListener('click', f);
243      e.preventDefault();
244    }.bind(this));
245  }
246  entryBox.appendChild(bookmarkSection);
247
248  var visitEntryWrapper = /** @type {HTMLElement} */(
249      entryBox.appendChild(document.createElement('div')));
250  if (addTitleFavicon || this.blockedVisit)
251    visitEntryWrapper.classList.add('visit-entry');
252  if (this.blockedVisit) {
253    visitEntryWrapper.classList.add('blocked-indicator');
254    visitEntryWrapper.appendChild(this.getVisitAttemptDOM_());
255  } else {
256    var title = visitEntryWrapper.appendChild(
257        this.getTitleDOM_(isSearchResult));
258
259    if (addTitleFavicon)
260      this.addFaviconToElement_(visitEntryWrapper);
261
262    if (focusless)
263      title.querySelector('a').tabIndex = -1;
264
265    visitEntryWrapper.appendChild(domain);
266  }
267
268  if (isMobileVersion()) {
269    var removeButton = createElementWithClassName('button', 'remove-entry');
270    removeButton.setAttribute('aria-label',
271                              loadTimeData.getString('removeFromHistory'));
272    removeButton.classList.add('custom-appearance');
273    removeButton.addEventListener(
274        'click', this.removeEntryFromHistory_.bind(this));
275    entryBox.appendChild(removeButton);
276
277    // Support clicking anywhere inside the entry box.
278    entryBox.addEventListener('click', function(e) {
279      if (!e.defaultPrevented)
280        self.titleLink.click();
281    });
282  } else {
283    var dropDown = createElementWithClassName('button', 'drop-down');
284    dropDown.value = 'Open action menu';
285    dropDown.title = loadTimeData.getString('actionMenuDescription');
286    dropDown.setAttribute('menu', '#action-menu');
287    dropDown.setAttribute('aria-haspopup', 'true');
288
289    if (focusless)
290      dropDown.tabIndex = -1;
291
292    cr.ui.decorate(dropDown, MenuButton);
293    dropDown.respondToArrowKeys = false;
294
295    dropDown.addEventListener('mousedown', setActiveVisit);
296    dropDown.addEventListener('focus', setActiveVisit);
297
298    // Prevent clicks on the drop down from affecting the checkbox.  We need to
299    // call blur() explicitly because preventDefault() cancels any focus
300    // handling.
301    dropDown.addEventListener('click', function(e) {
302      e.preventDefault();
303      document.activeElement.blur();
304    });
305    entryBox.appendChild(dropDown);
306  }
307
308  // Let the entryBox be styled appropriately when it contains keyboard focus.
309  entryBox.addEventListener('focus', function() {
310    this.classList.add('contains-focus');
311  }, true);
312  entryBox.addEventListener('blur', function() {
313    this.classList.remove('contains-focus');
314  }, true);
315
316  var entryBoxContainer =
317      createElementWithClassName('div', 'entry-box-container');
318  node.appendChild(entryBoxContainer);
319  entryBoxContainer.appendChild(entryBox);
320
321  if (isSearchResult || useMonthDate) {
322    // Show the day instead of the time.
323    time.appendChild(document.createTextNode(this.dateShort));
324  } else {
325    time.appendChild(document.createTextNode(this.dateTimeOfDay));
326  }
327
328  this.domNode_ = node;
329  node.visit = this;
330
331  return node;
332};
333
334/**
335 * Remove this visit from the history.
336 */
337Visit.prototype.removeFromHistory = function() {
338  recordUmaAction('HistoryPage_EntryMenuRemoveFromHistory');
339  this.model_.removeVisitsFromHistory([this], function() {
340    this.model_.getView().removeVisit(this);
341  }.bind(this));
342};
343
344// Closure Compiler doesn't support Object.defineProperty().
345// https://github.com/google/closure-compiler/issues/302
346Object.defineProperty(Visit.prototype, 'checkBox', {
347  get: /** @this {Visit} */function() {
348    return this.domNode_.querySelector('input[type=checkbox]');
349  },
350});
351
352Object.defineProperty(Visit.prototype, 'bookmarkStar', {
353  get: /** @this {Visit} */function() {
354    return this.domNode_.querySelector('.bookmark-section.starred');
355  },
356});
357
358Object.defineProperty(Visit.prototype, 'titleLink', {
359  get: /** @this {Visit} */function() {
360    return this.domNode_.querySelector('.title a');
361  },
362});
363
364Object.defineProperty(Visit.prototype, 'dropDown', {
365  get: /** @this {Visit} */function() {
366    return this.domNode_.querySelector('button.drop-down');
367  },
368});
369
370// Visit, private: ------------------------------------------------------------
371
372/**
373 * Add child text nodes to a node such that occurrences of the specified text is
374 * highlighted.
375 * @param {Node} node The node under which new text nodes will be made as
376 *     children.
377 * @param {string} content Text to be added beneath |node| as one or more
378 *     text nodes.
379 * @param {string} highlightText Occurences of this text inside |content| will
380 *     be highlighted.
381 * @private
382 */
383Visit.prototype.addHighlightedText_ = function(node, content, highlightText) {
384  var i = 0;
385  if (highlightText) {
386    var re = new RegExp(Visit.pregQuote_(highlightText), 'gim');
387    var match;
388    while (match = re.exec(content)) {
389      if (match.index > i)
390        node.appendChild(document.createTextNode(content.slice(i,
391                                                               match.index)));
392      i = re.lastIndex;
393      // Mark the highlighted text in bold.
394      var b = document.createElement('b');
395      b.textContent = content.substring(match.index, i);
396      node.appendChild(b);
397    }
398  }
399  if (i < content.length)
400    node.appendChild(document.createTextNode(content.slice(i)));
401};
402
403/**
404 * Returns the DOM element containing a link on the title of the URL for the
405 * current visit.
406 * @param {boolean} isSearchResult Whether or not the entry is a search result.
407 * @return {Element} DOM representation for the title block.
408 * @private
409 */
410Visit.prototype.getTitleDOM_ = function(isSearchResult) {
411  var node = createElementWithClassName('div', 'title');
412  var link = document.createElement('a');
413  link.href = this.url_;
414  link.id = 'id-' + this.id_;
415  link.target = '_top';
416  var integerId = parseInt(this.id_, 10);
417  link.addEventListener('click', function() {
418    recordUmaAction('HistoryPage_EntryLinkClick');
419    // Record the ID of the entry to signify how many entries are above this
420    // link on the page.
421    recordUmaHistogram('HistoryPage.ClickPosition',
422                       UMA_MAX_BUCKET_VALUE,
423                       integerId);
424    if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
425      recordUmaHistogram('HistoryPage.ClickPositionSubset',
426                         UMA_MAX_SUBSET_BUCKET_VALUE,
427                         integerId);
428    }
429  });
430  link.addEventListener('contextmenu', function() {
431    recordUmaAction('HistoryPage_EntryLinkRightClick');
432  });
433
434  if (isSearchResult) {
435    link.addEventListener('click', function() {
436      recordUmaAction('HistoryPage_SearchResultClick');
437    });
438  }
439
440  // Add a tooltip, since it might be ellipsized.
441  // TODO(dubroy): Find a way to show the tooltip only when necessary.
442  link.title = this.title_;
443
444  this.addHighlightedText_(link, this.title_, this.model_.getSearchText());
445  node.appendChild(link);
446
447  return node;
448};
449
450/**
451 * Returns the DOM element containing the text for a blocked visit attempt.
452 * @return {Element} DOM representation of the visit attempt.
453 * @private
454 */
455Visit.prototype.getVisitAttemptDOM_ = function() {
456  var node = createElementWithClassName('div', 'title');
457  node.innerHTML = loadTimeData.getStringF('blockedVisitText',
458                                           this.url_,
459                                           this.id_,
460                                           this.domain_);
461  return node;
462};
463
464/**
465 * Set the favicon for an element.
466 * @param {Element} el The DOM element to which to add the icon.
467 * @private
468 */
469Visit.prototype.addFaviconToElement_ = function(el) {
470  var url = isMobileVersion() ?
471      getFaviconImageSet(this.url_, 32, 'touch-icon') :
472      getFaviconImageSet(this.url_);
473  el.style.backgroundImage = url;
474};
475
476/**
477 * Launch a search for more history entries from the same domain.
478 * @private
479 */
480Visit.prototype.showMoreFromSite_ = function() {
481  recordUmaAction('HistoryPage_EntryMenuShowMoreFromSite');
482  historyView.setSearch(this.domain_);
483  $('search-field').focus();
484};
485
486/**
487 * @param {Event} e A keydown event to handle.
488 * @private
489 */
490Visit.prototype.handleKeydown_ = function(e) {
491  // Delete or Backspace should delete the entry if allowed.
492  if ((e.keyIdentifier == 'U+0008' || e.keyIdentifier == 'U+007F') &&
493      !this.model_.isDeletingVisits()) {
494    this.removeEntryFromHistory_(e);
495  }
496};
497
498/**
499 * Removes a history entry on click or keydown and finds a new entry to focus.
500 * @param {Event} e A click or keydown event.
501 * @private
502 */
503Visit.prototype.removeEntryFromHistory_ = function(e) {
504  if (!this.model_.deletingHistoryAllowed)
505    return;
506
507  this.model_.getView().onBeforeRemove(this);
508  this.removeFromHistory();
509  e.preventDefault();
510};
511
512// Visit, private, static: ----------------------------------------------------
513
514/**
515 * Quote a string so it can be used in a regular expression.
516 * @param {string} str The source string.
517 * @return {string} The escaped string.
518 * @private
519 */
520Visit.pregQuote_ = function(str) {
521  return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
522};
523
524///////////////////////////////////////////////////////////////////////////////
525// HistoryModel:
526
527/**
528 * Global container for history data. Future optimizations might include
529 * allowing the creation of a HistoryModel for each search string, allowing
530 * quick flips back and forth between results.
531 *
532 * The history model is based around pages, and only fetching the data to
533 * fill the currently requested page. This is somewhat dependent on the view,
534 * and so future work may wish to change history model to operate on
535 * timeframe (day or week) based containers.
536 *
537 * @constructor
538 */
539function HistoryModel() {
540  this.clearModel_();
541}
542
543// HistoryModel, Public: ------------------------------------------------------
544
545/** @enum {number} */
546HistoryModel.Range = {
547  ALL_TIME: 0,
548  WEEK: 1,
549  MONTH: 2
550};
551
552/**
553 * Sets our current view that is called when the history model changes.
554 * @param {HistoryView} view The view to set our current view to.
555 */
556HistoryModel.prototype.setView = function(view) {
557  this.view_ = view;
558};
559
560
561/**
562 * @return {HistoryView|undefined} Returns the view for this model (if set).
563 */
564HistoryModel.prototype.getView = function() {
565  return this.view_;
566};
567
568/**
569 * Reload our model with the current parameters.
570 */
571HistoryModel.prototype.reload = function() {
572  // Save user-visible state, clear the model, and restore the state.
573  var search = this.searchText_;
574  var page = this.requestedPage_;
575  var range = this.rangeInDays_;
576  var offset = this.offset_;
577  var groupByDomain = this.groupByDomain_;
578
579  this.clearModel_();
580  this.searchText_ = search;
581  this.requestedPage_ = page;
582  this.rangeInDays_ = range;
583  this.offset_ = offset;
584  this.groupByDomain_ = groupByDomain;
585  this.queryHistory_();
586};
587
588/**
589 * @return {string} The current search text.
590 */
591HistoryModel.prototype.getSearchText = function() {
592  return this.searchText_;
593};
594
595/**
596 * Tell the model that the view will want to see the current page. When
597 * the data becomes available, the model will call the view back.
598 * @param {number} page The page we want to view.
599 */
600HistoryModel.prototype.requestPage = function(page) {
601  this.requestedPage_ = page;
602  this.updateSearch_();
603};
604
605/**
606 * Receiver for history query.
607 * @param {HistoryQuery} info An object containing information about the query.
608 * @param {Array.<HistoryEntry>} results A list of results.
609 */
610HistoryModel.prototype.addResults = function(info, results) {
611  // If no requests are in flight then this was an old request so we drop the
612  // results. Double check the search term as well.
613  if (!this.inFlight_ || info.term != this.searchText_)
614    return;
615
616  $('loading-spinner').hidden = true;
617  this.inFlight_ = false;
618  this.isQueryFinished_ = info.finished;
619  this.queryStartTime = info.queryStartTime;
620  this.queryEndTime = info.queryEndTime;
621
622  var lastVisit = this.visits_.slice(-1)[0];
623  var lastDay = lastVisit ? lastVisit.dateRelativeDay : null;
624
625  for (var i = 0, result; result = results[i]; i++) {
626    var thisDay = result.dateRelativeDay;
627    var isSameDay = lastDay == thisDay;
628    this.visits_.push(new Visit(result, isSameDay, this));
629    lastDay = thisDay;
630  }
631
632  if (loadTimeData.getBoolean('isUserSignedIn')) {
633    var message = loadTimeData.getString(
634        info.hasSyncedResults ? 'hasSyncedResults' : 'noSyncedResults');
635    this.view_.showNotification(message);
636  }
637
638  this.updateSearch_();
639};
640
641/**
642 * @return {number} The number of visits in the model.
643 */
644HistoryModel.prototype.getSize = function() {
645  return this.visits_.length;
646};
647
648/**
649 * Get a list of visits between specified index positions.
650 * @param {number} start The start index.
651 * @param {number} end The end index.
652 * @return {Array.<Visit>} A list of visits.
653 */
654HistoryModel.prototype.getNumberedRange = function(start, end) {
655  return this.visits_.slice(start, end);
656};
657
658/**
659 * Return true if there are more results beyond the current page.
660 * @return {boolean} true if the there are more results, otherwise false.
661 */
662HistoryModel.prototype.hasMoreResults = function() {
663  return this.haveDataForPage_(this.requestedPage_ + 1) ||
664      !this.isQueryFinished_;
665};
666
667/**
668 * Removes a list of visits from the history, and calls |callback| when the
669 * removal has successfully completed.
670 * @param {Array.<Visit>} visits The visits to remove.
671 * @param {Function} callback The function to call after removal succeeds.
672 */
673HistoryModel.prototype.removeVisitsFromHistory = function(visits, callback) {
674  assert(this.deletingHistoryAllowed);
675
676  var toBeRemoved = [];
677  for (var i = 0; i < visits.length; i++) {
678    toBeRemoved.push({
679      url: visits[i].url_,
680      timestamps: visits[i].allTimestamps
681    });
682  }
683
684  chrome.send('removeVisits', toBeRemoved);
685  this.deleteCompleteCallback_ = callback;
686};
687
688/** @return {boolean} Whether the model is currently deleting a visit. */
689HistoryModel.prototype.isDeletingVisits = function() {
690  return !!this.deleteCompleteCallback_;
691};
692
693/**
694 * Called when visits have been succesfully removed from the history.
695 */
696HistoryModel.prototype.deleteComplete = function() {
697  // Call the callback, with 'this' undefined inside the callback.
698  this.deleteCompleteCallback_.call();
699  this.deleteCompleteCallback_ = null;
700};
701
702// Getter and setter for HistoryModel.rangeInDays_.
703Object.defineProperty(HistoryModel.prototype, 'rangeInDays', {
704  get: /** @this {HistoryModel} */function() {
705    return this.rangeInDays_;
706  },
707  set: /** @this {HistoryModel} */function(range) {
708    this.rangeInDays_ = range;
709  }
710});
711
712/**
713 * Getter and setter for HistoryModel.offset_. The offset moves the current
714 * query 'window' |range| days behind. As such for range set to WEEK an offset
715 * of 0 refers to the last 7 days, an offset of 1 refers to the 7 day period
716 * that ended 7 days ago, etc. For MONTH an offset of 0 refers to the current
717 * calendar month, 1 to the previous one, etc.
718 */
719Object.defineProperty(HistoryModel.prototype, 'offset', {
720  get: /** @this {HistoryModel} */function() {
721    return this.offset_;
722  },
723  set: /** @this {HistoryModel} */function(offset) {
724    this.offset_ = offset;
725  }
726});
727
728// Setter for HistoryModel.requestedPage_.
729Object.defineProperty(HistoryModel.prototype, 'requestedPage', {
730  set: /** @this {HistoryModel} */function(page) {
731    this.requestedPage_ = page;
732  }
733});
734
735/**
736 * Removes |visit| from this model.
737 * @param {Visit} visit A visit to remove.
738 */
739HistoryModel.prototype.removeVisit = function(visit) {
740  var index = this.visits_.indexOf(visit);
741  if (index >= 0)
742    this.visits_.splice(index, 1);
743};
744
745// HistoryModel, Private: -----------------------------------------------------
746
747/**
748 * Clear the history model.
749 * @private
750 */
751HistoryModel.prototype.clearModel_ = function() {
752  this.inFlight_ = false;  // Whether a query is inflight.
753  this.searchText_ = '';
754  // Whether this user is a supervised user.
755  this.isSupervisedProfile = loadTimeData.getBoolean('isSupervisedProfile');
756  this.deletingHistoryAllowed = loadTimeData.getBoolean('allowDeletingHistory');
757
758  // Only create checkboxes for editing entries if they can be used either to
759  // delete an entry or to block/allow it.
760  this.editingEntriesAllowed = this.deletingHistoryAllowed;
761
762  // Flag to show that the results are grouped by domain or not.
763  this.groupByDomain_ = false;
764
765  this.visits_ = [];  // Date-sorted list of visits (most recent first).
766  this.nextVisitId_ = 0;
767  selectionAnchor = -1;
768
769  // The page that the view wants to see - we only fetch slightly past this
770  // point. If the view requests a page that we don't have data for, we try
771  // to fetch it and call back when we're done.
772  this.requestedPage_ = 0;
773
774  // The range of history to view or search over.
775  this.rangeInDays_ = HistoryModel.Range.ALL_TIME;
776
777  // Skip |offset_| * weeks/months from the begining.
778  this.offset_ = 0;
779
780  // Keeps track of whether or not there are more results available than are
781  // currently held in |this.visits_|.
782  this.isQueryFinished_ = false;
783
784  if (this.view_)
785    this.view_.clear_();
786};
787
788/**
789 * Figure out if we need to do more queries to fill the currently requested
790 * page. If we think we can fill the page, call the view and let it know
791 * we're ready to show something. This only applies to the daily time-based
792 * view.
793 * @private
794 */
795HistoryModel.prototype.updateSearch_ = function() {
796  var doneLoading = this.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
797                    this.isQueryFinished_ ||
798                    this.canFillPage_(this.requestedPage_);
799
800  // Try to fetch more results if more results can arrive and the page is not
801  // full.
802  if (!doneLoading && !this.inFlight_)
803    this.queryHistory_();
804
805  // Show the result or a message if no results were returned.
806  this.view_.onModelReady(doneLoading);
807};
808
809/**
810 * Query for history, either for a search or time-based browsing.
811 * @private
812 */
813HistoryModel.prototype.queryHistory_ = function() {
814  var maxResults =
815      (this.rangeInDays_ == HistoryModel.Range.ALL_TIME) ? RESULTS_PER_PAGE : 0;
816
817  // If there are already some visits, pick up the previous query where it
818  // left off.
819  var lastVisit = this.visits_.slice(-1)[0];
820  var endTime = lastVisit ? lastVisit.date.getTime() : 0;
821
822  $('loading-spinner').hidden = false;
823  this.inFlight_ = true;
824  chrome.send('queryHistory',
825      [this.searchText_, this.offset_, this.rangeInDays_, endTime, maxResults]);
826};
827
828/**
829 * Check to see if we have data for the given page.
830 * @param {number} page The page number.
831 * @return {boolean} Whether we have any data for the given page.
832 * @private
833 */
834HistoryModel.prototype.haveDataForPage_ = function(page) {
835  return page * RESULTS_PER_PAGE < this.getSize();
836};
837
838/**
839 * Check to see if we have data to fill the given page.
840 * @param {number} page The page number.
841 * @return {boolean} Whether we have data to fill the page.
842 * @private
843 */
844HistoryModel.prototype.canFillPage_ = function(page) {
845  return ((page + 1) * RESULTS_PER_PAGE <= this.getSize());
846};
847
848/**
849 * Gets whether we are grouped by domain.
850 * @return {boolean} Whether the results are grouped by domain.
851 */
852HistoryModel.prototype.getGroupByDomain = function() {
853  return this.groupByDomain_;
854};
855
856///////////////////////////////////////////////////////////////////////////////
857// HistoryFocusObserver:
858
859/**
860 * @constructor
861 * @implements {cr.ui.FocusRow.Observer}
862 */
863function HistoryFocusObserver() {}
864
865HistoryFocusObserver.prototype = {
866  /** @override */
867  onActivate: function(row) {
868    this.getActiveRowElement_(row).classList.add('active');
869  },
870
871  /** @override */
872  onDeactivate: function(row) {
873    this.getActiveRowElement_(row).classList.remove('active');
874  },
875
876  /**
877   * @param {cr.ui.FocusRow} row The row to find an element for.
878   * @return {Element} |row|'s "active" element.
879   * @private
880   */
881  getActiveRowElement_: function(row) {
882    return findAncestorByClass(row.items[0], 'entry') ||
883           findAncestorByClass(row.items[0], 'site-domain-wrapper');
884  },
885};
886
887///////////////////////////////////////////////////////////////////////////////
888// HistoryView:
889
890/**
891 * Functions and state for populating the page with HTML. This should one-day
892 * contain the view and use event handlers, rather than pushing HTML out and
893 * getting called externally.
894 * @param {HistoryModel} model The model backing this view.
895 * @constructor
896 */
897function HistoryView(model) {
898  this.editButtonTd_ = $('edit-button');
899  this.editingControlsDiv_ = $('editing-controls');
900  this.resultDiv_ = $('results-display');
901  this.focusGrid_ = new cr.ui.FocusGrid(this.resultDiv_,
902                                        new HistoryFocusObserver);
903  this.pageDiv_ = $('results-pagination');
904  this.model_ = model;
905  this.pageIndex_ = 0;
906  this.lastDisplayed_ = [];
907
908  this.model_.setView(this);
909
910  this.currentVisits_ = [];
911
912  // If there is no search button, use the search button label as placeholder
913  // text in the search field.
914  if ($('search-button').offsetWidth == 0)
915    $('search-field').placeholder = $('search-button').value;
916
917  var self = this;
918
919  $('clear-browsing-data').addEventListener('click', openClearBrowsingData);
920  $('remove-selected').addEventListener('click', removeItems);
921
922  // Add handlers for the page navigation buttons at the bottom.
923  $('newest-button').addEventListener('click', function() {
924    recordUmaAction('HistoryPage_NewestHistoryClick');
925    self.setPage(0);
926  });
927  $('newer-button').addEventListener('click', function() {
928    recordUmaAction('HistoryPage_NewerHistoryClick');
929    self.setPage(self.pageIndex_ - 1);
930  });
931  $('older-button').addEventListener('click', function() {
932    recordUmaAction('HistoryPage_OlderHistoryClick');
933    self.setPage(self.pageIndex_ + 1);
934  });
935
936  var handleRangeChange = function(e) {
937    // Update the results and save the last state.
938    var value = parseInt(e.target.value, 10);
939    self.setRangeInDays(/** @type {HistoryModel.Range.<number>} */(value));
940  };
941
942  // Add handlers for the range options.
943  $('timeframe-filter-all').addEventListener('change', handleRangeChange);
944  $('timeframe-filter-week').addEventListener('change', handleRangeChange);
945  $('timeframe-filter-month').addEventListener('change', handleRangeChange);
946
947  $('range-previous').addEventListener('click', function(e) {
948    if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
949      self.setPage(self.pageIndex_ + 1);
950    else
951      self.setOffset(self.getOffset() + 1);
952  });
953  $('range-next').addEventListener('click', function(e) {
954    if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
955      self.setPage(self.pageIndex_ - 1);
956    else
957      self.setOffset(self.getOffset() - 1);
958  });
959  $('range-today').addEventListener('click', function(e) {
960    if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
961      self.setPage(0);
962    else
963      self.setOffset(0);
964  });
965}
966
967// HistoryView, public: -------------------------------------------------------
968/**
969 * Do a search on a specific term.
970 * @param {string} term The string to search for.
971 */
972HistoryView.prototype.setSearch = function(term) {
973  window.scrollTo(0, 0);
974  this.setPageState(term, 0, this.getRangeInDays(), this.getOffset());
975};
976
977/**
978 * Reload the current view.
979 */
980HistoryView.prototype.reload = function() {
981  this.model_.reload();
982  this.updateSelectionEditButtons();
983  this.updateRangeButtons_();
984};
985
986/**
987 * Sets all the parameters for the history page and then reloads the view to
988 * update the results.
989 * @param {string} searchText The search string to set.
990 * @param {number} page The page to be viewed.
991 * @param {HistoryModel.Range} range The range to view or search over.
992 * @param {number} offset Set the begining of the query to the specific offset.
993 */
994HistoryView.prototype.setPageState = function(searchText, page, range, offset) {
995  this.clear_();
996  this.model_.searchText_ = searchText;
997  this.pageIndex_ = page;
998  this.model_.requestedPage_ = page;
999  this.model_.rangeInDays_ = range;
1000  this.model_.groupByDomain_ = false;
1001  if (range != HistoryModel.Range.ALL_TIME)
1002    this.model_.groupByDomain_ = true;
1003  this.model_.offset_ = offset;
1004  this.reload();
1005  pageState.setUIState(this.model_.getSearchText(),
1006                       this.pageIndex_,
1007                       this.getRangeInDays(),
1008                       this.getOffset());
1009};
1010
1011/**
1012 * Switch to a specified page.
1013 * @param {number} page The page we wish to view.
1014 */
1015HistoryView.prototype.setPage = function(page) {
1016  // TODO(sergiu): Move this function to setPageState as well and see why one
1017  // of the tests fails when using setPageState.
1018  this.clear_();
1019  this.pageIndex_ = parseInt(page, 10);
1020  window.scrollTo(0, 0);
1021  this.model_.requestPage(page);
1022  pageState.setUIState(this.model_.getSearchText(),
1023                       this.pageIndex_,
1024                       this.getRangeInDays(),
1025                       this.getOffset());
1026};
1027
1028/**
1029 * @return {number} The page number being viewed.
1030 */
1031HistoryView.prototype.getPage = function() {
1032  return this.pageIndex_;
1033};
1034
1035/**
1036 * Set the current range for grouped results.
1037 * @param {HistoryModel.Range} range The number of days to which the range
1038 *     should be set.
1039 */
1040HistoryView.prototype.setRangeInDays = function(range) {
1041  // Set the range, offset and reset the page.
1042  this.setPageState(this.model_.getSearchText(), 0, range, 0);
1043};
1044
1045/**
1046 * Get the current range in days.
1047 * @return {HistoryModel.Range} Current range in days from the model.
1048 */
1049HistoryView.prototype.getRangeInDays = function() {
1050  return this.model_.rangeInDays;
1051};
1052
1053/**
1054 * Set the current offset for grouped results.
1055 * @param {number} offset Offset to set.
1056 */
1057HistoryView.prototype.setOffset = function(offset) {
1058  // If there is another query already in flight wait for that to complete.
1059  if (this.model_.inFlight_)
1060    return;
1061  this.setPageState(this.model_.getSearchText(),
1062                    this.pageIndex_,
1063                    this.getRangeInDays(),
1064                    offset);
1065};
1066
1067/**
1068 * Get the current offset.
1069 * @return {number} Current offset from the model.
1070 */
1071HistoryView.prototype.getOffset = function() {
1072  return this.model_.offset;
1073};
1074
1075/**
1076 * Callback for the history model to let it know that it has data ready for us
1077 * to view.
1078 * @param {boolean} doneLoading Whether the current request is complete.
1079 */
1080HistoryView.prototype.onModelReady = function(doneLoading) {
1081  this.displayResults_(doneLoading);
1082
1083  // Allow custom styling based on whether there are any results on the page.
1084  // To make this easier, add a class to the body if there are any results.
1085  var hasResults = this.model_.visits_.length > 0;
1086  document.body.classList.toggle('has-results', hasResults);
1087
1088  this.updateFocusGrid_();
1089  this.updateNavBar_();
1090
1091  if (isMobileVersion()) {
1092    // Hide the search field if it is empty and there are no results.
1093    var isSearch = this.model_.getSearchText().length > 0;
1094    $('search-field').hidden = !(hasResults || isSearch);
1095  }
1096};
1097
1098/**
1099 * Enables or disables the buttons that control editing entries depending on
1100 * whether there are any checked boxes.
1101 */
1102HistoryView.prototype.updateSelectionEditButtons = function() {
1103  if (loadTimeData.getBoolean('allowDeletingHistory')) {
1104    var anyChecked = document.querySelector('.entry input:checked') != null;
1105    $('remove-selected').disabled = !anyChecked;
1106  } else {
1107    $('remove-selected').disabled = true;
1108  }
1109};
1110
1111/**
1112 * Shows the notification bar at the top of the page with |innerHTML| as its
1113 * content.
1114 * @param {string} innerHTML The HTML content of the warning.
1115 * @param {boolean} isWarning If true, style the notification as a warning.
1116 */
1117HistoryView.prototype.showNotification = function(innerHTML, isWarning) {
1118  var bar = $('notification-bar');
1119  bar.innerHTML = innerHTML;
1120  bar.hidden = false;
1121  if (isWarning)
1122    bar.classList.add('warning');
1123  else
1124    bar.classList.remove('warning');
1125
1126  // Make sure that any links in the HTML are targeting the top level.
1127  var links = bar.querySelectorAll('a');
1128  for (var i = 0; i < links.length; i++)
1129    links[i].target = '_top';
1130
1131  this.positionNotificationBar();
1132};
1133
1134/**
1135 * @param {Visit} visit The visit about to be removed from this view.
1136 */
1137HistoryView.prototype.onBeforeRemove = function(visit) {
1138  assert(this.currentVisits_.indexOf(visit) >= 0);
1139
1140  var pos = this.focusGrid_.getPositionForTarget(document.activeElement);
1141  if (!pos)
1142    return;
1143
1144  var row = this.focusGrid_.rows[pos.row + 1] ||
1145            this.focusGrid_.rows[pos.row - 1];
1146  if (row)
1147    row.focusIndex(Math.min(pos.col, row.items.length - 1));
1148};
1149
1150/** @param {Visit} visit The visit about to be unstarred. */
1151HistoryView.prototype.onBeforeUnstarred = function(visit) {
1152  assert(this.currentVisits_.indexOf(visit) >= 0);
1153  assert(visit.bookmarkStar == document.activeElement);
1154
1155  var pos = this.focusGrid_.getPositionForTarget(document.activeElement);
1156  var row = this.focusGrid_.rows[pos.row];
1157  row.focusIndex(Math.min(pos.col + 1, row.items.length - 1));
1158};
1159
1160/** @param {Visit} visit The visit that was just unstarred. */
1161HistoryView.prototype.onAfterUnstarred = function(visit) {
1162  this.updateFocusGrid_();
1163};
1164
1165/**
1166 * Removes a single entry from the view. Also removes gaps before and after
1167 * entry if necessary.
1168 * @param {Visit} visit The visit to be removed.
1169 */
1170HistoryView.prototype.removeVisit = function(visit) {
1171  var entry = visit.domNode_;
1172  var previousEntry = entry.previousSibling;
1173  var nextEntry = entry.nextSibling;
1174  var toRemove = [entry];
1175
1176  // If there is no previous entry, and the next entry is a gap, remove it.
1177  if (!previousEntry && nextEntry && nextEntry.classList.contains('gap'))
1178    toRemove.push(nextEntry);
1179
1180  // If there is no next entry, and the previous entry is a gap, remove it.
1181  if (!nextEntry && previousEntry && previousEntry.classList.contains('gap'))
1182    toRemove.push(previousEntry);
1183
1184  // If both the next and previous entries are gaps, remove the next one.
1185  if (nextEntry && nextEntry.classList.contains('gap') &&
1186      previousEntry && previousEntry.classList.contains('gap')) {
1187    toRemove.push(nextEntry);
1188  }
1189
1190  // If removing the last entry on a day, remove the entire day.
1191  var dayResults = findAncestorByClass(entry, 'day-results');
1192  if (dayResults && dayResults.querySelectorAll('.entry').length <= 1) {
1193    toRemove.push(dayResults.previousSibling);  // Remove the 'h3'.
1194    toRemove.push(dayResults);
1195  }
1196
1197  // Callback to be called when each node has finished animating. It detects
1198  // when all the animations have completed.
1199  function onRemove() {
1200    for (var i = 0; i < toRemove.length; ++i) {
1201      if (toRemove[i].parentNode)
1202        return;
1203    }
1204    onEntryRemoved();
1205  }
1206
1207  // Kick off the removal process.
1208  for (var i = 0; i < toRemove.length; ++i) {
1209    removeNode(toRemove[i], onRemove, this);
1210  }
1211  this.updateFocusGrid_();
1212
1213  var index = this.currentVisits_.indexOf(visit);
1214  if (index >= 0)
1215    this.currentVisits_.splice(index, 1);
1216
1217  this.model_.removeVisit(visit);
1218};
1219
1220/**
1221 * Called when an individual history entry has been removed from the page.
1222 * This will only be called when all the elements affected by the deletion
1223 * have been removed from the DOM and the animations have completed.
1224 */
1225HistoryView.prototype.onEntryRemoved = function() {
1226  this.updateSelectionEditButtons();
1227
1228  if (this.model_.getSize() == 0)
1229    this.onModelReady(true);  // Shows "No entries" message.
1230};
1231
1232/**
1233 * Adjusts the position of the notification bar based on the size of the page.
1234 */
1235HistoryView.prototype.positionNotificationBar = function() {
1236  var bar = $('notification-bar');
1237
1238  // If the bar does not fit beside the editing controls, put it into the
1239  // overflow state.
1240  if (bar.getBoundingClientRect().top >=
1241      $('editing-controls').getBoundingClientRect().bottom) {
1242    bar.classList.add('alone');
1243  } else {
1244    bar.classList.remove('alone');
1245  }
1246};
1247
1248// HistoryView, private: ------------------------------------------------------
1249
1250/**
1251 * Clear the results in the view.  Since we add results piecemeal, we need
1252 * to clear them out when we switch to a new page or reload.
1253 * @private
1254 */
1255HistoryView.prototype.clear_ = function() {
1256  var alertOverlay = $('alertOverlay');
1257  if (alertOverlay && alertOverlay.classList.contains('showing'))
1258    hideConfirmationOverlay();
1259
1260  this.resultDiv_.textContent = '';
1261
1262  this.currentVisits_.forEach(function(visit) {
1263    visit.isRendered = false;
1264  });
1265  this.currentVisits_ = [];
1266
1267  document.body.classList.remove('has-results');
1268};
1269
1270/**
1271 * Record that the given visit has been rendered.
1272 * @param {Visit} visit The visit that was rendered.
1273 * @private
1274 */
1275HistoryView.prototype.setVisitRendered_ = function(visit) {
1276  visit.isRendered = true;
1277  this.currentVisits_.push(visit);
1278};
1279
1280/**
1281 * Generates and adds the grouped visits DOM for a certain domain. This
1282 * includes the clickable arrow and domain name and the visit entries for
1283 * that domain.
1284 * @param {Element} results DOM object to which to add the elements.
1285 * @param {string} domain Current domain name.
1286 * @param {Array} domainVisits Array of visits for this domain.
1287 * @private
1288 */
1289HistoryView.prototype.getGroupedVisitsDOM_ = function(
1290    results, domain, domainVisits) {
1291  // Add a new domain entry.
1292  var siteResults = results.appendChild(
1293      createElementWithClassName('li', 'site-entry'));
1294
1295  var siteDomainWrapper = siteResults.appendChild(
1296      createElementWithClassName('div', 'site-domain-wrapper'));
1297  // Make a row that will contain the arrow, the favicon and the domain.
1298  var siteDomainRow = siteDomainWrapper.appendChild(
1299      createElementWithClassName('div', 'site-domain-row'));
1300
1301  if (this.model_.editingEntriesAllowed) {
1302    var siteDomainCheckbox =
1303        createElementWithClassName('input', 'domain-checkbox');
1304
1305    siteDomainCheckbox.type = 'checkbox';
1306    siteDomainCheckbox.addEventListener('click', domainCheckboxClicked);
1307    siteDomainCheckbox.domain_ = domain;
1308    siteDomainCheckbox.setAttribute('aria-label', domain);
1309    siteDomainRow.appendChild(siteDomainCheckbox);
1310  }
1311
1312  var siteArrow = siteDomainRow.appendChild(
1313      createElementWithClassName('div', 'site-domain-arrow'));
1314  var siteDomain = siteDomainRow.appendChild(
1315      createElementWithClassName('div', 'site-domain'));
1316  var siteDomainLink = siteDomain.appendChild(
1317      createElementWithClassName('button', 'link-button'));
1318  siteDomainLink.addEventListener('click', function(e) { e.preventDefault(); });
1319  siteDomainLink.textContent = domain;
1320  var numberOfVisits = createElementWithClassName('span', 'number-visits');
1321  var domainElement = document.createElement('span');
1322
1323  numberOfVisits.textContent = loadTimeData.getStringF('numberVisits',
1324                                                       domainVisits.length);
1325  siteDomain.appendChild(numberOfVisits);
1326
1327  domainVisits[0].addFaviconToElement_(siteDomain);
1328
1329  siteDomainWrapper.addEventListener(
1330      'click', this.toggleGroupedVisits_.bind(this));
1331
1332  if (this.model_.isSupervisedProfile) {
1333    siteDomainRow.appendChild(
1334        getFilteringStatusDOM(domainVisits[0].hostFilteringBehavior));
1335  }
1336
1337  siteResults.appendChild(siteDomainWrapper);
1338  var resultsList = siteResults.appendChild(
1339      createElementWithClassName('ol', 'site-results'));
1340  resultsList.classList.add('grouped');
1341
1342  // Collapse until it gets toggled.
1343  resultsList.style.height = 0;
1344  resultsList.setAttribute('aria-hidden', 'true');
1345
1346  // Add the results for each of the domain.
1347  var isMonthGroupedResult = this.getRangeInDays() == HistoryModel.Range.MONTH;
1348  for (var j = 0, visit; visit = domainVisits[j]; j++) {
1349    resultsList.appendChild(visit.getResultDOM({
1350      focusless: true,
1351      useMonthDate: isMonthGroupedResult,
1352    }));
1353    this.setVisitRendered_(visit);
1354  }
1355};
1356
1357/**
1358 * Enables or disables the time range buttons.
1359 * @private
1360 */
1361HistoryView.prototype.updateRangeButtons_ = function() {
1362  // The enabled state for the previous, today and next buttons.
1363  var previousState = false;
1364  var todayState = false;
1365  var nextState = false;
1366  var usePage = (this.getRangeInDays() == HistoryModel.Range.ALL_TIME);
1367
1368  // Use pagination for most recent visits, offset otherwise.
1369  // TODO(sergiu): Maybe send just one variable in the future.
1370  if (usePage) {
1371    if (this.getPage() != 0) {
1372      nextState = true;
1373      todayState = true;
1374    }
1375    previousState = this.model_.hasMoreResults();
1376  } else {
1377    if (this.getOffset() != 0) {
1378      nextState = true;
1379      todayState = true;
1380    }
1381    previousState = !this.model_.isQueryFinished_;
1382  }
1383
1384  $('range-previous').disabled = !previousState;
1385  $('range-today').disabled = !todayState;
1386  $('range-next').disabled = !nextState;
1387};
1388
1389/**
1390 * Groups visits by domain, sorting them by the number of visits.
1391 * @param {Array} visits Visits received from the query results.
1392 * @param {Element} results Object where the results are added to.
1393 * @private
1394 */
1395HistoryView.prototype.groupVisitsByDomain_ = function(visits, results) {
1396  var visitsByDomain = {};
1397  var domains = [];
1398
1399  // Group the visits into a dictionary and generate a list of domains.
1400  for (var i = 0, visit; visit = visits[i]; i++) {
1401    var domain = visit.domain_;
1402    if (!visitsByDomain[domain]) {
1403      visitsByDomain[domain] = [];
1404      domains.push(domain);
1405    }
1406    visitsByDomain[domain].push(visit);
1407  }
1408  var sortByVisits = function(a, b) {
1409    return visitsByDomain[b].length - visitsByDomain[a].length;
1410  };
1411  domains.sort(sortByVisits);
1412
1413  for (var i = 0; i < domains.length; ++i) {
1414    var domain = domains[i];
1415    this.getGroupedVisitsDOM_(results, domain, visitsByDomain[domain]);
1416  }
1417};
1418
1419/**
1420 * Adds the results for a month.
1421 * @param {Array} visits Visits returned by the query.
1422 * @param {Node} parentNode Node to which to add the results to.
1423 * @private
1424 */
1425HistoryView.prototype.addMonthResults_ = function(visits, parentNode) {
1426  if (visits.length == 0)
1427    return;
1428
1429  var monthResults = /** @type {HTMLOListElement} */(parentNode.appendChild(
1430      createElementWithClassName('ol', 'month-results')));
1431  // Don't add checkboxes if entries can not be edited.
1432  if (!this.model_.editingEntriesAllowed)
1433    monthResults.classList.add('no-checkboxes');
1434
1435  this.groupVisitsByDomain_(visits, monthResults);
1436};
1437
1438/**
1439 * Adds the results for a certain day. This includes a title with the day of
1440 * the results and the results themselves, grouped or not.
1441 * @param {Array} visits Visits returned by the query.
1442 * @param {Node} parentNode Node to which to add the results to.
1443 * @private
1444 */
1445HistoryView.prototype.addDayResults_ = function(visits, parentNode) {
1446  if (visits.length == 0)
1447    return;
1448
1449  var firstVisit = visits[0];
1450  var day = parentNode.appendChild(createElementWithClassName('h3', 'day'));
1451  day.appendChild(document.createTextNode(firstVisit.dateRelativeDay));
1452  if (firstVisit.continued) {
1453    day.appendChild(document.createTextNode(' ' +
1454                                            loadTimeData.getString('cont')));
1455  }
1456  var dayResults = /** @type {HTMLElement} */(parentNode.appendChild(
1457      createElementWithClassName('ol', 'day-results')));
1458
1459  // Don't add checkboxes if entries can not be edited.
1460  if (!this.model_.editingEntriesAllowed)
1461    dayResults.classList.add('no-checkboxes');
1462
1463  if (this.model_.getGroupByDomain()) {
1464    this.groupVisitsByDomain_(visits, dayResults);
1465  } else {
1466    var lastTime;
1467
1468    for (var i = 0, visit; visit = visits[i]; i++) {
1469      // If enough time has passed between visits, indicate a gap in browsing.
1470      var thisTime = visit.date.getTime();
1471      if (lastTime && lastTime - thisTime > BROWSING_GAP_TIME)
1472        dayResults.appendChild(createElementWithClassName('li', 'gap'));
1473
1474      // Insert the visit into the DOM.
1475      dayResults.appendChild(visit.getResultDOM({ addTitleFavicon: true }));
1476      this.setVisitRendered_(visit);
1477
1478      lastTime = thisTime;
1479    }
1480  }
1481};
1482
1483/**
1484 * Adds the text that shows the current interval, used for week and month
1485 * results.
1486 * @param {Node} resultsFragment The element to which the interval will be
1487 *     added to.
1488 * @private
1489 */
1490HistoryView.prototype.addTimeframeInterval_ = function(resultsFragment) {
1491  if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME)
1492    return;
1493
1494  // If this is a time range result add some text that shows what is the
1495  // time range for the results the user is viewing.
1496  var timeFrame = resultsFragment.appendChild(
1497      createElementWithClassName('h2', 'timeframe'));
1498  // TODO(sergiu): Figure the best way to show this for the first day of
1499  // the month.
1500  timeFrame.appendChild(document.createTextNode(loadTimeData.getStringF(
1501      'historyInterval',
1502      this.model_.queryStartTime,
1503      this.model_.queryEndTime)));
1504};
1505
1506/**
1507 * Update the page with results.
1508 * @param {boolean} doneLoading Whether the current request is complete.
1509 * @private
1510 */
1511HistoryView.prototype.displayResults_ = function(doneLoading) {
1512  // Either show a page of results received for the all time results or all the
1513  // received results for the weekly and monthly view.
1514  var results = this.model_.visits_;
1515  if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) {
1516    var rangeStart = this.pageIndex_ * RESULTS_PER_PAGE;
1517    var rangeEnd = rangeStart + RESULTS_PER_PAGE;
1518    results = this.model_.getNumberedRange(rangeStart, rangeEnd);
1519  }
1520  var searchText = this.model_.getSearchText();
1521  var groupByDomain = this.model_.getGroupByDomain();
1522
1523  if (searchText) {
1524    // Add a header for the search results, if there isn't already one.
1525    if (!this.resultDiv_.querySelector('h3')) {
1526      var header = document.createElement('h3');
1527      header.textContent = loadTimeData.getStringF('searchResultsFor',
1528                                                   searchText);
1529      this.resultDiv_.appendChild(header);
1530    }
1531
1532    this.addTimeframeInterval_(this.resultDiv_);
1533
1534    var searchResults = createElementWithClassName('ol', 'search-results');
1535
1536    // Don't add checkboxes if entries can not be edited.
1537    if (!this.model_.editingEntriesAllowed)
1538      searchResults.classList.add('no-checkboxes');
1539
1540    if (results.length == 0 && doneLoading) {
1541      var noSearchResults = searchResults.appendChild(
1542          createElementWithClassName('div', 'no-results-message'));
1543      noSearchResults.textContent = loadTimeData.getString('noSearchResults');
1544    } else {
1545      for (var i = 0, visit; visit = results[i]; i++) {
1546        if (!visit.isRendered) {
1547          searchResults.appendChild(visit.getResultDOM({
1548            isSearchResult: true,
1549            addTitleFavicon: true
1550          }));
1551          this.setVisitRendered_(visit);
1552        }
1553      }
1554    }
1555    this.resultDiv_.appendChild(searchResults);
1556  } else {
1557    var resultsFragment = document.createDocumentFragment();
1558
1559    this.addTimeframeInterval_(resultsFragment);
1560
1561    if (results.length == 0 && doneLoading) {
1562      var noResults = resultsFragment.appendChild(
1563          createElementWithClassName('div', 'no-results-message'));
1564      noResults.textContent = loadTimeData.getString('noResults');
1565      this.resultDiv_.appendChild(resultsFragment);
1566      return;
1567    }
1568
1569    if (this.getRangeInDays() == HistoryModel.Range.MONTH &&
1570        groupByDomain) {
1571      // Group everything together in the month view.
1572      this.addMonthResults_(results, resultsFragment);
1573    } else {
1574      var dayStart = 0;
1575      var dayEnd = 0;
1576      // Go through all of the visits and process them in chunks of one day.
1577      while (dayEnd < results.length) {
1578        // Skip over the ones that are already rendered.
1579        while (dayStart < results.length && results[dayStart].isRendered)
1580          ++dayStart;
1581        var dayEnd = dayStart + 1;
1582        while (dayEnd < results.length && results[dayEnd].continued)
1583          ++dayEnd;
1584
1585        this.addDayResults_(
1586            results.slice(dayStart, dayEnd), resultsFragment);
1587      }
1588    }
1589
1590    // Add all the days and their visits to the page.
1591    this.resultDiv_.appendChild(resultsFragment);
1592  }
1593  // After the results have been added to the DOM, determine the size of the
1594  // time column.
1595  this.setTimeColumnWidth_();
1596};
1597
1598var focusGridRowSelector = [
1599  '.day-results > .entry:not(.fade-out)',
1600  '.expand .grouped .entry:not(.fade-out)',
1601  '.site-domain-wrapper'
1602].join(', ');
1603
1604var focusGridColumnSelector = [
1605  '.entry-box input',
1606  '.bookmark-section.starred',
1607  '.title a',
1608  '.drop-down',
1609  '.domain-checkbox',
1610  '.link-button',
1611].join(', ');
1612
1613/** @private */
1614HistoryView.prototype.updateFocusGrid_ = function() {
1615  var rows = this.resultDiv_.querySelectorAll(focusGridRowSelector);
1616  var grid = [];
1617
1618  for (var i = 0; i < rows.length; ++i) {
1619    assert(rows[i].parentNode);
1620    grid.push(rows[i].querySelectorAll(focusGridColumnSelector));
1621  }
1622
1623  this.focusGrid_.setGrid(grid);
1624};
1625
1626/**
1627 * Update the visibility of the page navigation buttons.
1628 * @private
1629 */
1630HistoryView.prototype.updateNavBar_ = function() {
1631  this.updateRangeButtons_();
1632
1633  // Supervised users have the control bar on top, don't show it on the bottom
1634  // as well.
1635  if (!loadTimeData.getBoolean('isSupervisedProfile')) {
1636    $('newest-button').hidden = this.pageIndex_ == 0;
1637    $('newer-button').hidden = this.pageIndex_ == 0;
1638    $('older-button').hidden =
1639        this.model_.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
1640        !this.model_.hasMoreResults();
1641  }
1642};
1643
1644/**
1645 * Updates the visibility of the 'Clear browsing data' button.
1646 * Only used on mobile platforms.
1647 * @private
1648 */
1649HistoryView.prototype.updateClearBrowsingDataButton_ = function() {
1650  // Ideally, we should hide the 'Clear browsing data' button whenever the
1651  // soft keyboard is visible. This is not possible, so instead, hide the
1652  // button whenever the search field has focus.
1653  $('clear-browsing-data').hidden =
1654      (document.activeElement === $('search-field'));
1655};
1656
1657/**
1658 * Dynamically sets the min-width of the time column for history entries.
1659 * This ensures that all entry times will have the same width, without
1660 * imposing a fixed width that may not be appropriate for some locales.
1661 * @private
1662 */
1663HistoryView.prototype.setTimeColumnWidth_ = function() {
1664  // Find the maximum width of all the time elements on the page.
1665  var times = this.resultDiv_.querySelectorAll('.entry .time');
1666  var widths = Array.prototype.map.call(times, function(el) {
1667    el.style.minWidth = '-webkit-min-content';
1668    var width = el.clientWidth;
1669    el.style.minWidth = '';
1670
1671    // Add an extra pixel to prevent rounding errors from causing the text to
1672    // be ellipsized at certain zoom levels (see crbug.com/329779).
1673    return width + 1;
1674  });
1675  var maxWidth = widths.length ? Math.max.apply(null, widths) : 0;
1676
1677  // Add a dynamic stylesheet to the page (or replace the existing one), to
1678  // ensure that all entry times have the same width.
1679  var styleEl = $('timeColumnStyle');
1680  if (!styleEl) {
1681    styleEl = document.head.appendChild(document.createElement('style'));
1682    styleEl.id = 'timeColumnStyle';
1683  }
1684  styleEl.textContent = '.entry .time { min-width: ' + maxWidth + 'px; }';
1685};
1686
1687/**
1688 * Toggles an element in the grouped history.
1689 * @param {Event} e The event with element |e.target| which was clicked on.
1690 * @private
1691 */
1692HistoryView.prototype.toggleGroupedVisits_ = function(e) {
1693  var entry = findAncestorByClass(/** @type {Element} */(e.target),
1694                                  'site-entry');
1695  var innerResultList = entry.querySelector('.site-results');
1696
1697  if (entry.classList.contains('expand')) {
1698    innerResultList.style.height = 0;
1699    innerResultList.setAttribute('aria-hidden', 'true');
1700  } else {
1701    innerResultList.setAttribute('aria-hidden', 'false');
1702    innerResultList.style.height = 'auto';
1703    // -webkit-transition does not work on height:auto elements so first set
1704    // the height to auto so that it is computed and then set it to the
1705    // computed value in pixels so the transition works properly.
1706    var height = innerResultList.clientHeight;
1707    innerResultList.style.height = 0;
1708    setTimeout(function() {
1709      innerResultList.style.height = height + 'px';
1710    }, 0);
1711  }
1712
1713  entry.classList.toggle('expand');
1714  this.updateFocusGrid_();
1715};
1716
1717///////////////////////////////////////////////////////////////////////////////
1718// State object:
1719/**
1720 * An 'AJAX-history' implementation.
1721 * @param {HistoryModel} model The model we're representing.
1722 * @param {HistoryView} view The view we're representing.
1723 * @constructor
1724 */
1725function PageState(model, view) {
1726  // Enforce a singleton.
1727  if (PageState.instance) {
1728    return PageState.instance;
1729  }
1730
1731  this.model = model;
1732  this.view = view;
1733
1734  if (typeof this.checker_ != 'undefined' && this.checker_) {
1735    clearInterval(this.checker_);
1736  }
1737
1738  // TODO(glen): Replace this with a bound method so we don't need
1739  //     public model and view.
1740  this.checker_ = window.setInterval(function(stateObj) {
1741    var hashData = stateObj.getHashData();
1742    var page = parseInt(hashData.page, 10);
1743    var range = parseInt(hashData.range, 10);
1744    var offset = parseInt(hashData.offset, 10);
1745    if (hashData.q != stateObj.model.getSearchText() ||
1746        page != stateObj.view.getPage() ||
1747        range != stateObj.model.rangeInDays ||
1748        offset != stateObj.model.offset) {
1749      stateObj.view.setPageState(hashData.q, page, range, offset);
1750    }
1751  }, 50, this);
1752}
1753
1754/**
1755 * Holds the singleton instance.
1756 */
1757PageState.instance = null;
1758
1759/**
1760 * @return {Object} An object containing parameters from our window hash.
1761 */
1762PageState.prototype.getHashData = function() {
1763  var result = {
1764    q: '',
1765    page: 0,
1766    range: 0,
1767    offset: 0
1768  };
1769
1770  if (!window.location.hash)
1771    return result;
1772
1773  var hashSplit = window.location.hash.substr(1).split('&');
1774  for (var i = 0; i < hashSplit.length; i++) {
1775    var pair = hashSplit[i].split('=');
1776    if (pair.length > 1)
1777      result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' '));
1778  }
1779
1780  return result;
1781};
1782
1783/**
1784 * Set the hash to a specified state, this will create an entry in the
1785 * session history so the back button cycles through hash states, which
1786 * are then picked up by our listener.
1787 * @param {string} term The current search string.
1788 * @param {number} page The page currently being viewed.
1789 * @param {HistoryModel.Range} range The range to view or search over.
1790 * @param {number} offset Set the begining of the query to the specific offset.
1791 */
1792PageState.prototype.setUIState = function(term, page, range, offset) {
1793  // Make sure the form looks pretty.
1794  $('search-field').value = term;
1795  var hash = this.getHashData();
1796  if (hash.q != term || hash.page != page || hash.range != range ||
1797      hash.offset != offset) {
1798    window.location.hash = PageState.getHashString(term, page, range, offset);
1799  }
1800};
1801
1802/**
1803 * Static method to get the hash string for a specified state
1804 * @param {string} term The current search string.
1805 * @param {number} page The page currently being viewed.
1806 * @param {HistoryModel.Range} range The range to view or search over.
1807 * @param {number} offset Set the begining of the query to the specific offset.
1808 * @return {string} The string to be used in a hash.
1809 */
1810PageState.getHashString = function(term, page, range, offset) {
1811  // Omit elements that are empty.
1812  var newHash = [];
1813
1814  if (term)
1815    newHash.push('q=' + encodeURIComponent(term));
1816
1817  if (page)
1818    newHash.push('page=' + page);
1819
1820  if (range)
1821    newHash.push('range=' + range);
1822
1823  if (offset)
1824    newHash.push('offset=' + offset);
1825
1826  return newHash.join('&');
1827};
1828
1829///////////////////////////////////////////////////////////////////////////////
1830// Document Functions:
1831/**
1832 * Window onload handler, sets up the page.
1833 */
1834function load() {
1835  uber.onContentFrameLoaded();
1836
1837  var searchField = $('search-field');
1838
1839  historyModel = new HistoryModel();
1840  historyView = new HistoryView(historyModel);
1841  pageState = new PageState(historyModel, historyView);
1842
1843  // Create default view.
1844  var hashData = pageState.getHashData();
1845  var page = parseInt(hashData.page, 10) || historyView.getPage();
1846  var range = /** @type {HistoryModel.Range} */(parseInt(hashData.range, 10)) ||
1847      historyView.getRangeInDays();
1848  var offset = parseInt(hashData.offset, 10) || historyView.getOffset();
1849  historyView.setPageState(hashData.q, page, range, offset);
1850
1851  if ($('overlay')) {
1852    cr.ui.overlay.setupOverlay($('overlay'));
1853    cr.ui.overlay.globalInitialization();
1854  }
1855  HistoryFocusManager.getInstance().initialize();
1856
1857  var doSearch = function(e) {
1858    recordUmaAction('HistoryPage_Search');
1859    historyView.setSearch(searchField.value);
1860
1861    if (isMobileVersion())
1862      searchField.blur();  // Dismiss the keyboard.
1863  };
1864
1865  var mayRemoveVisits = loadTimeData.getBoolean('allowDeletingHistory');
1866  $('remove-visit').disabled = !mayRemoveVisits;
1867
1868  if (mayRemoveVisits) {
1869    $('remove-visit').addEventListener('activate', function(e) {
1870      activeVisit.removeFromHistory();
1871      activeVisit = null;
1872    });
1873  }
1874
1875  if (!loadTimeData.getBoolean('showDeleteVisitUI'))
1876    $('remove-visit').hidden = true;
1877
1878  searchField.addEventListener('search', doSearch);
1879  $('search-button').addEventListener('click', doSearch);
1880
1881  $('more-from-site').addEventListener('activate', function(e) {
1882    activeVisit.showMoreFromSite_();
1883    activeVisit = null;
1884  });
1885
1886  // Only show the controls if the command line switch is activated.
1887  if (loadTimeData.getBoolean('groupByDomain') ||
1888      loadTimeData.getBoolean('isSupervisedProfile')) {
1889    // Hide the top container which has the "Clear browsing data" and "Remove
1890    // selected entries" buttons since they're unavailable for supervised users.
1891    $('top-container').hidden = true;
1892    $('history-page').classList.add('big-topbar-page');
1893    $('filter-controls').hidden = false;
1894  }
1895
1896  uber.setTitle(loadTimeData.getString('title'));
1897
1898  // Adjust the position of the notification bar when the window size changes.
1899  window.addEventListener('resize',
1900      historyView.positionNotificationBar.bind(historyView));
1901
1902  if (isMobileVersion()) {
1903    // Move the search box out of the header.
1904    var resultsDisplay = $('results-display');
1905    resultsDisplay.parentNode.insertBefore($('search-field'), resultsDisplay);
1906
1907    window.addEventListener(
1908        'resize', historyView.updateClearBrowsingDataButton_);
1909
1910    // When the search field loses focus, add a delay before updating the
1911    // visibility, otherwise the button will flash on the screen before the
1912    // keyboard animates away.
1913    searchField.addEventListener('blur', function() {
1914      setTimeout(historyView.updateClearBrowsingDataButton_, 250);
1915    });
1916
1917    // Move the button to the bottom of the page.
1918    $('history-page').appendChild($('clear-browsing-data'));
1919  } else {
1920    window.addEventListener('message', function(e) {
1921      e = /** @type {!MessageEvent.<!{method: string}>} */(e);
1922      if (e.data.method == 'frameSelected')
1923        searchField.focus();
1924    });
1925    searchField.focus();
1926  }
1927
1928<if expr="is_ios">
1929  function checkKeyboardVisibility() {
1930    // Figure out the real height based on the orientation, becauase
1931    // screen.width and screen.height don't update after rotation.
1932    var screenHeight = window.orientation % 180 ? screen.width : screen.height;
1933
1934    // Assume that the keyboard is visible if more than 30% of the screen is
1935    // taken up by window chrome.
1936    var isKeyboardVisible = (window.innerHeight / screenHeight) < 0.7;
1937
1938    document.body.classList.toggle('ios-keyboard-visible', isKeyboardVisible);
1939  }
1940  window.addEventListener('orientationchange', checkKeyboardVisibility);
1941  window.addEventListener('resize', checkKeyboardVisibility);
1942</if> /* is_ios */
1943}
1944
1945/**
1946 * Updates the filter status labels of a host/URL entry to the current value.
1947 * @param {Element} statusElement The div which contains the status labels.
1948 * @param {SupervisedUserFilteringBehavior} newStatus The filter status of the
1949 *     current domain/URL.
1950 */
1951function updateHostStatus(statusElement, newStatus) {
1952  var filteringBehaviorDiv =
1953      statusElement.querySelector('.filtering-behavior');
1954  // Reset to the base class first, then add modifier classes if needed.
1955  filteringBehaviorDiv.className = 'filtering-behavior';
1956  if (newStatus == SupervisedUserFilteringBehavior.BLOCK) {
1957    filteringBehaviorDiv.textContent =
1958        loadTimeData.getString('filterBlocked');
1959    filteringBehaviorDiv.classList.add('filter-blocked');
1960  } else {
1961    filteringBehaviorDiv.textContent = '';
1962  }
1963}
1964
1965/**
1966 * Click handler for the 'Clear browsing data' dialog.
1967 * @param {Event} e The click event.
1968 */
1969function openClearBrowsingData(e) {
1970  recordUmaAction('HistoryPage_InitClearBrowsingData');
1971  chrome.send('clearBrowsingData');
1972}
1973
1974/**
1975 * Shows the dialog for the user to confirm removal of selected history entries.
1976 */
1977function showConfirmationOverlay() {
1978  $('alertOverlay').classList.add('showing');
1979  $('overlay').hidden = false;
1980  $('history-page').setAttribute('aria-hidden', 'true');
1981  uber.invokeMethodOnParent('beginInterceptingEvents');
1982
1983  // If an element is focused behind the confirm overlay, blur it so focus
1984  // doesn't accidentally get stuck behind it.
1985  if ($('history-page').contains(document.activeElement))
1986    document.activeElement.blur();
1987}
1988
1989/**
1990 * Hides the confirmation overlay used to confirm selected history entries.
1991 */
1992function hideConfirmationOverlay() {
1993  $('alertOverlay').classList.remove('showing');
1994  $('overlay').hidden = true;
1995  $('history-page').removeAttribute('aria-hidden');
1996  uber.invokeMethodOnParent('stopInterceptingEvents');
1997}
1998
1999/**
2000 * Shows the confirmation alert for history deletions and permits browser tests
2001 * to override the dialog.
2002 * @param {function()=} okCallback A function to be called when the user presses
2003 *     the ok button.
2004 * @param {function()=} cancelCallback A function to be called when the user
2005 *     presses the cancel button.
2006 */
2007function confirmDeletion(okCallback, cancelCallback) {
2008  alertOverlay.setValues(
2009      loadTimeData.getString('removeSelected'),
2010      loadTimeData.getString('deleteWarning'),
2011      loadTimeData.getString('deleteConfirm'),
2012      loadTimeData.getString('cancel'),
2013      okCallback,
2014      cancelCallback);
2015  showConfirmationOverlay();
2016}
2017
2018/**
2019 * Click handler for the 'Remove selected items' button.
2020 * Confirms the deletion with the user, and then deletes the selected visits.
2021 */
2022function removeItems() {
2023  recordUmaAction('HistoryPage_RemoveSelected');
2024  if (!loadTimeData.getBoolean('allowDeletingHistory'))
2025    return;
2026
2027  var checked = $('results-display').querySelectorAll(
2028      '.entry-box input[type=checkbox]:checked:not([disabled])');
2029  var disabledItems = [];
2030  var toBeRemoved = [];
2031
2032  for (var i = 0; i < checked.length; i++) {
2033    var checkbox = checked[i];
2034    var entry = findAncestorByClass(checkbox, 'entry');
2035    toBeRemoved.push(entry.visit);
2036
2037    // Disable the checkbox and put a strikethrough style on the link, so the
2038    // user can see what will be deleted.
2039    checkbox.disabled = true;
2040    entry.visit.titleLink.classList.add('to-be-removed');
2041    disabledItems.push(checkbox);
2042    var integerId = parseInt(entry.visit.id_, 10);
2043    // Record the ID of the entry to signify how many entries are above this
2044    // link on the page.
2045    recordUmaHistogram('HistoryPage.RemoveEntryPosition',
2046                       UMA_MAX_BUCKET_VALUE,
2047                       integerId);
2048    if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
2049      recordUmaHistogram('HistoryPage.RemoveEntryPositionSubset',
2050                         UMA_MAX_SUBSET_BUCKET_VALUE,
2051                         integerId);
2052    }
2053    if (entry.parentNode.className == 'search-results')
2054      recordUmaAction('HistoryPage_SearchResultRemove');
2055  }
2056
2057  function onConfirmRemove() {
2058    recordUmaAction('HistoryPage_ConfirmRemoveSelected');
2059    historyModel.removeVisitsFromHistory(toBeRemoved,
2060        historyView.reload.bind(historyView));
2061    $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
2062    hideConfirmationOverlay();
2063  }
2064
2065  function onCancelRemove() {
2066    recordUmaAction('HistoryPage_CancelRemoveSelected');
2067    // Return everything to its previous state.
2068    for (var i = 0; i < disabledItems.length; i++) {
2069      var checkbox = disabledItems[i];
2070      checkbox.disabled = false;
2071
2072      var entry = findAncestorByClass(checkbox, 'entry');
2073      entry.visit.titleLink.classList.remove('to-be-removed');
2074    }
2075    $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
2076    hideConfirmationOverlay();
2077  }
2078
2079  if (checked.length) {
2080    confirmDeletion(onConfirmRemove, onCancelRemove);
2081    $('overlay').addEventListener('cancelOverlay', onCancelRemove);
2082  }
2083}
2084
2085/**
2086 * Handler for the 'click' event on a checkbox.
2087 * @param {Event} e The click event.
2088 */
2089function checkboxClicked(e) {
2090  handleCheckboxStateChange(/** @type {!HTMLInputElement} */(e.currentTarget),
2091                            e.shiftKey);
2092}
2093
2094/**
2095 * Post-process of checkbox state change. This handles range selection and
2096 * updates internal state.
2097 * @param {!HTMLInputElement} checkbox Clicked checkbox.
2098 * @param {boolean} shiftKey true if shift key is pressed.
2099 */
2100function handleCheckboxStateChange(checkbox, shiftKey) {
2101  updateParentCheckbox(checkbox);
2102  var id = Number(checkbox.id.slice('checkbox-'.length));
2103  // Handle multi-select if shift was pressed.
2104  if (shiftKey && (selectionAnchor != -1)) {
2105    var checked = checkbox.checked;
2106    // Set all checkboxes from the anchor up to the clicked checkbox to the
2107    // state of the clicked one.
2108    var begin = Math.min(id, selectionAnchor);
2109    var end = Math.max(id, selectionAnchor);
2110    for (var i = begin; i <= end; i++) {
2111      var ithCheckbox = document.querySelector('#checkbox-' + i);
2112      if (ithCheckbox) {
2113        ithCheckbox.checked = checked;
2114        updateParentCheckbox(ithCheckbox);
2115      }
2116    }
2117  }
2118  selectionAnchor = id;
2119
2120  historyView.updateSelectionEditButtons();
2121}
2122
2123/**
2124 * Handler for the 'click' event on a domain checkbox. Checkes or unchecks the
2125 * checkboxes of the visits to this domain in the respective group.
2126 * @param {Event} e The click event.
2127 */
2128function domainCheckboxClicked(e) {
2129  var siteEntry = findAncestorByClass(/** @type {Element} */(e.currentTarget),
2130                                      'site-entry');
2131  var checkboxes =
2132      siteEntry.querySelectorAll('.site-results input[type=checkbox]');
2133  for (var i = 0; i < checkboxes.length; i++)
2134    checkboxes[i].checked = e.currentTarget.checked;
2135  historyView.updateSelectionEditButtons();
2136  // Stop propagation as clicking the checkbox would otherwise trigger the
2137  // group to collapse/expand.
2138  e.stopPropagation();
2139}
2140
2141/**
2142 * Updates the domain checkbox for this visit checkbox if it has been
2143 * unchecked.
2144 * @param {Element} checkbox The checkbox that has been clicked.
2145 */
2146function updateParentCheckbox(checkbox) {
2147  if (checkbox.checked)
2148    return;
2149
2150  var entry = findAncestorByClass(checkbox, 'site-entry');
2151  if (!entry)
2152    return;
2153
2154  var groupCheckbox = entry.querySelector('.site-domain-wrapper input');
2155  if (groupCheckbox)
2156    groupCheckbox.checked = false;
2157}
2158
2159function entryBoxMousedown(event) {
2160  // Prevent text selection when shift-clicking to select multiple entries.
2161  if (event.shiftKey)
2162    event.preventDefault();
2163}
2164
2165/**
2166 * Handle click event for entryBoxes.
2167 * @param {!Event} event A click event.
2168 */
2169function entryBoxClick(event) {
2170  event = /** @type {!MouseEvent} */(event);
2171  // Do nothing if a bookmark star is clicked.
2172  if (event.defaultPrevented)
2173    return;
2174  var element = event.target;
2175  // Do nothing if the event happened in an interactive element.
2176  for (; element != event.currentTarget; element = element.parentNode) {
2177    switch (element.tagName) {
2178      case 'A':
2179      case 'BUTTON':
2180      case 'INPUT':
2181        return;
2182    }
2183  }
2184  var checkbox = assertInstanceof($(event.currentTarget.getAttribute('for')),
2185                                  HTMLInputElement);
2186  checkbox.checked = !checkbox.checked;
2187  handleCheckboxStateChange(checkbox, event.shiftKey);
2188  // We don't want to focus on the checkbox.
2189  event.preventDefault();
2190}
2191
2192/**
2193 * Called when an individual history entry has been removed from the page.
2194 * This will only be called when all the elements affected by the deletion
2195 * have been removed from the DOM and the animations have completed.
2196 */
2197function onEntryRemoved() {
2198  historyView.onEntryRemoved();
2199}
2200
2201/**
2202 * Triggers a fade-out animation, and then removes |node| from the DOM.
2203 * @param {Node} node The node to be removed.
2204 * @param {Function?} onRemove A function to be called after the node
2205 *     has been removed from the DOM.
2206 * @param {*=} opt_scope An optional scope object to call |onRemove| with.
2207 */
2208function removeNode(node, onRemove, opt_scope) {
2209  node.classList.add('fade-out'); // Trigger CSS fade out animation.
2210
2211  // Delete the node when the animation is complete.
2212  node.addEventListener('webkitTransitionEnd', function(e) {
2213    node.parentNode.removeChild(node);
2214
2215    // In case there is nested deletion happening, prevent this event from
2216    // being handled by listeners on ancestor nodes.
2217    e.stopPropagation();
2218
2219    if (onRemove)
2220      onRemove.call(opt_scope);
2221  });
2222}
2223
2224/**
2225 * Builds the DOM elements to show the filtering status of a domain/URL.
2226 * @param {SupervisedUserFilteringBehavior} filteringBehavior The filter
2227 *     behavior for this item.
2228 * @return {Element} Returns the DOM elements which show the status.
2229 */
2230function getFilteringStatusDOM(filteringBehavior) {
2231  var filterStatusDiv = createElementWithClassName('div', 'filter-status');
2232  var filteringBehaviorDiv =
2233      createElementWithClassName('div', 'filtering-behavior');
2234  filterStatusDiv.appendChild(filteringBehaviorDiv);
2235
2236  updateHostStatus(filterStatusDiv, filteringBehavior);
2237  return filterStatusDiv;
2238}
2239
2240
2241///////////////////////////////////////////////////////////////////////////////
2242// Chrome callbacks:
2243
2244/**
2245 * Our history system calls this function with results from searches.
2246 * @param {HistoryQuery} info An object containing information about the query.
2247 * @param {Array.<HistoryEntry>} results A list of results.
2248 */
2249function historyResult(info, results) {
2250  historyModel.addResults(info, results);
2251}
2252
2253/**
2254 * Called by the history backend when history removal is successful.
2255 */
2256function deleteComplete() {
2257  historyModel.deleteComplete();
2258}
2259
2260/**
2261 * Called by the history backend when history removal is unsuccessful.
2262 */
2263function deleteFailed() {
2264  window.console.log('Delete failed');
2265}
2266
2267/**
2268 * Called when the history is deleted by someone else.
2269 */
2270function historyDeleted() {
2271  var anyChecked = document.querySelector('.entry input:checked') != null;
2272  // Reload the page, unless the user has any items checked.
2273  // TODO(dubroy): We should just reload the page & restore the checked items.
2274  if (!anyChecked)
2275    historyView.reload();
2276}
2277
2278// Add handlers to HTML elements.
2279document.addEventListener('DOMContentLoaded', load);
2280
2281// This event lets us enable and disable menu items before the menu is shown.
2282document.addEventListener('canExecute', function(e) {
2283  e.canExecute = true;
2284});
2285