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