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. 25 var historyModel; 26 var historyView; 27 var pageState; 28 var selectionAnchor = -1; 29 var 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 */ 41 var 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 */ 66 var 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 */ 78 var HistoryQuery; 79 80 MenuButton.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 */ 86 function 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 */ 94 function 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 */ 105 function 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 */ 123 function 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 */ 180 Visit.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 */ 337 Visit.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 346 Object.defineProperty(Visit.prototype, 'checkBox', { 347 get: /** @this {Visit} */function() { 348 return this.domNode_.querySelector('input[type=checkbox]'); 349 }, 350 }); 351 352 Object.defineProperty(Visit.prototype, 'bookmarkStar', { 353 get: /** @this {Visit} */function() { 354 return this.domNode_.querySelector('.bookmark-section.starred'); 355 }, 356 }); 357 358 Object.defineProperty(Visit.prototype, 'titleLink', { 359 get: /** @this {Visit} */function() { 360 return this.domNode_.querySelector('.title a'); 361 }, 362 }); 363 364 Object.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 */ 383 Visit.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 */ 410 Visit.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 */ 455 Visit.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 */ 469 Visit.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 */ 480 Visit.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 */ 490 Visit.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 */ 503 Visit.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 */ 520 Visit.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 */ 539 function HistoryModel() { 540 this.clearModel_(); 541 } 542 543 // HistoryModel, Public: ------------------------------------------------------ 544 545 /** @enum {number} */ 546 HistoryModel.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 */ 556 HistoryModel.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 */ 564 HistoryModel.prototype.getView = function() { 565 return this.view_; 566 }; 567 568 /** 569 * Reload our model with the current parameters. 570 */ 571 HistoryModel.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 */ 591 HistoryModel.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 */ 600 HistoryModel.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 */ 610 HistoryModel.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 */ 644 HistoryModel.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 */ 654 HistoryModel.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 */ 662 HistoryModel.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 */ 673 HistoryModel.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. */ 689 HistoryModel.prototype.isDeletingVisits = function() { 690 return !!this.deleteCompleteCallback_; 691 }; 692 693 /** 694 * Called when visits have been succesfully removed from the history. 695 */ 696 HistoryModel.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_. 703 Object.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 */ 719 Object.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_. 729 Object.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 */ 739 HistoryModel.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 */ 751 HistoryModel.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 */ 795 HistoryModel.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 */ 813 HistoryModel.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 */ 834 HistoryModel.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 */ 844 HistoryModel.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 */ 852 HistoryModel.prototype.getGroupByDomain = function() { 853 return this.groupByDomain_; 854 }; 855 856 /////////////////////////////////////////////////////////////////////////////// 857 // HistoryFocusObserver: 858 859 /** 860 * @constructor 861 * @implements {cr.ui.FocusRow.Observer} 862 */ 863 function HistoryFocusObserver() {} 864 865 HistoryFocusObserver.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 */ 897 function 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 */ 972 HistoryView.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 */ 980 HistoryView.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 */ 994 HistoryView.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 */ 1015 HistoryView.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 */ 1031 HistoryView.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 */ 1040 HistoryView.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 */ 1049 HistoryView.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 */ 1057 HistoryView.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 */ 1071 HistoryView.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 */ 1080 HistoryView.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 */ 1102 HistoryView.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 */ 1117 HistoryView.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 */ 1137 HistoryView.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. */ 1151 HistoryView.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. */ 1161 HistoryView.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 */ 1170 HistoryView.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 */ 1225 HistoryView.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 */ 1235 HistoryView.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 */ 1255 HistoryView.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 */ 1275 HistoryView.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 */ 1289 HistoryView.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 */ 1361 HistoryView.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 */ 1395 HistoryView.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 */ 1425 HistoryView.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 */ 1445 HistoryView.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 */ 1490 HistoryView.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 */ 1511 HistoryView.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 1598 var focusGridRowSelector = [ 1599 '.day-results > .entry:not(.fade-out)', 1600 '.expand .grouped .entry:not(.fade-out)', 1601 '.site-domain-wrapper' 1602 ].join(', '); 1603 1604 var 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 */ 1614 HistoryView.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 */ 1630 HistoryView.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 */ 1649 HistoryView.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 */ 1663 HistoryView.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 */ 1692 HistoryView.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 */ 1725 function 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 */ 1757 PageState.instance = null; 1758 1759 /** 1760 * @return {Object} An object containing parameters from our window hash. 1761 */ 1762 PageState.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 */ 1792 PageState.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 */ 1810 PageState.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 */ 1834 function 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 */ 1951 function 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 */ 1969 function 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 */ 1977 function 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 */ 1992 function 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 */ 2007 function 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 */ 2022 function 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 */ 2089 function 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 */ 2100 function 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 */ 2128 function 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 */ 2146 function 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 2159 function 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 */ 2169 function 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 */ 2197 function 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 */ 2208 function 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 */ 2230 function 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 */ 2249 function historyResult(info, results) { 2250 historyModel.addResults(info, results); 2251 } 2252 2253 /** 2254 * Called by the history backend when history removal is successful. 2255 */ 2256 function deleteComplete() { 2257 historyModel.deleteComplete(); 2258 } 2259 2260 /** 2261 * Called by the history backend when history removal is unsuccessful. 2262 */ 2263 function deleteFailed() { 2264 window.console.log('Delete failed'); 2265 } 2266 2267 /** 2268 * Called when the history is deleted by someone else. 2269 */ 2270 function 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. 2279 document.addEventListener('DOMContentLoaded', load); 2280 2281 // This event lets us enable and disable menu items before the menu is shown. 2282 document.addEventListener('canExecute', function(e) { 2283 e.canExecute = true; 2284 }); 2285