// Copyright 2013 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Matcher which maintains a client-side cache on top of some * other matcher. * @author reinerp@google.com (Reiner Pope) */ goog.provide('goog.ui.ac.CachingMatcher'); goog.require('goog.array'); goog.require('goog.async.Throttle'); goog.require('goog.ui.ac.ArrayMatcher'); goog.require('goog.ui.ac.RenderOptions'); /** * A matcher which wraps another (typically slow) matcher and * keeps a client-side cache of the results. For instance, you can use this to * wrap a RemoteArrayMatcher to hide the latency of the underlying matcher * having to make ajax request. * * Objects in the cache are deduped on their stringified forms. * * Note - when the user types a character, they will instantly get a set of * local results, and then some time later, the results from the server will * show up. * * @constructor * @param {!Object} baseMatcher The underlying matcher to use. Must implement * requestMatchingRows. * @final */ goog.ui.ac.CachingMatcher = function(baseMatcher) { /** @private {!Array}} The cache. */ this.rows_ = []; /** * Set of stringified rows, for fast deduping. Each element of this.rows_ * is stored in rowStrings_ as (' ' + row) to ensure we avoid builtin * properties like 'toString'. * @private {Object} */ this.rowStrings_ = {}; /** * Maximum number of rows in the cache. If the cache grows larger than this, * the entire cache will be emptied. * @private {number} */ this.maxCacheSize_ = 1000; /** @private {!Object} The underlying matcher to use. */ this.baseMatcher_ = baseMatcher; /** * Local matching function. * @private {function(string, number, !Array): !Array} */ this.getMatchesForRows_ = goog.ui.ac.ArrayMatcher.getMatchesForRows; /** @private {number} Number of matches to request from the base matcher. */ this.baseMatcherMaxMatches_ = 100; /** @private {goog.async.Throttle} */ this.throttledTriggerBaseMatch_ = new goog.async.Throttle(this.triggerBaseMatch_, 150, this); /** @private {string} */ this.mostRecentToken_ = ''; /** @private {Function} */ this.mostRecentMatchHandler_ = null; /** @private {number} */ this.mostRecentMaxMatches_ = 10; /** * The set of rows which we last displayed. * * NOTE(reinerp): The need for this is subtle. When a server result comes * back, we don't want to suddenly change the list of results without the user * doing anything. So we make sure to add the new server results to the end of * the currently displayed list. * * We need to keep track of the last rows we displayed, because the "similar * matcher" we use locally might otherwise reorder results. * * @private {Array} */ this.mostRecentMatches_ = []; }; /** * Sets the number of milliseconds with which to throttle the match requests * on the underlying matcher. * * Default value: 150. * * @param {number} throttleTime . */ goog.ui.ac.CachingMatcher.prototype.setThrottleTime = function(throttleTime) { this.throttledTriggerBaseMatch_ = new goog.async.Throttle(this.triggerBaseMatch_, throttleTime, this); }; /** * Sets the maxMatches to use for the base matcher. If the base matcher makes * AJAX requests, it may help to make this a large number so that the local * cache gets populated quickly. * * Default value: 100. * * @param {number} maxMatches The value to set. */ goog.ui.ac.CachingMatcher.prototype.setBaseMatcherMaxMatches = function( maxMatches) { this.baseMatcherMaxMatches_ = maxMatches; }; /** * Sets the maximum size of the local cache. If the local cache grows larger * than this size, it will be emptied. * * Default value: 1000. * * @param {number} maxCacheSize . */ goog.ui.ac.CachingMatcher.prototype.setMaxCacheSize = function(maxCacheSize) { this.maxCacheSize_ = maxCacheSize; }; /** * Sets the local matcher to use. * * The local matcher should be a function with the same signature as * {@link goog.ui.ac.ArrayMatcher.getMatchesForRows}, i.e. its arguments are * searchToken, maxMatches, rowsToSearch; and it returns a list of matching * rows. * * Default value: {@link goog.ui.ac.ArrayMatcher.getMatchesForRows}. * * @param {function(string, number, !Array): !Array} * localMatcher */ goog.ui.ac.CachingMatcher.prototype.setLocalMatcher = function(localMatcher) { this.getMatchesForRows_ = localMatcher; }; /** * Function used to pass matches to the autocomplete. * @param {string} token Token to match. * @param {number} maxMatches Max number of matches to return. * @param {Function} matchHandler callback to execute after matching. */ goog.ui.ac.CachingMatcher.prototype.requestMatchingRows = function( token, maxMatches, matchHandler) { this.mostRecentMaxMatches_ = maxMatches; this.mostRecentToken_ = token; this.mostRecentMatchHandler_ = matchHandler; this.throttledTriggerBaseMatch_.fire(); var matches = this.getMatchesForRows_(token, maxMatches, this.rows_); matchHandler(token, matches); this.mostRecentMatches_ = matches; }; /** Clears the cache. */ goog.ui.ac.CachingMatcher.prototype.clearCache = function() { this.rows_ = []; this.rowStrings_ = {}; }; /** * Adds the specified rows to the cache. * @param {!Array} rows . * @private */ goog.ui.ac.CachingMatcher.prototype.addRows_ = function(rows) { goog.array.forEach(rows, function(row) { // The ' ' prefix is to avoid colliding with builtins like toString. if (!this.rowStrings_[' ' + row]) { this.rows_.push(row); this.rowStrings_[' ' + row] = true; } }, this); }; /** * Checks if the cache is larger than the maximum cache size. If so clears it. * @private */ goog.ui.ac.CachingMatcher.prototype.clearCacheIfTooLarge_ = function() { if (this.rows_.length > this.maxCacheSize_) { this.clearCache(); } }; /** * Triggers a match request against the base matcher. This function is * unthrottled, so don't call it directly; instead use * this.throttledTriggerBaseMatch_. * @private */ goog.ui.ac.CachingMatcher.prototype.triggerBaseMatch_ = function() { this.baseMatcher_.requestMatchingRows( this.mostRecentToken_, this.baseMatcherMaxMatches_, goog.bind(this.onBaseMatch_, this)); }; /** * Handles a match response from the base matcher. * @param {string} token The token against which the base match was called. * @param {!Array} matches The matches returned by the base matcher. * @private */ goog.ui.ac.CachingMatcher.prototype.onBaseMatch_ = function(token, matches) { // NOTE(reinerp): The user might have typed some more characters since the // base matcher request was sent out, which manifests in that token might be // older than this.mostRecentToken_. We make sure to do our local matches // using this.mostRecentToken_ rather than token so that we display results // relevant to what the user is seeing right now. // NOTE(reinerp): We compute a diff between the currently displayed results // and the new results we would get now that the server results have come // back. Using this diff, we make sure the new results are only added to the // end of the list of results. See the documentation on // this.mostRecentMatches_ for details this.addRows_(matches); var oldMatchesSet = {}; goog.array.forEach(this.mostRecentMatches_, function(match) { // The ' ' prefix is to avoid colliding with builtins like toString. oldMatchesSet[' ' + match] = true; }); var newMatches = this.getMatchesForRows_( this.mostRecentToken_, this.mostRecentMaxMatches_, this.rows_); newMatches = goog.array.filter( newMatches, function(match) { return !(oldMatchesSet[' ' + match]); }); newMatches = this.mostRecentMatches_.concat(newMatches) .slice(0, this.mostRecentMaxMatches_); this.mostRecentMatches_ = newMatches; // We've gone to the effort of keeping the existing rows as before, so let's // make sure to keep them highlighted. var options = new goog.ui.ac.RenderOptions(); options.setPreserveHilited(true); this.mostRecentMatchHandler_(this.mostRecentToken_, newMatches, options); // We clear the cache *after* running the local match, so we don't // suddenly remove results just because the remote match came back. this.clearCacheIfTooLarge_(); };