| // Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| <include src="../uber/uber_utils.js"> |
| <include src="history_focus_manager.js"> |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // Globals: |
| /** @const */ var RESULTS_PER_PAGE = 150; |
| |
| // Amount of time between pageviews that we consider a 'break' in browsing, |
| // measured in milliseconds. |
| /** @const */ var BROWSING_GAP_TIME = 15 * 60 * 1000; |
| |
| // The largest bucket value for UMA histogram, based on entry ID. All entries |
| // with IDs greater than this will be included in this bucket. |
| /** @const */ var UMA_MAX_BUCKET_VALUE = 1000; |
| |
| // The largest bucket value for a UMA histogram that is a subset of above. |
| /** @const */ var UMA_MAX_SUBSET_BUCKET_VALUE = 100; |
| |
| // TODO(glen): Get rid of these global references, replace with a controller |
| // or just make the classes own more of the page. |
| var historyModel; |
| var historyView; |
| var pageState; |
| var selectionAnchor = -1; |
| var activeVisit = null; |
| |
| /** @const */ var Command = cr.ui.Command; |
| /** @const */ var Menu = cr.ui.Menu; |
| /** @const */ var MenuButton = cr.ui.MenuButton; |
| |
| /** |
| * Enum that shows the filtering behavior for a host or URL to a managed user. |
| * Must behave like the FilteringBehavior enum from managed_mode_url_filter.h. |
| * @enum {number} |
| */ |
| ManagedModeFilteringBehavior = { |
| ALLOW: 0, |
| WARN: 1, |
| BLOCK: 2 |
| }; |
| |
| MenuButton.createDropDownArrows(); |
| |
| /** |
| * Returns true if the mobile (non-desktop) version is being shown. |
| * @return {boolean} true if the mobile version is being shown. |
| */ |
| function isMobileVersion() { |
| return !document.body.classList.contains('uber-frame'); |
| } |
| |
| /** |
| * Record an action in UMA. |
| * @param {string} actionDesc The name of the action to be logged. |
| */ |
| function recordUmaAction(actionDesc) { |
| chrome.send('metricsHandler:recordAction', [actionDesc]); |
| } |
| |
| /** |
| * Record a histogram value in UMA. If specified value is larger than the max |
| * bucket value, record the value in the largest bucket. |
| * @param {string} histogram The name of the histogram to be recorded in. |
| * @param {integer} maxBucketValue The max value for the last histogram bucket. |
| * @param {integer} value The value to record in the histogram. |
| */ |
| function recordUmaHistogram(histogram, maxBucketValue, value) { |
| chrome.send('metricsHandler:recordInHistogram', |
| [histogram, |
| ((value > maxBucketValue) ? maxBucketValue : value), |
| maxBucketValue]); |
| } |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // Visit: |
| |
| /** |
| * Class to hold all the information about an entry in our model. |
| * @param {Object} result An object containing the visit's data. |
| * @param {boolean} continued Whether this visit is on the same day as the |
| * visit before it. |
| * @param {HistoryModel} model The model object this entry belongs to. |
| * @constructor |
| */ |
| function Visit(result, continued, model) { |
| this.model_ = model; |
| this.title_ = result.title; |
| this.url_ = result.url; |
| this.domain_ = result.domain; |
| this.starred_ = result.starred; |
| |
| // These identify the name and type of the device on which this visit |
| // occurred. They will be empty if the visit occurred on the current device. |
| this.deviceName = result.deviceName; |
| this.deviceType = result.deviceType; |
| |
| // The ID will be set according to when the visit was displayed, not |
| // received. Set to -1 to show that it has not been set yet. |
| this.id_ = -1; |
| |
| this.isRendered = false; // Has the visit already been rendered on the page? |
| |
| // All the date information is public so that owners can compare properties of |
| // two items easily. |
| |
| this.date = new Date(result.time); |
| |
| // See comment in BrowsingHistoryHandler::QueryComplete - we won't always |
| // get all of these. |
| this.dateRelativeDay = result.dateRelativeDay || ''; |
| this.dateTimeOfDay = result.dateTimeOfDay || ''; |
| this.dateShort = result.dateShort || ''; |
| |
| // Shows the filtering behavior for that host (only used for managed users). |
| // A value of |ManagedModeFilteringBehavior.ALLOW| is not displayed so it is |
| // used as the default value. |
| this.hostFilteringBehavior = ManagedModeFilteringBehavior.ALLOW; |
| if (typeof result.hostFilteringBehavior != 'undefined') |
| this.hostFilteringBehavior = result.hostFilteringBehavior; |
| |
| this.blockedVisit = result.blockedVisit || false; |
| |
| // Whether this is the continuation of a previous day. |
| this.continued = continued; |
| |
| this.allTimestamps = result.allTimestamps; |
| } |
| |
| // Visit, public: ------------------------------------------------------------- |
| |
| /** |
| * Returns a dom structure for a browse page result or a search page result. |
| * @param {Object} propertyBag A bag of configuration properties, false by |
| * default: |
| * - isSearchResult: Whether or not the result is a search result. |
| * - addTitleFavicon: Whether or not the favicon should be added. |
| * - useMonthDate: Whether or not the full date should be inserted (used for |
| * monthly view). |
| * @return {Node} A DOM node to represent the history entry or search result. |
| */ |
| Visit.prototype.getResultDOM = function(propertyBag) { |
| var isSearchResult = propertyBag.isSearchResult || false; |
| var addTitleFavicon = propertyBag.addTitleFavicon || false; |
| var useMonthDate = propertyBag.useMonthDate || false; |
| var node = createElementWithClassName('li', 'entry'); |
| var time = createElementWithClassName('div', 'time'); |
| var entryBox = createElementWithClassName('label', 'entry-box'); |
| var domain = createElementWithClassName('div', 'domain'); |
| |
| this.id_ = this.model_.nextVisitId_++; |
| var self = this; |
| |
| // Only create the checkbox if it can be used either to delete an entry or to |
| // block/allow it. |
| if (this.model_.editingEntriesAllowed) { |
| var checkbox = document.createElement('input'); |
| checkbox.type = 'checkbox'; |
| checkbox.id = 'checkbox-' + this.id_; |
| checkbox.time = this.date.getTime(); |
| checkbox.tabIndex = -1; |
| checkbox.addEventListener('click', checkboxClicked); |
| entryBox.appendChild(checkbox); |
| |
| // Clicking anywhere in the entryBox will check/uncheck the checkbox. |
| entryBox.setAttribute('for', checkbox.id); |
| entryBox.addEventListener('mousedown', entryBoxMousedown); |
| entryBox.addEventListener('click', entryBoxClick); |
| entryBox.addEventListener('keydown', this.handleKeydown_.bind(this)); |
| } |
| |
| // Keep track of the drop down that triggered the menu, so we know |
| // which element to apply the command to. |
| // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it. |
| var setActiveVisit = function(e) { |
| activeVisit = self; |
| var menu = $('action-menu'); |
| menu.dataset.devicename = self.deviceName; |
| menu.dataset.devicetype = self.deviceType; |
| }; |
| domain.textContent = this.domain_; |
| |
| entryBox.appendChild(time); |
| |
| var bookmarkSection = createElementWithClassName('div', 'bookmark-section'); |
| if (this.starred_) { |
| bookmarkSection.classList.add('starred'); |
| bookmarkSection.addEventListener('click', function f(e) { |
| recordUmaAction('HistoryPage_BookmarkStarClicked'); |
| bookmarkSection.classList.remove('starred'); |
| chrome.send('removeBookmark', [self.url_]); |
| bookmarkSection.removeEventListener('click', f); |
| e.preventDefault(); |
| }); |
| } |
| entryBox.appendChild(bookmarkSection); |
| |
| var visitEntryWrapper = entryBox.appendChild(document.createElement('div')); |
| if (addTitleFavicon || this.blockedVisit) |
| visitEntryWrapper.classList.add('visit-entry'); |
| if (this.blockedVisit) { |
| visitEntryWrapper.classList.add('blocked-indicator'); |
| visitEntryWrapper.appendChild(this.getVisitAttemptDOM_()); |
| } else { |
| visitEntryWrapper.appendChild(this.getTitleDOM_(isSearchResult)); |
| if (addTitleFavicon) |
| this.addFaviconToElement_(visitEntryWrapper); |
| visitEntryWrapper.appendChild(domain); |
| } |
| |
| if (isMobileVersion()) { |
| var removeButton = createElementWithClassName('button', 'remove-entry'); |
| removeButton.setAttribute('aria-label', |
| loadTimeData.getString('removeFromHistory')); |
| removeButton.classList.add('custom-appearance'); |
| removeButton.addEventListener( |
| 'click', this.removeEntryFromHistory_.bind(this)); |
| entryBox.appendChild(removeButton); |
| |
| // Support clicking anywhere inside the entry box. |
| entryBox.addEventListener('click', function(e) { |
| self.titleLink.click(); |
| }); |
| } else { |
| var dropDown = createElementWithClassName('button', 'drop-down'); |
| dropDown.value = 'Open action menu'; |
| dropDown.title = loadTimeData.getString('actionMenuDescription'); |
| dropDown.tabIndex = -1; |
| dropDown.setAttribute('menu', '#action-menu'); |
| dropDown.setAttribute('aria-haspopup', 'true'); |
| |
| cr.ui.decorate(dropDown, MenuButton); |
| dropDown.respondToArrowKeys = false; |
| |
| dropDown.addEventListener('mousedown', setActiveVisit); |
| dropDown.addEventListener('focus', setActiveVisit); |
| |
| // Prevent clicks on the drop down from affecting the checkbox. We need to |
| // call blur() explicitly because preventDefault() cancels any focus |
| // handling. |
| dropDown.addEventListener('click', function(e) { |
| e.preventDefault(); |
| document.activeElement.blur(); |
| }); |
| entryBox.appendChild(dropDown); |
| } |
| |
| // Let the entryBox be styled appropriately when it contains keyboard focus. |
| entryBox.addEventListener('focus', function() { |
| this.classList.add('contains-focus'); |
| }, true); |
| entryBox.addEventListener('blur', function() { |
| this.classList.remove('contains-focus'); |
| }, true); |
| |
| var entryBoxContainer = |
| createElementWithClassName('div', 'entry-box-container'); |
| node.appendChild(entryBoxContainer); |
| entryBoxContainer.appendChild(entryBox); |
| |
| if (isSearchResult || useMonthDate) { |
| // Show the day instead of the time. |
| time.appendChild(document.createTextNode(this.dateShort)); |
| } else { |
| time.appendChild(document.createTextNode(this.dateTimeOfDay)); |
| } |
| |
| this.domNode_ = node; |
| node.visit = this; |
| |
| return node; |
| }; |
| |
| /** |
| * Remove this visit from the history. |
| */ |
| Visit.prototype.removeFromHistory = function() { |
| recordUmaAction('HistoryPage_EntryMenuRemoveFromHistory'); |
| this.model_.removeVisitsFromHistory([this], function() { |
| this.model_.getView().removeVisit(this); |
| }.bind(this)); |
| }; |
| |
| /** |
| * @param {boolean} isLead Whether this visit is the "lead" visit, i.e. the one |
| * that would be focused if the entry list is tabbed to. |
| */ |
| Visit.prototype.setIsLead = function(isLead) { |
| this.domNode_.querySelector('.entry-box').classList.toggle('lead', isLead); |
| if (!isLead) { |
| if (this.checkBox) |
| this.checkBox.tabIndex = -1; |
| |
| this.titleLink.tabIndex = -1; |
| |
| if (this.dropDown) |
| this.dropDown.tabIndex = -1; |
| } |
| }; |
| |
| /** |
| * @param {Element} control A control element to focus. |
| */ |
| Visit.prototype.focusControl = function(control) { |
| var controls = [this.titleLink]; |
| |
| if (this.checkBox) |
| controls.push(this.checkBox); |
| |
| if (this.dropDown) |
| controls.push(this.dropDown); |
| |
| assert(controls.indexOf(control) >= 0); |
| |
| for (var i = 0; i < controls.length; ++i) { |
| controls[i].tabIndex = controls[i] == control ? 0 : -1; |
| } |
| |
| control.focus(); |
| this.setIsLead(true); |
| }; |
| |
| Object.defineProperty(Visit.prototype, 'checkBox', { |
| get: function() { |
| return this.domNode_.querySelector('input[type=checkbox]'); |
| }, |
| }); |
| |
| Object.defineProperty(Visit.prototype, 'titleLink', { |
| get: function() { |
| return this.domNode_.querySelector('.title a'); |
| }, |
| }); |
| |
| Object.defineProperty(Visit.prototype, 'dropDown', { |
| get: function() { |
| return this.domNode_.querySelector('button.drop-down'); |
| }, |
| }); |
| |
| // Visit, private: ------------------------------------------------------------ |
| |
| /** |
| * Add child text nodes to a node such that occurrences of the specified text is |
| * highlighted. |
| * @param {Node} node The node under which new text nodes will be made as |
| * children. |
| * @param {string} content Text to be added beneath |node| as one or more |
| * text nodes. |
| * @param {string} highlightText Occurences of this text inside |content| will |
| * be highlighted. |
| * @private |
| */ |
| Visit.prototype.addHighlightedText_ = function(node, content, highlightText) { |
| var i = 0; |
| if (highlightText) { |
| var re = new RegExp(Visit.pregQuote_(highlightText), 'gim'); |
| var match; |
| while (match = re.exec(content)) { |
| if (match.index > i) |
| node.appendChild(document.createTextNode(content.slice(i, |
| match.index))); |
| i = re.lastIndex; |
| // Mark the highlighted text in bold. |
| var b = document.createElement('b'); |
| b.textContent = content.substring(match.index, i); |
| node.appendChild(b); |
| } |
| } |
| if (i < content.length) |
| node.appendChild(document.createTextNode(content.slice(i))); |
| }; |
| |
| /** |
| * Returns the DOM element containing a link on the title of the URL for the |
| * current visit. |
| * @param {boolean} isSearchResult Whether or not the entry is a search result. |
| * @return {Element} DOM representation for the title block. |
| * @private |
| */ |
| Visit.prototype.getTitleDOM_ = function(isSearchResult) { |
| var node = createElementWithClassName('div', 'title'); |
| var link = document.createElement('a'); |
| link.href = this.url_; |
| link.id = 'id-' + this.id_; |
| link.target = '_top'; |
| link.tabIndex = -1; |
| var integerId = parseInt(this.id_, 10); |
| link.addEventListener('click', function() { |
| recordUmaAction('HistoryPage_EntryLinkClick'); |
| // Record the ID of the entry to signify how many entries are above this |
| // link on the page. |
| recordUmaHistogram('HistoryPage.ClickPosition', |
| UMA_MAX_BUCKET_VALUE, |
| integerId); |
| if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) { |
| recordUmaHistogram('HistoryPage.ClickPositionSubset', |
| UMA_MAX_SUBSET_BUCKET_VALUE, |
| integerId); |
| } |
| }); |
| link.addEventListener('contextmenu', function() { |
| recordUmaAction('HistoryPage_EntryLinkRightClick'); |
| }); |
| |
| if (isSearchResult) { |
| link.addEventListener('click', function() { |
| recordUmaAction('HistoryPage_SearchResultClick'); |
| }); |
| } |
| |
| // Add a tooltip, since it might be ellipsized. |
| // TODO(dubroy): Find a way to show the tooltip only when necessary. |
| link.title = this.title_; |
| |
| this.addHighlightedText_(link, this.title_, this.model_.getSearchText()); |
| node.appendChild(link); |
| |
| return node; |
| }; |
| |
| /** |
| * Returns the DOM element containing the text for a blocked visit attempt. |
| * @return {Element} DOM representation of the visit attempt. |
| * @private |
| */ |
| Visit.prototype.getVisitAttemptDOM_ = function() { |
| var node = createElementWithClassName('div', 'title'); |
| node.innerHTML = loadTimeData.getStringF('blockedVisitText', |
| this.url_, |
| this.id_, |
| this.domain_); |
| return node; |
| }; |
| |
| /** |
| * Set the favicon for an element. |
| * @param {Element} el The DOM element to which to add the icon. |
| * @private |
| */ |
| Visit.prototype.addFaviconToElement_ = function(el) { |
| var url = isMobileVersion() ? |
| getFaviconImageSet(this.url_, 32, 'touch-icon') : |
| getFaviconImageSet(this.url_); |
| el.style.backgroundImage = url; |
| }; |
| |
| /** |
| * Launch a search for more history entries from the same domain. |
| * @private |
| */ |
| Visit.prototype.showMoreFromSite_ = function() { |
| recordUmaAction('HistoryPage_EntryMenuShowMoreFromSite'); |
| historyView.setSearch(this.domain_); |
| $('search-field').focus(); |
| }; |
| |
| /** |
| * @param {Event} e A keydown event to handle. |
| * @private |
| */ |
| Visit.prototype.handleKeydown_ = function(e) { |
| var keyCode = e.keyCode; |
| if (keyCode == 8 || keyCode == 46) { // Delete or Backspace. |
| if (!this.model_.isDeletingVisits()) |
| this.removeEntryFromHistory_(e); |
| return; |
| } |
| |
| var target = e.target; |
| if (target != document.activeElement || |
| !(keyCode == 37 || keyCode == 39) || // Left or right. |
| (keyCode == 37 && target == this.checkBox) || |
| (keyCode == 39 && target == this.dropDown)) { |
| return; |
| } |
| |
| var toFocus; |
| if (e.keyCode == 37) // Left. |
| toFocus = target == this.dropDown ? this.titleLink : this.checkBox; |
| else // Right. |
| toFocus = target == this.checkBox ? this.titleLink : this.dropDown; |
| |
| this.focusControl(toFocus); |
| e.preventDefault(); |
| }; |
| |
| /** |
| * Removes a history entry on click or keydown and finds a new entry to focus. |
| * @param {Event} e A click or keydown event. |
| * @private |
| */ |
| Visit.prototype.removeEntryFromHistory_ = function(e) { |
| this.model_.getView().onBeforeRemove(this); |
| this.removeFromHistory(); |
| e.preventDefault(); |
| }; |
| |
| // Visit, private, static: ---------------------------------------------------- |
| |
| /** |
| * Quote a string so it can be used in a regular expression. |
| * @param {string} str The source string. |
| * @return {string} The escaped string. |
| * @private |
| */ |
| Visit.pregQuote_ = function(str) { |
| return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1'); |
| }; |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // HistoryModel: |
| |
| /** |
| * Global container for history data. Future optimizations might include |
| * allowing the creation of a HistoryModel for each search string, allowing |
| * quick flips back and forth between results. |
| * |
| * The history model is based around pages, and only fetching the data to |
| * fill the currently requested page. This is somewhat dependent on the view, |
| * and so future work may wish to change history model to operate on |
| * timeframe (day or week) based containers. |
| * |
| * @constructor |
| */ |
| function HistoryModel() { |
| this.clearModel_(); |
| } |
| |
| // HistoryModel, Public: ------------------------------------------------------ |
| |
| /** @enum {number} */ |
| HistoryModel.Range = { |
| ALL_TIME: 0, |
| WEEK: 1, |
| MONTH: 2 |
| }; |
| |
| /** |
| * Sets our current view that is called when the history model changes. |
| * @param {HistoryView} view The view to set our current view to. |
| */ |
| HistoryModel.prototype.setView = function(view) { |
| this.view_ = view; |
| }; |
| |
| |
| /** |
| * @return {HistoryView|undefined} Returns the view for this model (if set). |
| */ |
| HistoryModel.prototype.getView = function() { |
| return this.view_; |
| }; |
| |
| /** |
| * Reload our model with the current parameters. |
| */ |
| HistoryModel.prototype.reload = function() { |
| // Save user-visible state, clear the model, and restore the state. |
| var search = this.searchText_; |
| var page = this.requestedPage_; |
| var range = this.rangeInDays_; |
| var offset = this.offset_; |
| var groupByDomain = this.groupByDomain_; |
| |
| this.clearModel_(); |
| this.searchText_ = search; |
| this.requestedPage_ = page; |
| this.rangeInDays_ = range; |
| this.offset_ = offset; |
| this.groupByDomain_ = groupByDomain; |
| this.queryHistory_(); |
| }; |
| |
| /** |
| * @return {string} The current search text. |
| */ |
| HistoryModel.prototype.getSearchText = function() { |
| return this.searchText_; |
| }; |
| |
| /** |
| * Tell the model that the view will want to see the current page. When |
| * the data becomes available, the model will call the view back. |
| * @param {number} page The page we want to view. |
| */ |
| HistoryModel.prototype.requestPage = function(page) { |
| this.requestedPage_ = page; |
| this.updateSearch_(); |
| }; |
| |
| /** |
| * Receiver for history query. |
| * @param {Object} info An object containing information about the query. |
| * @param {Array} results A list of results. |
| */ |
| HistoryModel.prototype.addResults = function(info, results) { |
| // If no requests are in flight then this was an old request so we drop the |
| // results. Double check the search term as well. |
| if (!this.inFlight_ || info.term != this.searchText_) |
| return; |
| |
| $('loading-spinner').hidden = true; |
| this.inFlight_ = false; |
| this.isQueryFinished_ = info.finished; |
| this.queryStartTime = info.queryStartTime; |
| this.queryEndTime = info.queryEndTime; |
| |
| var lastVisit = this.visits_.slice(-1)[0]; |
| var lastDay = lastVisit ? lastVisit.dateRelativeDay : null; |
| |
| for (var i = 0, result; result = results[i]; i++) { |
| var thisDay = result.dateRelativeDay; |
| var isSameDay = lastDay == thisDay; |
| this.visits_.push(new Visit(result, isSameDay, this)); |
| lastDay = thisDay; |
| } |
| |
| if (loadTimeData.getBoolean('isUserSignedIn')) { |
| var message = loadTimeData.getString( |
| info.hasSyncedResults ? 'hasSyncedResults' : 'noSyncedResults'); |
| this.view_.showNotification(message); |
| } |
| |
| this.updateSearch_(); |
| }; |
| |
| /** |
| * @return {number} The number of visits in the model. |
| */ |
| HistoryModel.prototype.getSize = function() { |
| return this.visits_.length; |
| }; |
| |
| /** |
| * Get a list of visits between specified index positions. |
| * @param {number} start The start index. |
| * @param {number} end The end index. |
| * @return {Array.<Visit>} A list of visits. |
| */ |
| HistoryModel.prototype.getNumberedRange = function(start, end) { |
| return this.visits_.slice(start, end); |
| }; |
| |
| /** |
| * Return true if there are more results beyond the current page. |
| * @return {boolean} true if the there are more results, otherwise false. |
| */ |
| HistoryModel.prototype.hasMoreResults = function() { |
| return this.haveDataForPage_(this.requestedPage_ + 1) || |
| !this.isQueryFinished_; |
| }; |
| |
| /** |
| * Removes a list of visits from the history, and calls |callback| when the |
| * removal has successfully completed. |
| * @param {Array<Visit>} visits The visits to remove. |
| * @param {Function} callback The function to call after removal succeeds. |
| */ |
| HistoryModel.prototype.removeVisitsFromHistory = function(visits, callback) { |
| var toBeRemoved = []; |
| for (var i = 0; i < visits.length; i++) { |
| toBeRemoved.push({ |
| url: visits[i].url_, |
| timestamps: visits[i].allTimestamps |
| }); |
| } |
| chrome.send('removeVisits', toBeRemoved); |
| this.deleteCompleteCallback_ = callback; |
| }; |
| |
| /** @return {boolean} Whether the model is currently deleting a visit. */ |
| HistoryModel.prototype.isDeletingVisits = function() { |
| return !!this.deleteCompleteCallback_; |
| }; |
| |
| /** |
| * Called when visits have been succesfully removed from the history. |
| */ |
| HistoryModel.prototype.deleteComplete = function() { |
| // Call the callback, with 'this' undefined inside the callback. |
| this.deleteCompleteCallback_.call(); |
| this.deleteCompleteCallback_ = null; |
| }; |
| |
| // Getter and setter for HistoryModel.rangeInDays_. |
| Object.defineProperty(HistoryModel.prototype, 'rangeInDays', { |
| get: function() { |
| return this.rangeInDays_; |
| }, |
| set: function(range) { |
| this.rangeInDays_ = range; |
| } |
| }); |
| |
| /** |
| * Getter and setter for HistoryModel.offset_. The offset moves the current |
| * query 'window' |range| days behind. As such for range set to WEEK an offset |
| * of 0 refers to the last 7 days, an offset of 1 refers to the 7 day period |
| * that ended 7 days ago, etc. For MONTH an offset of 0 refers to the current |
| * calendar month, 1 to the previous one, etc. |
| */ |
| Object.defineProperty(HistoryModel.prototype, 'offset', { |
| get: function() { |
| return this.offset_; |
| }, |
| set: function(offset) { |
| this.offset_ = offset; |
| } |
| }); |
| |
| // Setter for HistoryModel.requestedPage_. |
| Object.defineProperty(HistoryModel.prototype, 'requestedPage', { |
| set: function(page) { |
| this.requestedPage_ = page; |
| } |
| }); |
| |
| /** |
| * Removes |visit| from this model. |
| * @param {Visit} visit A visit to remove. |
| */ |
| HistoryModel.prototype.removeVisit = function(visit) { |
| var index = this.visits_.indexOf(visit); |
| if (index >= 0) |
| this.visits_.splice(index, 1); |
| }; |
| |
| // HistoryModel, Private: ----------------------------------------------------- |
| |
| /** |
| * Clear the history model. |
| * @private |
| */ |
| HistoryModel.prototype.clearModel_ = function() { |
| this.inFlight_ = false; // Whether a query is inflight. |
| this.searchText_ = ''; |
| // Whether this user is a managed user. |
| this.isManagedProfile = loadTimeData.getBoolean('isManagedProfile'); |
| this.deletingHistoryAllowed = loadTimeData.getBoolean('allowDeletingHistory'); |
| |
| // Only create checkboxes for editing entries if they can be used either to |
| // delete an entry or to block/allow it. |
| this.editingEntriesAllowed = this.deletingHistoryAllowed; |
| |
| // Flag to show that the results are grouped by domain or not. |
| this.groupByDomain_ = false; |
| |
| this.visits_ = []; // Date-sorted list of visits (most recent first). |
| this.nextVisitId_ = 0; |
| selectionAnchor = -1; |
| |
| // The page that the view wants to see - we only fetch slightly past this |
| // point. If the view requests a page that we don't have data for, we try |
| // to fetch it and call back when we're done. |
| this.requestedPage_ = 0; |
| |
| // The range of history to view or search over. |
| this.rangeInDays_ = HistoryModel.Range.ALL_TIME; |
| |
| // Skip |offset_| * weeks/months from the begining. |
| this.offset_ = 0; |
| |
| // Keeps track of whether or not there are more results available than are |
| // currently held in |this.visits_|. |
| this.isQueryFinished_ = false; |
| |
| if (this.view_) |
| this.view_.clear_(); |
| }; |
| |
| /** |
| * Figure out if we need to do more queries to fill the currently requested |
| * page. If we think we can fill the page, call the view and let it know |
| * we're ready to show something. This only applies to the daily time-based |
| * view. |
| * @private |
| */ |
| HistoryModel.prototype.updateSearch_ = function() { |
| var doneLoading = this.rangeInDays_ != HistoryModel.Range.ALL_TIME || |
| this.isQueryFinished_ || |
| this.canFillPage_(this.requestedPage_); |
| |
| // Try to fetch more results if more results can arrive and the page is not |
| // full. |
| if (!doneLoading && !this.inFlight_) |
| this.queryHistory_(); |
| |
| // Show the result or a message if no results were returned. |
| this.view_.onModelReady(doneLoading); |
| }; |
| |
| /** |
| * Query for history, either for a search or time-based browsing. |
| * @private |
| */ |
| HistoryModel.prototype.queryHistory_ = function() { |
| var maxResults = |
| (this.rangeInDays_ == HistoryModel.Range.ALL_TIME) ? RESULTS_PER_PAGE : 0; |
| |
| // If there are already some visits, pick up the previous query where it |
| // left off. |
| var lastVisit = this.visits_.slice(-1)[0]; |
| var endTime = lastVisit ? lastVisit.date.getTime() : 0; |
| |
| $('loading-spinner').hidden = false; |
| this.inFlight_ = true; |
| chrome.send('queryHistory', |
| [this.searchText_, this.offset_, this.rangeInDays_, endTime, maxResults]); |
| }; |
| |
| /** |
| * Check to see if we have data for the given page. |
| * @param {number} page The page number. |
| * @return {boolean} Whether we have any data for the given page. |
| * @private |
| */ |
| HistoryModel.prototype.haveDataForPage_ = function(page) { |
| return page * RESULTS_PER_PAGE < this.getSize(); |
| }; |
| |
| /** |
| * Check to see if we have data to fill the given page. |
| * @param {number} page The page number. |
| * @return {boolean} Whether we have data to fill the page. |
| * @private |
| */ |
| HistoryModel.prototype.canFillPage_ = function(page) { |
| return ((page + 1) * RESULTS_PER_PAGE <= this.getSize()); |
| }; |
| |
| /** |
| * Enables or disables grouping by domain. |
| * @param {boolean} groupByDomain New groupByDomain_ value. |
| */ |
| HistoryModel.prototype.setGroupByDomain = function(groupByDomain) { |
| this.groupByDomain_ = groupByDomain; |
| this.offset_ = 0; |
| }; |
| |
| /** |
| * Gets whether we are grouped by domain. |
| * @return {boolean} Whether the results are grouped by domain. |
| */ |
| HistoryModel.prototype.getGroupByDomain = function() { |
| return this.groupByDomain_; |
| }; |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // HistoryView: |
| |
| /** |
| * Functions and state for populating the page with HTML. This should one-day |
| * contain the view and use event handlers, rather than pushing HTML out and |
| * getting called externally. |
| * @param {HistoryModel} model The model backing this view. |
| * @constructor |
| */ |
| function HistoryView(model) { |
| this.editButtonTd_ = $('edit-button'); |
| this.editingControlsDiv_ = $('editing-controls'); |
| this.resultDiv_ = $('results-display'); |
| this.pageDiv_ = $('results-pagination'); |
| this.model_ = model; |
| this.pageIndex_ = 0; |
| this.lastDisplayed_ = []; |
| |
| this.model_.setView(this); |
| |
| this.currentVisits_ = []; |
| |
| // If there is no search button, use the search button label as placeholder |
| // text in the search field. |
| if ($('search-button').offsetWidth == 0) |
| $('search-field').placeholder = $('search-button').value; |
| |
| var self = this; |
| |
| $('clear-browsing-data').addEventListener('click', openClearBrowsingData); |
| $('remove-selected').addEventListener('click', removeItems); |
| |
| // Add handlers for the page navigation buttons at the bottom. |
| $('newest-button').addEventListener('click', function() { |
| recordUmaAction('HistoryPage_NewestHistoryClick'); |
| self.setPage(0); |
| }); |
| $('newer-button').addEventListener('click', function() { |
| recordUmaAction('HistoryPage_NewerHistoryClick'); |
| self.setPage(self.pageIndex_ - 1); |
| }); |
| $('older-button').addEventListener('click', function() { |
| recordUmaAction('HistoryPage_OlderHistoryClick'); |
| self.setPage(self.pageIndex_ + 1); |
| }); |
| |
| var handleRangeChange = function(e) { |
| // Update the results and save the last state. |
| self.setRangeInDays(parseInt(e.target.value, 10)); |
| }; |
| |
| // Add handlers for the range options. |
| $('timeframe-filter-all').addEventListener('change', handleRangeChange); |
| $('timeframe-filter-week').addEventListener('change', handleRangeChange); |
| $('timeframe-filter-month').addEventListener('change', handleRangeChange); |
| |
| $('range-previous').addEventListener('click', function(e) { |
| if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME) |
| self.setPage(self.pageIndex_ + 1); |
| else |
| self.setOffset(self.getOffset() + 1); |
| }); |
| $('range-next').addEventListener('click', function(e) { |
| if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME) |
| self.setPage(self.pageIndex_ - 1); |
| else |
| self.setOffset(self.getOffset() - 1); |
| }); |
| $('range-today').addEventListener('click', function(e) { |
| if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME) |
| self.setPage(0); |
| else |
| self.setOffset(0); |
| }); |
| |
| this.resultDiv_.addEventListener( |
| 'keydown', this.handleKeydown_.bind(this)); |
| this.resultDiv_.addEventListener( |
| 'mousedown', this.handleMousedown_.bind(this), true); |
| this.resultDiv_.addEventListener( |
| 'focusin', this.updateFocusableElements_.bind(this)); |
| this.resultDiv_.addEventListener( |
| 'blur', this.updateFocusableElements_.bind(this)); |
| } |
| |
| // HistoryView, public: ------------------------------------------------------- |
| /** |
| * Do a search on a specific term. |
| * @param {string} term The string to search for. |
| */ |
| HistoryView.prototype.setSearch = function(term) { |
| window.scrollTo(0, 0); |
| this.setPageState(term, 0, this.getRangeInDays(), this.getOffset()); |
| }; |
| |
| /** |
| * Reload the current view. |
| */ |
| HistoryView.prototype.reload = function() { |
| this.model_.reload(); |
| this.updateSelectionEditButtons(); |
| this.updateRangeButtons_(); |
| }; |
| |
| /** |
| * Sets all the parameters for the history page and then reloads the view to |
| * update the results. |
| * @param {string} searchText The search string to set. |
| * @param {number} page The page to be viewed. |
| * @param {HistoryModel.Range} range The range to view or search over. |
| * @param {number} offset Set the begining of the query to the specific offset. |
| */ |
| HistoryView.prototype.setPageState = function(searchText, page, range, offset) { |
| this.clear_(); |
| this.model_.searchText_ = searchText; |
| this.pageIndex_ = page; |
| this.model_.requestedPage_ = page; |
| this.model_.rangeInDays_ = range; |
| this.model_.groupByDomain_ = false; |
| if (range != HistoryModel.Range.ALL_TIME) |
| this.model_.groupByDomain_ = true; |
| this.model_.offset_ = offset; |
| this.reload(); |
| pageState.setUIState(this.model_.getSearchText(), |
| this.pageIndex_, |
| this.getRangeInDays(), |
| this.getOffset()); |
| }; |
| |
| /** |
| * Switch to a specified page. |
| * @param {number} page The page we wish to view. |
| */ |
| HistoryView.prototype.setPage = function(page) { |
| // TODO(sergiu): Move this function to setPageState as well and see why one |
| // of the tests fails when using setPageState. |
| this.clear_(); |
| this.pageIndex_ = parseInt(page, 10); |
| window.scrollTo(0, 0); |
| this.model_.requestPage(page); |
| pageState.setUIState(this.model_.getSearchText(), |
| this.pageIndex_, |
| this.getRangeInDays(), |
| this.getOffset()); |
| }; |
| |
| /** |
| * @return {number} The page number being viewed. |
| */ |
| HistoryView.prototype.getPage = function() { |
| return this.pageIndex_; |
| }; |
| |
| /** |
| * Set the current range for grouped results. |
| * @param {string} range The number of days to which the range should be set. |
| */ |
| HistoryView.prototype.setRangeInDays = function(range) { |
| // Set the range, offset and reset the page. |
| this.setPageState(this.model_.getSearchText(), 0, range, 0); |
| }; |
| |
| /** |
| * Get the current range in days. |
| * @return {number} Current range in days from the model. |
| */ |
| HistoryView.prototype.getRangeInDays = function() { |
| return this.model_.rangeInDays; |
| }; |
| |
| /** |
| * Set the current offset for grouped results. |
| * @param {number} offset Offset to set. |
| */ |
| HistoryView.prototype.setOffset = function(offset) { |
| // If there is another query already in flight wait for that to complete. |
| if (this.model_.inFlight_) |
| return; |
| this.setPageState(this.model_.getSearchText(), |
| this.pageIndex_, |
| this.getRangeInDays(), |
| offset); |
| }; |
| |
| /** |
| * Get the current offset. |
| * @return {number} Current offset from the model. |
| */ |
| HistoryView.prototype.getOffset = function() { |
| return this.model_.offset; |
| }; |
| |
| /** |
| * Callback for the history model to let it know that it has data ready for us |
| * to view. |
| * @param {boolean} doneLoading Whether the current request is complete. |
| */ |
| HistoryView.prototype.onModelReady = function(doneLoading) { |
| this.displayResults_(doneLoading); |
| |
| // Allow custom styling based on whether there are any results on the page. |
| // To make this easier, add a class to the body if there are any results. |
| var hasResults = this.model_.visits_.length > 0; |
| document.body.classList.toggle('has-results', hasResults); |
| |
| this.updateFocusableElements_(); |
| this.updateNavBar_(); |
| |
| if (isMobileVersion()) { |
| // Hide the search field if it is empty and there are no results. |
| var isSearch = this.model_.getSearchText().length > 0; |
| $('search-field').hidden = !(hasResults || isSearch); |
| } |
| }; |
| |
| /** |
| * Enables or disables the buttons that control editing entries depending on |
| * whether there are any checked boxes. |
| */ |
| HistoryView.prototype.updateSelectionEditButtons = function() { |
| if (loadTimeData.getBoolean('allowDeletingHistory')) { |
| var anyChecked = document.querySelector('.entry input:checked') != null; |
| $('remove-selected').disabled = !anyChecked; |
| } else { |
| $('remove-selected').disabled = true; |
| } |
| }; |
| |
| /** |
| * Shows the notification bar at the top of the page with |innerHTML| as its |
| * content. |
| * @param {string} innerHTML The HTML content of the warning. |
| * @param {boolean} isWarning If true, style the notification as a warning. |
| */ |
| HistoryView.prototype.showNotification = function(innerHTML, isWarning) { |
| var bar = $('notification-bar'); |
| bar.innerHTML = innerHTML; |
| bar.hidden = false; |
| if (isWarning) |
| bar.classList.add('warning'); |
| else |
| bar.classList.remove('warning'); |
| |
| // Make sure that any links in the HTML are targeting the top level. |
| var links = bar.querySelectorAll('a'); |
| for (var i = 0; i < links.length; i++) |
| links[i].target = '_top'; |
| |
| this.positionNotificationBar(); |
| }; |
| |
| /** |
| * @param {Visit} visit The visit about to be removed from this view. |
| */ |
| HistoryView.prototype.onBeforeRemove = function(visit) { |
| assert(this.currentVisits_.indexOf(visit) >= 0); |
| var toFocus = this.getVisitAfter_(visit) || this.getVisitBefore_(visit); |
| this.swapFocusedVisit_(toFocus); |
| }; |
| |
| /** |
| * Removes a single entry from the view. Also removes gaps before and after |
| * entry if necessary. |
| * @param {Visit} visit The visit to be removed. |
| */ |
| HistoryView.prototype.removeVisit = function(visit) { |
| var entry = visit.domNode_; |
| var previousEntry = entry.previousSibling; |
| var nextEntry = entry.nextSibling; |
| var toRemove = [entry]; |
| |
| // If there is no previous entry, and the next entry is a gap, remove it. |
| if (!previousEntry && nextEntry && nextEntry.classList.contains('gap')) |
| toRemove.push(nextEntry); |
| |
| // If there is no next entry, and the previous entry is a gap, remove it. |
| if (!nextEntry && previousEntry && previousEntry.classList.contains('gap')) |
| toRemove.push(previousEntry); |
| |
| // If both the next and previous entries are gaps, remove the next one. |
| if (nextEntry && nextEntry.classList.contains('gap') && |
| previousEntry && previousEntry.classList.contains('gap')) { |
| toRemove.push(nextEntry); |
| } |
| |
| // If removing the last entry on a day, remove the entire day. |
| var dayResults = findAncestorByClass(entry, 'day-results'); |
| if (dayResults && dayResults.querySelectorAll('.entry').length <= 1) { |
| toRemove.push(dayResults.previousSibling); // Remove the 'h3'. |
| toRemove.push(dayResults); |
| } |
| |
| // Callback to be called when each node has finished animating. It detects |
| // when all the animations have completed. |
| function onRemove() { |
| for (var i = 0; i < toRemove.length; ++i) { |
| if (toRemove[i].parentNode) |
| return; |
| } |
| onEntryRemoved(); |
| } |
| |
| // Kick off the removal process. |
| for (var i = 0; i < toRemove.length; ++i) { |
| removeNode(toRemove[i], onRemove, this); |
| } |
| |
| var index = this.currentVisits_.indexOf(visit); |
| if (index >= 0) |
| this.currentVisits_.splice(index, 1); |
| |
| this.model_.removeVisit(visit); |
| }; |
| |
| /** |
| * Called when an individual history entry has been removed from the page. |
| * This will only be called when all the elements affected by the deletion |
| * have been removed from the DOM and the animations have completed. |
| */ |
| HistoryView.prototype.onEntryRemoved = function() { |
| this.updateSelectionEditButtons(); |
| |
| if (this.model_.getSize() == 0) |
| this.onModelReady(true); // Shows "No entries" message. |
| }; |
| |
| /** |
| * Adjusts the position of the notification bar based on the size of the page. |
| */ |
| HistoryView.prototype.positionNotificationBar = function() { |
| var bar = $('notification-bar'); |
| |
| // If the bar does not fit beside the editing controls, put it into the |
| // overflow state. |
| if (bar.getBoundingClientRect().top >= |
| $('editing-controls').getBoundingClientRect().bottom) { |
| bar.classList.add('alone'); |
| } else { |
| bar.classList.remove('alone'); |
| } |
| }; |
| |
| // HistoryView, private: ------------------------------------------------------ |
| |
| /** |
| * Clear the results in the view. Since we add results piecemeal, we need |
| * to clear them out when we switch to a new page or reload. |
| * @private |
| */ |
| HistoryView.prototype.clear_ = function() { |
| var alertOverlay = $('alertOverlay'); |
| if (alertOverlay && alertOverlay.classList.contains('showing')) |
| hideConfirmationOverlay(); |
| |
| this.resultDiv_.textContent = ''; |
| |
| this.currentVisits_.forEach(function(visit) { |
| visit.isRendered = false; |
| }); |
| this.currentVisits_ = []; |
| |
| document.body.classList.remove('has-results'); |
| }; |
| |
| /** |
| * Record that the given visit has been rendered. |
| * @param {Visit} visit The visit that was rendered. |
| * @private |
| */ |
| HistoryView.prototype.setVisitRendered_ = function(visit) { |
| visit.isRendered = true; |
| this.currentVisits_.push(visit); |
| }; |
| |
| /** |
| * Generates and adds the grouped visits DOM for a certain domain. This |
| * includes the clickable arrow and domain name and the visit entries for |
| * that domain. |
| * @param {Element} results DOM object to which to add the elements. |
| * @param {string} domain Current domain name. |
| * @param {Array} domainVisits Array of visits for this domain. |
| * @private |
| */ |
| HistoryView.prototype.getGroupedVisitsDOM_ = function( |
| results, domain, domainVisits) { |
| // Add a new domain entry. |
| var siteResults = results.appendChild( |
| createElementWithClassName('li', 'site-entry')); |
| |
| // Make a wrapper that will contain the arrow, the favicon and the domain. |
| var siteDomainWrapper = siteResults.appendChild( |
| createElementWithClassName('div', 'site-domain-wrapper')); |
| |
| if (this.model_.editingEntriesAllowed) { |
| var siteDomainCheckbox = |
| createElementWithClassName('input', 'domain-checkbox'); |
| |
| siteDomainCheckbox.type = 'checkbox'; |
| siteDomainCheckbox.addEventListener('click', domainCheckboxClicked); |
| siteDomainCheckbox.domain_ = domain; |
| |
| siteDomainWrapper.appendChild(siteDomainCheckbox); |
| } |
| |
| var siteArrow = siteDomainWrapper.appendChild( |
| createElementWithClassName('div', 'site-domain-arrow collapse')); |
| var siteDomain = siteDomainWrapper.appendChild( |
| createElementWithClassName('div', 'site-domain')); |
| var siteDomainLink = siteDomain.appendChild( |
| createElementWithClassName('button', 'link-button')); |
| siteDomainLink.addEventListener('click', function(e) { e.preventDefault(); }); |
| siteDomainLink.textContent = domain; |
| var numberOfVisits = createElementWithClassName('span', 'number-visits'); |
| var domainElement = document.createElement('span'); |
| |
| numberOfVisits.textContent = loadTimeData.getStringF('numberVisits', |
| domainVisits.length); |
| siteDomain.appendChild(numberOfVisits); |
| |
| domainVisits[0].addFaviconToElement_(siteDomain); |
| |
| siteDomainWrapper.addEventListener('click', toggleHandler); |
| |
| if (this.model_.isManagedProfile) { |
| siteDomainWrapper.appendChild( |
| getManagedStatusDOM(domainVisits[0].hostFilteringBehavior)); |
| } |
| |
| siteResults.appendChild(siteDomainWrapper); |
| var resultsList = siteResults.appendChild( |
| createElementWithClassName('ol', 'site-results')); |
| resultsList.classList.add('grouped'); |
| |
| // Collapse until it gets toggled. |
| resultsList.style.height = 0; |
| |
| // Add the results for each of the domain. |
| var isMonthGroupedResult = this.getRangeInDays() == HistoryModel.Range.MONTH; |
| for (var j = 0, visit; visit = domainVisits[j]; j++) { |
| resultsList.appendChild(visit.getResultDOM({ |
| useMonthDate: isMonthGroupedResult |
| })); |
| this.setVisitRendered_(visit); |
| } |
| }; |
| |
| /** |
| * Enables or disables the time range buttons. |
| * @private |
| */ |
| HistoryView.prototype.updateRangeButtons_ = function() { |
| // The enabled state for the previous, today and next buttons. |
| var previousState = false; |
| var todayState = false; |
| var nextState = false; |
| var usePage = (this.getRangeInDays() == HistoryModel.Range.ALL_TIME); |
| |
| // Use pagination for most recent visits, offset otherwise. |
| // TODO(sergiu): Maybe send just one variable in the future. |
| if (usePage) { |
| if (this.getPage() != 0) { |
| nextState = true; |
| todayState = true; |
| } |
| previousState = this.model_.hasMoreResults(); |
| } else { |
| if (this.getOffset() != 0) { |
| nextState = true; |
| todayState = true; |
| } |
| previousState = !this.model_.isQueryFinished_; |
| } |
| |
| $('range-previous').disabled = !previousState; |
| $('range-today').disabled = !todayState; |
| $('range-next').disabled = !nextState; |
| }; |
| |
| /** |
| * Groups visits by domain, sorting them by the number of visits. |
| * @param {Array} visits Visits received from the query results. |
| * @param {Element} results Object where the results are added to. |
| * @private |
| */ |
| HistoryView.prototype.groupVisitsByDomain_ = function(visits, results) { |
| var visitsByDomain = {}; |
| var domains = []; |
| |
| // Group the visits into a dictionary and generate a list of domains. |
| for (var i = 0, visit; visit = visits[i]; i++) { |
| var domain = visit.domain_; |
| if (!visitsByDomain[domain]) { |
| visitsByDomain[domain] = []; |
| domains.push(domain); |
| } |
| visitsByDomain[domain].push(visit); |
| } |
| var sortByVisits = function(a, b) { |
| return visitsByDomain[b].length - visitsByDomain[a].length; |
| }; |
| domains.sort(sortByVisits); |
| |
| for (var i = 0; i < domains.length; ++i) { |
| var domain = domains[i]; |
| this.getGroupedVisitsDOM_(results, domain, visitsByDomain[domain]); |
| } |
| }; |
| |
| /** |
| * Adds the results for a month. |
| * @param {Array} visits Visits returned by the query. |
| * @param {Element} parentElement Element to which to add the results to. |
| * @private |
| */ |
| HistoryView.prototype.addMonthResults_ = function(visits, parentElement) { |
| if (visits.length == 0) |
| return; |
| |
| var monthResults = parentElement.appendChild( |
| createElementWithClassName('ol', 'month-results')); |
| // Don't add checkboxes if entries can not be edited. |
| if (!this.model_.editingEntriesAllowed) |
| monthResults.classList.add('no-checkboxes'); |
| |
| this.groupVisitsByDomain_(visits, monthResults); |
| }; |
| |
| /** |
| * Adds the results for a certain day. This includes a title with the day of |
| * the results and the results themselves, grouped or not. |
| * @param {Array} visits Visits returned by the query. |
| * @param {Element} parentElement Element to which to add the results to. |
| * @private |
| */ |
| HistoryView.prototype.addDayResults_ = function(visits, parentElement) { |
| if (visits.length == 0) |
| return; |
| |
| var firstVisit = visits[0]; |
| var day = parentElement.appendChild(createElementWithClassName('h3', 'day')); |
| day.appendChild(document.createTextNode(firstVisit.dateRelativeDay)); |
| if (firstVisit.continued) { |
| day.appendChild(document.createTextNode(' ' + |
| loadTimeData.getString('cont'))); |
| } |
| var dayResults = parentElement.appendChild( |
| createElementWithClassName('ol', 'day-results')); |
| |
| // Don't add checkboxes if entries can not be edited. |
| if (!this.model_.editingEntriesAllowed) |
| dayResults.classList.add('no-checkboxes'); |
| |
| if (this.model_.getGroupByDomain()) { |
| this.groupVisitsByDomain_(visits, dayResults); |
| } else { |
| var lastTime; |
| |
| for (var i = 0, visit; visit = visits[i]; i++) { |
| // If enough time has passed between visits, indicate a gap in browsing. |
| var thisTime = visit.date.getTime(); |
| if (lastTime && lastTime - thisTime > BROWSING_GAP_TIME) |
| dayResults.appendChild(createElementWithClassName('li', 'gap')); |
| |
| // Insert the visit into the DOM. |
| dayResults.appendChild(visit.getResultDOM({ addTitleFavicon: true })); |
| this.setVisitRendered_(visit); |
| |
| lastTime = thisTime; |
| } |
| } |
| }; |
| |
| /** |
| * Adds the text that shows the current interval, used for week and month |
| * results. |
| * @param {Element} resultsFragment The element to which the interval will be |
| * added to. |
| * @private |
| */ |
| HistoryView.prototype.addTimeframeInterval_ = function(resultsFragment) { |
| if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) |
| return; |
| |
| // If this is a time range result add some text that shows what is the |
| // time range for the results the user is viewing. |
| var timeFrame = resultsFragment.appendChild( |
| createElementWithClassName('h2', 'timeframe')); |
| // TODO(sergiu): Figure the best way to show this for the first day of |
| // the month. |
| timeFrame.appendChild(document.createTextNode(loadTimeData.getStringF( |
| 'historyInterval', |
| this.model_.queryStartTime, |
| this.model_.queryEndTime))); |
| }; |
| |
| /** |
| * Update the page with results. |
| * @param {boolean} doneLoading Whether the current request is complete. |
| * @private |
| */ |
| HistoryView.prototype.displayResults_ = function(doneLoading) { |
| // Either show a page of results received for the all time results or all the |
| // received results for the weekly and monthly view. |
| var results = this.model_.visits_; |
| if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) { |
| var rangeStart = this.pageIndex_ * RESULTS_PER_PAGE; |
| var rangeEnd = rangeStart + RESULTS_PER_PAGE; |
| results = this.model_.getNumberedRange(rangeStart, rangeEnd); |
| } |
| var searchText = this.model_.getSearchText(); |
| var groupByDomain = this.model_.getGroupByDomain(); |
| |
| if (searchText) { |
| // Add a header for the search results, if there isn't already one. |
| if (!this.resultDiv_.querySelector('h3')) { |
| var header = document.createElement('h3'); |
| header.textContent = loadTimeData.getStringF('searchResultsFor', |
| searchText); |
| this.resultDiv_.appendChild(header); |
| } |
| |
| this.addTimeframeInterval_(this.resultDiv_); |
| |
| var searchResults = createElementWithClassName('ol', 'search-results'); |
| |
| // Don't add checkboxes if entries can not be edited. |
| if (!this.model_.editingEntriesAllowed) |
| searchResults.classList.add('no-checkboxes'); |
| |
| if (results.length == 0 && doneLoading) { |
| var noSearchResults = searchResults.appendChild( |
| createElementWithClassName('div', 'no-results-message')); |
| noSearchResults.textContent = loadTimeData.getString('noSearchResults'); |
| } else { |
| for (var i = 0, visit; visit = results[i]; i++) { |
| if (!visit.isRendered) { |
| searchResults.appendChild(visit.getResultDOM({ |
| isSearchResult: true, |
| addTitleFavicon: true |
| })); |
| this.setVisitRendered_(visit); |
| } |
| } |
| } |
| this.resultDiv_.appendChild(searchResults); |
| } else { |
| var resultsFragment = document.createDocumentFragment(); |
| |
| this.addTimeframeInterval_(resultsFragment); |
| |
| if (results.length == 0 && doneLoading) { |
| var noResults = resultsFragment.appendChild( |
| createElementWithClassName('div', 'no-results-message')); |
| noResults.textContent = loadTimeData.getString('noResults'); |
| this.resultDiv_.appendChild(resultsFragment); |
| return; |
| } |
| |
| if (this.getRangeInDays() == HistoryModel.Range.MONTH && |
| groupByDomain) { |
| // Group everything together in the month view. |
| this.addMonthResults_(results, resultsFragment); |
| } else { |
| var dayStart = 0; |
| var dayEnd = 0; |
| // Go through all of the visits and process them in chunks of one day. |
| while (dayEnd < results.length) { |
| // Skip over the ones that are already rendered. |
| while (dayStart < results.length && results[dayStart].isRendered) |
| ++dayStart; |
| var dayEnd = dayStart + 1; |
| while (dayEnd < results.length && results[dayEnd].continued) |
| ++dayEnd; |
| |
| this.addDayResults_( |
| results.slice(dayStart, dayEnd), resultsFragment, groupByDomain); |
| } |
| } |
| |
| // Add all the days and their visits to the page. |
| this.resultDiv_.appendChild(resultsFragment); |
| } |
| // After the results have been added to the DOM, determine the size of the |
| // time column. |
| this.setTimeColumnWidth_(this.resultDiv_); |
| }; |
| |
| /** |
| * Update the visibility of the page navigation buttons. |
| * @private |
| */ |
| HistoryView.prototype.updateNavBar_ = function() { |
| this.updateRangeButtons_(); |
| |
| // Managed users have the control bar on top, don't show it on the bottom |
| // as well. |
| if (!loadTimeData.getBoolean('isManagedProfile')) { |
| $('newest-button').hidden = this.pageIndex_ == 0; |
| $('newer-button').hidden = this.pageIndex_ == 0; |
| $('older-button').hidden = |
| this.model_.rangeInDays_ != HistoryModel.Range.ALL_TIME || |
| !this.model_.hasMoreResults(); |
| } |
| }; |
| |
| /** |
| * Updates the visibility of the 'Clear browsing data' button. |
| * Only used on mobile platforms. |
| * @private |
| */ |
| HistoryView.prototype.updateClearBrowsingDataButton_ = function() { |
| // Ideally, we should hide the 'Clear browsing data' button whenever the |
| // soft keyboard is visible. This is not possible, so instead, hide the |
| // button whenever the search field has focus. |
| $('clear-browsing-data').hidden = |
| (document.activeElement === $('search-field')); |
| }; |
| |
| /** |
| * Dynamically sets the min-width of the time column for history entries. |
| * This ensures that all entry times will have the same width, without |
| * imposing a fixed width that may not be appropriate for some locales. |
| * @private |
| */ |
| HistoryView.prototype.setTimeColumnWidth_ = function() { |
| // Find the maximum width of all the time elements on the page. |
| var times = this.resultDiv_.querySelectorAll('.entry .time'); |
| var widths = Array.prototype.map.call(times, function(el) { |
| el.style.minWidth = '-webkit-min-content'; |
| var width = el.clientWidth; |
| el.style.minWidth = ''; |
| |
| // Add an extra pixel to prevent rounding errors from causing the text to |
| // be ellipsized at certain zoom levels (see crbug.com/329779). |
| return width + 1; |
| }); |
| var maxWidth = widths.length ? Math.max.apply(null, widths) : 0; |
| |
| // Add a dynamic stylesheet to the page (or replace the existing one), to |
| // ensure that all entry times have the same width. |
| var styleEl = $('timeColumnStyle'); |
| if (!styleEl) { |
| styleEl = document.head.appendChild(document.createElement('style')); |
| styleEl.id = 'timeColumnStyle'; |
| } |
| styleEl.textContent = '.entry .time { min-width: ' + maxWidth + 'px; }'; |
| }; |
| |
| /** |
| * @param {Visit} visit The starting point when looking for a previous visit. |
| * @return {Visit|undefined} The previous visit (if there is one) or undefined. |
| * @private |
| */ |
| HistoryView.prototype.getVisitBefore_ = function(visit) { |
| var index = this.currentVisits_.indexOf(visit); |
| return index < 0 ? undefined : this.currentVisits_[index - 1]; |
| }; |
| |
| /** |
| * @param {Visit} visit The starting point when looking for a previous visit. |
| * @return {Visit|undefined} The next visit (if there is one) or undefined. |
| * @private |
| */ |
| HistoryView.prototype.getVisitAfter_ = function(visit) { |
| var index = this.currentVisits_.indexOf(visit); |
| return index < 0 ? undefined : this.currentVisits_[index + 1]; |
| }; |
| |
| /** |
| * Swaps focus to |toBeFocused|. Assumes the another visit is currently focused. |
| * @param {Visit} visit A visit to focus. |
| * @private |
| */ |
| HistoryView.prototype.swapFocusedVisit_ = function(visit) { |
| if (!visit) |
| return; |
| |
| var control; |
| var activeVisit = findAncestorByClass(document.activeElement, 'entry').visit; |
| if (document.activeElement == activeVisit.checkBox) |
| control = visit.checkBox; |
| else if (document.activeElement == activeVisit.titleLink) |
| control = visit.titleLink; |
| else if (document.activeElement == activeVisit.dropDown) |
| control = visit.dropDown; |
| |
| visit.focusControl(control); |
| activeVisit.setIsLead(false); |
| }; |
| |
| /** |
| * @param {Event} e A keydown event to handle. |
| * @private |
| */ |
| HistoryView.prototype.handleKeydown_ = function(e) { |
| // Only handle up or down arrows on the focused element. |
| var keyCode = e.keyCode, target = e.target; |
| if (target != document.activeElement || !(keyCode == 38 || keyCode == 40)) |
| return; |
| |
| var entry = findAncestorByClass(e.target, 'entry'); |
| var visit = entry && entry.visit; |
| this.swapFocusedVisit_(keyCode == 38 ? this.getVisitBefore_(visit) : |
| this.getVisitAfter_(visit)); |
| }; |
| |
| /** |
| * @param {Event} e A mousedown event to handle. |
| * @private |
| */ |
| HistoryView.prototype.handleMousedown_ = function(e) { |
| var target = e.target; |
| var entry = findAncestorByClass(target, 'entry'); |
| if (!entry || !entry.contains(target)) |
| return; |
| |
| var visit = entry.visit; |
| if (visit.titleLink.contains(target)) |
| visit.focusControl(visit.titleLink); |
| else if (visit.dropDown && visit.dropDown.contains(target)) |
| visit.focusControl(visit.dropDown); |
| else // Focus the checkbox by default. If no checkbox, focus the title. |
| visit.focusControl(visit.checkBox || visit.titleLink); |
| |
| e.preventDefault(); |
| }; |
| |
| /** |
| * Ensures there's only 1 focusable visit. |
| * @private |
| */ |
| HistoryView.prototype.updateFocusableElements_ = function() { |
| var focusable = Array.prototype.slice.call( |
| this.resultDiv_.querySelectorAll('[tabindex="0"]')); |
| |
| // Don't change the tabIndex of the first [tabindex=0] node or the active |
| // element if either are in |focusable|. |
| focusable.splice(Math.max(0, focusable.indexOf(document.activeElement)), 1); |
| |
| for (var i = 0; i < focusable.length; ++i) { |
| var el = focusable[i]; |
| var entry = findAncestorByClass(el, 'entry'); |
| if (!entry.contains(document.activeElement)) |
| entry.visit.setIsLead(false); |
| else |
| el.tabIndex = -1; |
| } |
| |
| // If there's no focusable elements, allow the first visit to be focused. |
| if (!this.resultDiv_.querySelector('[tabindex="0"]') && |
| this.currentVisits_.length > 0) { |
| var firstVisit = this.currentVisits_[0]; |
| firstVisit.setIsLead(true); |
| (firstVisit.checkBox || firstVisit.titleLink).tabIndex = 0; |
| } |
| }; |
| |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // State object: |
| /** |
| * An 'AJAX-history' implementation. |
| * @param {HistoryModel} model The model we're representing. |
| * @param {HistoryView} view The view we're representing. |
| * @constructor |
| */ |
| function PageState(model, view) { |
| // Enforce a singleton. |
| if (PageState.instance) { |
| return PageState.instance; |
| } |
| |
| this.model = model; |
| this.view = view; |
| |
| if (typeof this.checker_ != 'undefined' && this.checker_) { |
| clearInterval(this.checker_); |
| } |
| |
| // TODO(glen): Replace this with a bound method so we don't need |
| // public model and view. |
| this.checker_ = window.setInterval(function(stateObj) { |
| var hashData = stateObj.getHashData(); |
| var page = parseInt(hashData.page, 10); |
| var range = parseInt(hashData.range, 10); |
| var offset = parseInt(hashData.offset, 10); |
| if (hashData.q != stateObj.model.getSearchText() || |
| page != stateObj.view.getPage() || |
| range != stateObj.model.rangeInDays || |
| offset != stateObj.model.offset) { |
| stateObj.view.setPageState(hashData.q, page, range, offset); |
| } |
| }, 50, this); |
| } |
| |
| /** |
| * Holds the singleton instance. |
| */ |
| PageState.instance = null; |
| |
| /** |
| * @return {Object} An object containing parameters from our window hash. |
| */ |
| PageState.prototype.getHashData = function() { |
| var result = { |
| q: '', |
| page: 0, |
| grouped: false, |
| range: 0, |
| offset: 0 |
| }; |
| |
| if (!window.location.hash) |
| return result; |
| |
| var hashSplit = window.location.hash.substr(1).split('&'); |
| for (var i = 0; i < hashSplit.length; i++) { |
| var pair = hashSplit[i].split('='); |
| if (pair.length > 1) { |
| result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' ')); |
| } |
| } |
| |
| return result; |
| }; |
| |
| /** |
| * Set the hash to a specified state, this will create an entry in the |
| * session history so the back button cycles through hash states, which |
| * are then picked up by our listener. |
| * @param {string} term The current search string. |
| * @param {number} page The page currently being viewed. |
| * @param {HistoryModel.Range} range The range to view or search over. |
| * @param {number} offset Set the begining of the query to the specific offset. |
| */ |
| PageState.prototype.setUIState = function(term, page, range, offset) { |
| // Make sure the form looks pretty. |
| $('search-field').value = term; |
| var hash = this.getHashData(); |
| if (hash.q != term || hash.page != page || hash.range != range || |
| hash.offset != offset) { |
| window.location.hash = PageState.getHashString(term, page, range, offset); |
| } |
| }; |
| |
| /** |
| * Static method to get the hash string for a specified state |
| * @param {string} term The current search string. |
| * @param {number} page The page currently being viewed. |
| * @param {HistoryModel.Range} range The range to view or search over. |
| * @param {number} offset Set the begining of the query to the specific offset. |
| * @return {string} The string to be used in a hash. |
| */ |
| PageState.getHashString = function(term, page, range, offset) { |
| // Omit elements that are empty. |
| var newHash = []; |
| |
| if (term) |
| newHash.push('q=' + encodeURIComponent(term)); |
| |
| if (page) |
| newHash.push('page=' + page); |
| |
| if (range) |
| newHash.push('range=' + range); |
| |
| if (offset) |
| newHash.push('offset=' + offset); |
| |
| return newHash.join('&'); |
| }; |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // Document Functions: |
| /** |
| * Window onload handler, sets up the page. |
| */ |
| function load() { |
| uber.onContentFrameLoaded(); |
| |
| var searchField = $('search-field'); |
| |
| historyModel = new HistoryModel(); |
| historyView = new HistoryView(historyModel); |
| pageState = new PageState(historyModel, historyView); |
| |
| // Create default view. |
| var hashData = pageState.getHashData(); |
| var grouped = (hashData.grouped == 'true') || historyModel.getGroupByDomain(); |
| var page = parseInt(hashData.page, 10) || historyView.getPage(); |
| var range = parseInt(hashData.range, 10) || historyView.getRangeInDays(); |
| var offset = parseInt(hashData.offset, 10) || historyView.getOffset(); |
| historyView.setPageState(hashData.q, page, range, offset); |
| |
| if ($('overlay')) { |
| cr.ui.overlay.setupOverlay($('overlay')); |
| cr.ui.overlay.globalInitialization(); |
| } |
| HistoryFocusManager.getInstance().initialize(); |
| |
| var doSearch = function(e) { |
| recordUmaAction('HistoryPage_Search'); |
| historyView.setSearch(searchField.value); |
| |
| if (isMobileVersion()) |
| searchField.blur(); // Dismiss the keyboard. |
| }; |
| |
| var mayRemoveVisits = loadTimeData.getBoolean('allowDeletingHistory'); |
| $('remove-visit').disabled = !mayRemoveVisits; |
| |
| if (mayRemoveVisits) { |
| $('remove-visit').addEventListener('activate', function(e) { |
| activeVisit.removeFromHistory(); |
| activeVisit = null; |
| }); |
| } |
| |
| if (!loadTimeData.getBoolean('showDeleteVisitUI')) |
| $('remove-visit').hidden = true; |
| |
| searchField.addEventListener('search', doSearch); |
| $('search-button').addEventListener('click', doSearch); |
| |
| $('more-from-site').addEventListener('activate', function(e) { |
| activeVisit.showMoreFromSite_(); |
| activeVisit = null; |
| }); |
| |
| // Only show the controls if the command line switch is activated. |
| if (loadTimeData.getBoolean('groupByDomain') || |
| loadTimeData.getBoolean('isManagedProfile')) { |
| // Hide the top container which has the "Clear browsing data" and "Remove |
| // selected entries" buttons since they're unavailable in managed mode |
| $('top-container').hidden = true; |
| $('history-page').classList.add('big-topbar-page'); |
| $('filter-controls').hidden = false; |
| } |
| |
| uber.setTitle(loadTimeData.getString('title')); |
| |
| // Adjust the position of the notification bar when the window size changes. |
| window.addEventListener('resize', |
| historyView.positionNotificationBar.bind(historyView)); |
| |
| cr.ui.FocusManager.disableMouseFocusOnButtons(); |
| |
| if (isMobileVersion()) { |
| // Move the search box out of the header. |
| var resultsDisplay = $('results-display'); |
| resultsDisplay.parentNode.insertBefore($('search-field'), resultsDisplay); |
| |
| window.addEventListener( |
| 'resize', historyView.updateClearBrowsingDataButton_); |
| |
| // When the search field loses focus, add a delay before updating the |
| // visibility, otherwise the button will flash on the screen before the |
| // keyboard animates away. |
| searchField.addEventListener('blur', function() { |
| setTimeout(historyView.updateClearBrowsingDataButton_, 250); |
| }); |
| |
| // Move the button to the bottom of the page. |
| $('history-page').appendChild($('clear-browsing-data')); |
| } else { |
| window.addEventListener('message', function(e) { |
| if (e.data.method == 'frameSelected') |
| searchField.focus(); |
| }); |
| searchField.focus(); |
| } |
| |
| <if expr="is_ios"> |
| function checkKeyboardVisibility() { |
| // Figure out the real height based on the orientation, becauase |
| // screen.width and screen.height don't update after rotation. |
| var screenHeight = window.orientation % 180 ? screen.width : screen.height; |
| |
| // Assume that the keyboard is visible if more than 30% of the screen is |
| // taken up by window chrome. |
| var isKeyboardVisible = (window.innerHeight / screenHeight) < 0.7; |
| |
| document.body.classList.toggle('ios-keyboard-visible', isKeyboardVisible); |
| } |
| window.addEventListener('orientationchange', checkKeyboardVisibility); |
| window.addEventListener('resize', checkKeyboardVisibility); |
| </if> /* is_ios */ |
| } |
| |
| /** |
| * Updates the managed filter status labels of a host/URL entry to the current |
| * value. |
| * @param {Element} statusElement The div which contains the status labels. |
| * @param {ManagedModeFilteringBehavior} newStatus The filter status of the |
| * current domain/URL. |
| */ |
| function updateHostStatus(statusElement, newStatus) { |
| var filteringBehaviorDiv = |
| statusElement.querySelector('.filtering-behavior'); |
| // Reset to the base class first, then add modifier classes if needed. |
| filteringBehaviorDiv.className = 'filtering-behavior'; |
| if (newStatus == ManagedModeFilteringBehavior.BLOCK) { |
| filteringBehaviorDiv.textContent = |
| loadTimeData.getString('filterBlocked'); |
| filteringBehaviorDiv.classList.add('filter-blocked'); |
| } else { |
| filteringBehaviorDiv.textContent = ''; |
| } |
| } |
| |
| /** |
| * Click handler for the 'Clear browsing data' dialog. |
| * @param {Event} e The click event. |
| */ |
| function openClearBrowsingData(e) { |
| recordUmaAction('HistoryPage_InitClearBrowsingData'); |
| chrome.send('clearBrowsingData'); |
| } |
| |
| /** |
| * Shows the dialog for the user to confirm removal of selected history entries. |
| */ |
| function showConfirmationOverlay() { |
| $('alertOverlay').classList.add('showing'); |
| $('overlay').hidden = false; |
| uber.invokeMethodOnParent('beginInterceptingEvents'); |
| } |
| |
| /** |
| * Hides the confirmation overlay used to confirm selected history entries. |
| */ |
| function hideConfirmationOverlay() { |
| $('alertOverlay').classList.remove('showing'); |
| $('overlay').hidden = true; |
| uber.invokeMethodOnParent('stopInterceptingEvents'); |
| } |
| |
| /** |
| * Shows the confirmation alert for history deletions and permits browser tests |
| * to override the dialog. |
| * @param {function=} okCallback A function to be called when the user presses |
| * the ok button. |
| * @param {function=} cancelCallback A function to be called when the user |
| * presses the cancel button. |
| */ |
| function confirmDeletion(okCallback, cancelCallback) { |
| alertOverlay.setValues( |
| loadTimeData.getString('removeSelected'), |
| loadTimeData.getString('deleteWarning'), |
| loadTimeData.getString('cancel'), |
| loadTimeData.getString('deleteConfirm'), |
| cancelCallback, |
| okCallback); |
| showConfirmationOverlay(); |
| } |
| |
| /** |
| * Click handler for the 'Remove selected items' button. |
| * Confirms the deletion with the user, and then deletes the selected visits. |
| */ |
| function removeItems() { |
| recordUmaAction('HistoryPage_RemoveSelected'); |
| if (!loadTimeData.getBoolean('allowDeletingHistory')) |
| return; |
| |
| var checked = $('results-display').querySelectorAll( |
| '.entry-box input[type=checkbox]:checked:not([disabled])'); |
| var disabledItems = []; |
| var toBeRemoved = []; |
| |
| for (var i = 0; i < checked.length; i++) { |
| var checkbox = checked[i]; |
| var entry = findAncestorByClass(checkbox, 'entry'); |
| toBeRemoved.push(entry.visit); |
| |
| // Disable the checkbox and put a strikethrough style on the link, so the |
| // user can see what will be deleted. |
| checkbox.disabled = true; |
| entry.visit.titleLink.classList.add('to-be-removed'); |
| disabledItems.push(checkbox); |
| var integerId = parseInt(entry.visit.id_, 10); |
| // Record the ID of the entry to signify how many entries are above this |
| // link on the page. |
| recordUmaHistogram('HistoryPage.RemoveEntryPosition', |
| UMA_MAX_BUCKET_VALUE, |
| integerId); |
| if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) { |
| recordUmaHistogram('HistoryPage.RemoveEntryPositionSubset', |
| UMA_MAX_SUBSET_BUCKET_VALUE, |
| integerId); |
| } |
| if (entry.parentNode.className == 'search-results') |
| recordUmaAction('HistoryPage_SearchResultRemove'); |
| } |
| |
| function onConfirmRemove() { |
| recordUmaAction('HistoryPage_ConfirmRemoveSelected'); |
| historyModel.removeVisitsFromHistory(toBeRemoved, |
| historyView.reload.bind(historyView)); |
| $('overlay').removeEventListener('cancelOverlay', onCancelRemove); |
| hideConfirmationOverlay(); |
| } |
| |
| function onCancelRemove() { |
| recordUmaAction('HistoryPage_CancelRemoveSelected'); |
| // Return everything to its previous state. |
| for (var i = 0; i < disabledItems.length; i++) { |
| var checkbox = disabledItems[i]; |
| checkbox.disabled = false; |
| |
| var entry = findAncestorByClass(checkbox, 'entry'); |
| entry.visit.titleLink.classList.remove('to-be-removed'); |
| } |
| $('overlay').removeEventListener('cancelOverlay', onCancelRemove); |
| hideConfirmationOverlay(); |
| } |
| |
| if (checked.length) { |
| confirmDeletion(onConfirmRemove, onCancelRemove); |
| $('overlay').addEventListener('cancelOverlay', onCancelRemove); |
| } |
| } |
| |
| /** |
| * Handler for the 'click' event on a checkbox. |
| * @param {Event} e The click event. |
| */ |
| function checkboxClicked(e) { |
| handleCheckboxStateChange(e.currentTarget, e.shiftKey); |
| } |
| |
| /** |
| * Post-process of checkbox state change. This handles range selection and |
| * updates internal state. |
| * @param {!HTMLInputElement} checkbox Clicked checkbox. |
| * @param {boolean} shiftKey true if shift key is pressed. |
| */ |
| function handleCheckboxStateChange(checkbox, shiftKey) { |
| updateParentCheckbox(checkbox); |
| var id = Number(checkbox.id.slice('checkbox-'.length)); |
| // Handle multi-select if shift was pressed. |
| if (shiftKey && (selectionAnchor != -1)) { |
| var checked = checkbox.checked; |
| // Set all checkboxes from the anchor up to the clicked checkbox to the |
| // state of the clicked one. |
| var begin = Math.min(id, selectionAnchor); |
| var end = Math.max(id, selectionAnchor); |
| for (var i = begin; i <= end; i++) { |
| var checkbox = document.querySelector('#checkbox-' + i); |
| if (checkbox) { |
| checkbox.checked = checked; |
| updateParentCheckbox(checkbox); |
| } |
| } |
| } |
| selectionAnchor = id; |
| |
| historyView.updateSelectionEditButtons(); |
| } |
| |
| /** |
| * Handler for the 'click' event on a domain checkbox. Checkes or unchecks the |
| * checkboxes of the visits to this domain in the respective group. |
| * @param {Event} e The click event. |
| */ |
| function domainCheckboxClicked(e) { |
| var siteEntry = findAncestorByClass(e.currentTarget, 'site-entry'); |
| var checkboxes = |
| siteEntry.querySelectorAll('.site-results input[type=checkbox]'); |
| for (var i = 0; i < checkboxes.length; i++) |
| checkboxes[i].checked = e.currentTarget.checked; |
| historyView.updateSelectionEditButtons(); |
| // Stop propagation as clicking the checkbox would otherwise trigger the |
| // group to collapse/expand. |
| e.stopPropagation(); |
| } |
| |
| /** |
| * Updates the domain checkbox for this visit checkbox if it has been |
| * unchecked. |
| * @param {Element} checkbox The checkbox that has been clicked. |
| */ |
| function updateParentCheckbox(checkbox) { |
| if (checkbox.checked) |
| return; |
| |
| var entry = findAncestorByClass(checkbox, 'site-entry'); |
| if (!entry) |
| return; |
| |
| var groupCheckbox = entry.querySelector('.site-domain-wrapper input'); |
| if (groupCheckbox) |
| groupCheckbox.checked = false; |
| } |
| |
| function entryBoxMousedown(event) { |
| // Prevent text selection when shift-clicking to select multiple entries. |
| if (event.shiftKey) |
| event.preventDefault(); |
| } |
| |
| /** |
| * Handle click event for entryBox labels. |
| * @param {!MouseEvent} event A click event. |
| */ |
| function entryBoxClick(event) { |
| // Do nothing if a bookmark star is clicked. |
| if (event.defaultPrevented) |
| return; |
| var element = event.target; |
| // Do nothing if the event happened in an interactive element. |
| for (; element != event.currentTarget; element = element.parentNode) { |
| switch (element.tagName) { |
| case 'A': |
| case 'BUTTON': |
| case 'INPUT': |
| return; |
| } |
| } |
| var checkbox = event.currentTarget.control; |
| checkbox.checked = !checkbox.checked; |
| handleCheckboxStateChange(checkbox, event.shiftKey); |
| // We don't want to focus on the checkbox. |
| event.preventDefault(); |
| } |
| |
| /** |
| * Called when an individual history entry has been removed from the page. |
| * This will only be called when all the elements affected by the deletion |
| * have been removed from the DOM and the animations have completed. |
| */ |
| function onEntryRemoved() { |
| historyView.onEntryRemoved(); |
| } |
| |
| /** |
| * Triggers a fade-out animation, and then removes |node| from the DOM. |
| * @param {Node} node The node to be removed. |
| * @param {Function?} onRemove A function to be called after the node |
| * has been removed from the DOM. |
| * @param {*=} opt_scope An optional scope object to call |onRemove| with. |
| */ |
| function removeNode(node, onRemove, opt_scope) { |
| node.classList.add('fade-out'); // Trigger CSS fade out animation. |
| |
| // Delete the node when the animation is complete. |
| node.addEventListener('webkitTransitionEnd', function(e) { |
| node.parentNode.removeChild(node); |
| |
| // In case there is nested deletion happening, prevent this event from |
| // being handled by listeners on ancestor nodes. |
| e.stopPropagation(); |
| |
| if (onRemove) |
| onRemove.call(opt_scope); |
| }); |
| } |
| |
| /** |
| * Toggles an element in the grouped history. |
| * @param {Element} e The element which was clicked on. |
| */ |
| function toggleHandler(e) { |
| var innerResultList = e.currentTarget.parentElement.querySelector( |
| '.site-results'); |
| var innerArrow = e.currentTarget.parentElement.querySelector( |
| '.site-domain-arrow'); |
| if (innerArrow.classList.contains('collapse')) { |
| innerResultList.style.height = 'auto'; |
| // -webkit-transition does not work on height:auto elements so first set |
| // the height to auto so that it is computed and then set it to the |
| // computed value in pixels so the transition works properly. |
| var height = innerResultList.clientHeight; |
| innerResultList.style.height = 0; |
| setTimeout(function() { |
| innerResultList.style.height = height + 'px'; |
| }, 0); |
| innerArrow.classList.remove('collapse'); |
| innerArrow.classList.add('expand'); |
| } else { |
| innerResultList.style.height = 0; |
| innerArrow.classList.remove('expand'); |
| innerArrow.classList.add('collapse'); |
| } |
| } |
| |
| /** |
| * Builds the DOM elements to show the managed status of a domain/URL. |
| * @param {ManagedModeFilteringBehavior} filteringBehavior The filter behavior |
| * for this item. |
| * @return {Element} Returns the DOM elements which show the status. |
| */ |
| function getManagedStatusDOM(filteringBehavior) { |
| var filterStatusDiv = createElementWithClassName('div', 'filter-status'); |
| var filteringBehaviorDiv = |
| createElementWithClassName('div', 'filtering-behavior'); |
| filterStatusDiv.appendChild(filteringBehaviorDiv); |
| |
| updateHostStatus(filterStatusDiv, filteringBehavior); |
| return filterStatusDiv; |
| } |
| |
| |
| /////////////////////////////////////////////////////////////////////////////// |
| // Chrome callbacks: |
| |
| /** |
| * Our history system calls this function with results from searches. |
| * @param {Object} info An object containing information about the query. |
| * @param {Array} results A list of results. |
| */ |
| function historyResult(info, results) { |
| historyModel.addResults(info, results); |
| } |
| |
| /** |
| * Called by the history backend when history removal is successful. |
| */ |
| function deleteComplete() { |
| historyModel.deleteComplete(); |
| } |
| |
| /** |
| * Called by the history backend when history removal is unsuccessful. |
| */ |
| function deleteFailed() { |
| window.console.log('Delete failed'); |
| } |
| |
| /** |
| * Called when the history is deleted by someone else. |
| */ |
| function historyDeleted() { |
| var anyChecked = document.querySelector('.entry input:checked') != null; |
| // Reload the page, unless the user has any items checked. |
| // TODO(dubroy): We should just reload the page & restore the checked items. |
| if (!anyChecked) |
| historyView.reload(); |
| } |
| |
| // Add handlers to HTML elements. |
| document.addEventListener('DOMContentLoaded', load); |
| |
| // This event lets us enable and disable menu items before the menu is shown. |
| document.addEventListener('canExecute', function(e) { |
| e.canExecute = true; |
| }); |