diff options
| -rw-r--r-- | fuzzyMode.js | 8 | ||||
| -rw-r--r-- | lib/completion.js | 256 |
2 files changed, 140 insertions, 124 deletions
diff --git a/fuzzyMode.js b/fuzzyMode.js index cbefcde4..04fa2666 100644 --- a/fuzzyMode.js +++ b/fuzzyMode.js @@ -150,12 +150,12 @@ var fuzzyMode = (function() { updateCompletions: function() { var self = this; - this.completer.filter(this.query, function(completions) { - self.completions = completions.slice(0, self.maxResults); + this.completer.filter(this.query, this.maxResults, function(completions) { + self.completions = completions; // clear completions - self.completionList.innerHTML = self.completions.map(function(completion) { - return '<li>' + completion.render() + '</li>'; + self.completionList.innerHTML = completions.map(function(completion) { + return '<li>' + completion.html + '</li>'; }).join(''); self.completionList.style.display = self.completions.length > 0 ? 'block' : 'none'; diff --git a/lib/completion.js b/lib/completion.js index 68f8d4bf..1915a83a 100644 --- a/lib/completion.js +++ b/lib/completion.js @@ -195,62 +195,54 @@ var completion = (function() { return [ open(false), open(true, true), open(true, false) ]; } - /** Creates a completion that renders by marking fuzzy-matched parts. */ - function createHighlightingCompletion(query, str, action, relevancy) { - return { - action: action, - relevancy: relevancy, - - render: function() { - // we want to match the content in HTML tags, but not the HTML tags themselves, so we remove the - // tags and reinsert them after the matching process - var htmlTags = {}; - str = stripHtmlTags(str, htmlTags); - var groups = fuzzyMatcher.getMatchGroups(query, str); - var html = ''; - var htmlOffset = 0; - - // this helper function adds the HTML generated _for one single character_ to the HTML output - // and reinserts HTML tags stripped before, if they were at this position - function addToHtml(str) { - if (htmlOffset in htmlTags) - html += htmlTags[htmlOffset]; - html += str; - ++htmlOffset; - } - - function addCharsWithDecoration(str, before, after) { - before = before || ''; - after = after || ''; - for (var i = 0; i < str.length; ++i) - addToHtml(before + str[i] + after); - } - - // iterate over the match groups. They are non-matched and matched string parts, in alternating order - for (var i = 0; i < groups.length; ++i) { - if (i % 2 == 0) - // we have a non-matched part, it could have several characters. We need to insert them character - // by character, so that addToHtml can keep track of the position in the original string - addCharsWithDecoration(groups[i]); - else - // we have a matched part. In addition to the characters themselves, we add some decorating HTML. - addCharsWithDecoration(groups[i], '<span class="fuzzyMatch">', '</span>'); - }; - - // call it another time so that a tag at the very last position is reinserted - addToHtml(''); - - return html; - }, - } - } - /** Creates an file-internal representation of a URL match with the given paramters */ - function createCompletionHtml(type, url, title) { + function createCompletionHtml(type, str, title) { title = title || ''; // sanitize input, it could come from a malicious web site title = title.length > 0 ? ' <span class="title">' + utils.escapeHtml(title) + '</span>' : ''; - return '<em>' + type + '</em> ' + utils.escapeHtml(url) + title; + return '<em>' + type + '</em> ' + utils.escapeHtml(str) + title; + } + + /** Renders a completion by marking fuzzy-matched parts. */ + function renderFuzzy(query, html) { + // we want to match the content in HTML tags, but not the HTML tags themselves, so we remove the + // tags and reinsert them after the matching process + var htmlTags = {}; + var groups = fuzzyMatcher.getMatchGroups(query, stripHtmlTags(html, htmlTags)); + + html = []; + var htmlOffset = 0; + + // this helper function adds the HTML generated _for one single character_ to the HTML output + // and reinserts HTML tags stripped before, if they were at this position + function addToHtml(str) { + if (htmlOffset in htmlTags) + html.push(htmlTags[htmlOffset]); + html.push(str); + ++htmlOffset; + } + + function addCharsWithDecoration(str, before, after) { + before = before || ''; + after = after || ''; + for (var i = 0; i < str.length; ++i) + addToHtml(before + str[i] + after); + } + + // iterate over the match groups. They are non-matched and matched string parts, in alternating order + for (var i = 0; i < groups.length; ++i) { + if (i % 2 == 0) + // we have a non-matched part, it could have several characters. We need to insert them character + // by character, so that addToHtml can keep track of the position in the original string + addCharsWithDecoration(groups[i]); + else + // we have a matched part. In addition to the characters themselves, we add some decorating HTML. + addCharsWithDecoration(groups[i], '<span class="fuzzyMatch">', '</span>'); + }; + + // call it another time so that a tag at the very last position is reinserted + addToHtml(''); + return html.join(''); } /** Creates a function that returns a constant value */ @@ -258,27 +250,24 @@ var completion = (function() { return function() { return x; } } + /** A completion class that only holds a relevancy value and a function to get HTML and action + * properties */ + var LazyCompletion = function(relevancy, builder) { + this.relevancy = relevancy; + this.build = builder; + } + /** Helper class to construct fuzzy completers for asynchronous data sources like history or bookmark * matchers. */ - var AsyncFuzzyUrlCompleter = function() { + var AsyncCompleter = function() { this.completions = null; this.id = utils.createUniqueId(); this.readyCallback = this.fallbackReadyCallback = function(results) { this.completions = results; } - this.extractStringFromMatch = function(match) { return stripHtmlTags(match.str); } } - AsyncFuzzyUrlCompleter.prototype = { - calculateRelevancy: function(query, match) { - return match.url.length / - (fuzzyMatcher.calculateRelevancy(query, this.extractStringFromMatch(match)) + 1); - }, - - createAction: function(match) { - return createActionOpenUrl(match.url); - }, - - /** Convenience function to remove shared code in the completers. Clear the completion cache, sends + AsyncCompleter.prototype = { + /** Convenience function to remove shared code in the completers. Clears the completion cache, sends * a message to an extension port and pipes the returned message through a callback before storing it into * the instance's completion cache. */ @@ -298,17 +287,44 @@ var completion = (function() { fuzzyMatcher.invalidateFilterCache(this.id); }, + /** Convenience function to remove shared code in the completers. Creates an internal representation of + * a fuzzy completion item that is still independent of the query. The bind function will be called with + * the actual query as an argument later. */ + createInternalMatch: function(type, item, action) { + var url = item.url; + var parts = [type, url, item.title]; + var str = parts.join(' '); + action = action || createActionOpenUrl(url); + + function createLazyCompletion(query) { + return new LazyCompletion(url.length / fuzzyMatcher.calculateRelevancy(query, str), function() { + return { + html: renderFuzzy(query, createCompletionHtml.apply(null, parts)), + action: action, + }}); + } + + // add one more layer of indirection: For filtering, we only need the string to match. + // Only after we reduced the number of possible results, we call :bind on them to get + // an actual completion object + return { + str: parts.join(' '), + bind: createLazyCompletion, + } + }, + + // Default to handle results using fuzzy matching. This can be overridden by subclasses. + processResults: function(query, results) { + results = fuzzyMatcher.filter(query, results, function(match) { return match.str }, this.id); + // bind the query-agnostic, lazy results to a query + return results.map(function(result) { return result.bind(query); }); + }, + filter: function(query, callback) { var self = this; var handler = function(results) { - var filtered = fuzzyMatcher.filter(query, results, self.extractStringFromMatch, self.id); - callback(filtered.map(function(match) { - return createHighlightingCompletion( - query, match.str, - self.createAction(match), - self.calculateRelevancy(query, match)); - })); + callback(self.processResults(query, results)); } // are the results ready? @@ -319,8 +335,8 @@ var completion = (function() { // no: register the handler as a callback this.readyCallback = function(results) { handler(results); - this.readyCallback = this.fallbackReadyCallback; - this.readyCallback(results); + self.readyCallback = self.fallbackReadyCallback; + self.readyCallback(results); } } }, @@ -344,85 +360,86 @@ var completion = (function() { var pattern = commands[cmd][1]; var url = typeof pattern == 'function' ? pattern(term) : pattern.replace(/%s/g, term); - return { - render: function() { return createCompletionHtml(desc, term) }, - action: createActionOpenUrl(utils.createFullUrl(url)), - relevancy: -2 // this will appear even before the URL/search suggestion - }; + // this will appear even before the URL/search suggestion + return new LazyCompletion(-2, function() { + return { + html: createCompletionHtml(desc, term), + action: createActionOpenUrl(utils.createFullUrl(url)), + }}) }); } /** Checks if the input is a URL. If yes, returns a suggestion to visit it. If no, returns a suggestion * to start a web search. */ - this.getUrlOrSearchSuggestions = function(query, suggestions) { + this.getUrlOrSearchSuggestion = function(query, suggestions) { // trim query query = query.replace(/^\s+|\s+$/g, ''); var isUrl = utils.isUrl(query); - return [{ - render: function() { return createCompletionHtml(isUrl ? 'goto' : 'search', query); }, - action: createActionOpenUrl(isUrl ? utils.createFullUrl(query) - : utils.createSearchUrl(query)), - relevancy: -1, // low relevancy so this should appear at the top - }]; + return new LazyCompletion(-1, function() { + return { + html: createCompletionHtml(isUrl ? 'goto' : 'search', query), + action: createActionOpenUrl(isUrl ? utils.createFullUrl(query) + : utils.createSearchUrl(query)), + }}); } this.filter = function(query, callback) { - callback(this.getCommandSuggestions(query).concat( - this.getUrlOrSearchSuggestions(query))); + suggestions = this.getCommandSuggestions(query); + suggestions.push(this.getUrlOrSearchSuggestion(query)); + callback(suggestions); } } /** A fuzzy history completer */ var FuzzyHistoryCompleter = function(maxResults) { - AsyncFuzzyUrlCompleter.call(this); + AsyncCompleter.call(this); this.maxResults = maxResults || 1000; } - utils.extend(AsyncFuzzyUrlCompleter, FuzzyHistoryCompleter); + utils.extend(AsyncCompleter, FuzzyHistoryCompleter); + FuzzyHistoryCompleter.prototype.refresh = function() { - this.resetCache(); - this.fetchFromPort('getHistory', { maxResults: this.maxResults }, function(msg) { - return msg.history.map(function(historyItem) { - return { str: createCompletionHtml('history', historyItem.url, historyItem.title), - url: historyItem.url }; + var self = this; + self.resetCache(); + self.fetchFromPort('getHistory', { maxResults: self.maxResults }, function(msg) { + return msg.history.map(function(item) { + return self.createInternalMatch('history', item); }); }); } /** A fuzzy bookmark completer */ var FuzzyBookmarkCompleter = function() { - AsyncFuzzyUrlCompleter.call(this); + AsyncCompleter.call(this); } - utils.extend(AsyncFuzzyUrlCompleter, FuzzyBookmarkCompleter); + utils.extend(AsyncCompleter, FuzzyBookmarkCompleter); + FuzzyBookmarkCompleter.prototype.refresh = function() { - this.resetCache(); - this.fetchFromPort('getAllBookmarks', {}, function(msg) { + var self = this; + self.resetCache(); + self.fetchFromPort('getAllBookmarks', {}, function(msg) { return msg.bookmarks.filter(function(bookmark) { return bookmark.url !== undefined }) .map(function(bookmark) { - return { str: createCompletionHtml('bookmark', bookmark.url, bookmark.title), - url: bookmark.url }; - }) + return self.createInternalMatch('bookmark', bookmark); + }); }); } /** A fuzzy tab completer */ var FuzzyTabCompleter = function() { - AsyncFuzzyUrlCompleter.call(this); - } - utils.extend(AsyncFuzzyUrlCompleter, FuzzyTabCompleter); - FuzzyTabCompleter.prototype.createAction = function(match) { - var open = function() { - chrome.extension.sendRequest({ handler: 'selectSpecificTab', id: match.tab.id }); - } - return [ open, open ]; + AsyncCompleter.call(this); } + utils.extend(AsyncCompleter, FuzzyTabCompleter); + FuzzyTabCompleter.prototype.refresh = function() { - this.resetCache(); - this.fetchFromPort('getTabsInCurrentWindow', {}, function(msg) { + var self = this; + self.resetCache(); + self.fetchFromPort('getTabsInCurrentWindow', {}, function(msg) { return msg.tabs.map(function(tab) { - return { str: createCompletionHtml('tab', tab.url, tab.title), - url: tab.url, - tab: tab }; + var open = function() { + chrome.extension.sendRequest({ handler: 'selectSpecificTab', id: tab.id }); + } + return self.createInternalMatch('tab', tab, [open, open, open]); }); }); } @@ -441,7 +458,7 @@ var completion = (function() { this.sources.forEach(function(x) { x.refresh(); }); }, - filter: function(query, callback) { + filter: function(query, maxResults, callback) { if (query.length < this.queryThreshold) { callback([]); return; @@ -457,10 +474,9 @@ var completion = (function() { return; // all sources have provided results by now, so we can sort and return - all.sort(function(a,b) { - return a.relevancy - b.relevancy; - }); - callback(all); + all.sort(function(a,b) { return a.relevancy - b.relevancy; }); + // evalulate lazy completions for the top n results + callback(all.slice(0, maxResults).map(function(result) { return result.build(); })); }); }); } |
