diff options
| author | Niklas Baumstark | 2012-01-21 00:23:59 +0100 |
|---|---|---|
| committer | Niklas Baumstark | 2012-04-10 23:54:35 +0200 |
| commit | 6f589fedcc826b125884e3a5884c9791802afb7f (patch) | |
| tree | eb117a99558b5456b0367157a09a12f887db8630 | |
| parent | 269042a28230bb35406d1447fac8955ca1a5c0b3 (diff) | |
| download | vimium-6f589fedcc826b125884e3a5884c9791802afb7f.tar.bz2 | |
add fuzzy mode
| -rw-r--r-- | background_page.html | 55 | ||||
| -rw-r--r-- | commands.js | 9 | ||||
| -rw-r--r-- | fuzzyMode.js | 166 | ||||
| -rw-r--r-- | lib/completion.js | 324 | ||||
| -rw-r--r-- | lib/keyboardUtils.js | 3 | ||||
| -rw-r--r-- | lib/utils.js | 67 | ||||
| -rw-r--r-- | manifest.json | 3 | ||||
| -rw-r--r-- | vimium.css | 70 |
8 files changed, 676 insertions, 21 deletions
diff --git a/background_page.html b/background_page.html index d8d3f75b..bca446a6 100644 --- a/background_page.html +++ b/background_page.html @@ -26,7 +26,9 @@ returnScrollPosition: handleReturnScrollPosition, getCurrentTabUrl: getCurrentTabUrl, settings: handleSettings, - getBookmarks: getBookmarks + getBookmarks: getBookmarks, + getAllBookmarks: getAllBookmarks, + getHistory: getHistory, }; var sendRequestHandlers = { @@ -276,6 +278,57 @@ }) } + function getAllBookmarks(args, port) { + function traverseTree(bookmarks, callback) { + for (var i = 0; i < bookmarks.length; ++i) { + callback(bookmarks[i]); + if (typeof bookmarks[i].children === "undefined") + continue; + traverseTree(bookmarks[i].children, callback); + } + }; + + chrome.bookmarks.getTree(function(bookmarks) { + var results = []; + traverseTree(bookmarks, function(bookmark) { + if (typeof bookmark.url === "undefined") + return; + results.push(bookmark); + }); + port.postMessage({bookmarks:results}); + }); + }; + + function getHistory(args, port) { + chrome.history.search({ text: '', + maxResults: args.maxResults || 1000 }, + function(history) { + // sort by visit cound descending + history.sort(function(a, b) { + // visitCount may be undefined + var visitCountForA = a.visitCount || 0; + var visitCountForB = a.visitCount || 0; + return visitCountForB - visitCountForA; + }); + var results = []; + for (var i = 0; i < history.length; ++i) { + results.push(history[i]); + } + port.postMessage({history:results}); + }); + }; + + /* + * Used by everyone to get settings from local storage. + */ + function getSettingFromLocalStorage(setting) { + if (localStorage[setting] != "" && !localStorage[setting]) { + return defaultSettings[setting]; + } else { + return localStorage[setting]; + } + } + function getCurrentTimeInSeconds() { Math.floor((new Date()).getTime() / 1000); } chrome.tabs.onSelectionChanged.addListener(function(tabId, selectionInfo) { diff --git a/commands.js b/commands.js index c961a901..1c19e26a 100644 --- a/commands.js +++ b/commands.js @@ -144,7 +144,10 @@ function clearKeyMappingsAndSetDefaults() { "b": "activateBookmarkFindMode", "B": "activateBookmarkFindModeToOpenInNewTab", - "gf": "nextFrame" + "o": "fuzzyMode.activate", + "O": "fuzzyMode.activateNewTab", + + "gf": "nextFrame", }; for (var key in defaultKeyMappings) @@ -211,6 +214,9 @@ var commandDescriptions = { activateBookmarkFindMode: ["Open a bookmark in the current tab"], activateBookmarkFindModeToOpenInNewTab: ["Open a bookmark in a new tab"], + 'fuzzyMode.activate': ["Open URL, bookmark, history entry or a custom search (fuzzy)"], + 'fuzzyMode.activateNewTab': ["Open URL, bookmark, history entry or a custom search (fuzzy, new tab)"], + nextFrame: ["Cycle forward to the next frame on the page", { background: true, passCountToFunction: true }] }; @@ -230,6 +236,7 @@ var commandGroups = { "enterInsertMode", "focusInput", "linkHints.activateMode", "linkHints.activateModeToOpenInNewTab", "linkHints.activateModeWithQueue", "activateBookmarkFindMode", "activateBookmarkFindModeToOpenInNewTab", + "fuzzyMode.activate", "fuzzyMode.activateNewTab", "goPrevious", "goNext", "nextFrame"], findCommands: ["enterFindMode", "performFind", "performBackwardsFind"], historyNavigation: diff --git a/fuzzyMode.js b/fuzzyMode.js new file mode 100644 index 00000000..ec2e351b --- /dev/null +++ b/fuzzyMode.js @@ -0,0 +1,166 @@ +var fuzzyMode = (function() { + /** Trigger the fuzzy mode dialog */ + var fuzzyBox = null; + function start(newTab) { + if (!fuzzyBox) { + var completer = new completion.MergingCompleter([ + new completion.SmartCompleter(), + new completion.FuzzyHistoryCompleter(1000), + new completion.FuzzyBookmarkCompleter(), + ]); + fuzzyBox = new FuzzyBox(completer); + } + fuzzyBox.show(newTab); + } + + /** User interface for fuzzy completion */ + var FuzzyBox = function(completer, reverseAction) { + this.prompt = '> '; + this.completer = completer; + this.initDom(); + this.reset(); + } + FuzzyBox.prototype = { + show: function(reverseAction) { + this.reverseAction = reverseAction; + this.completer.refresh(); + this.update(); + this.box.style.display = 'block'; + var self = this; + handlerStack.push({ keydown: function(event) { self.onKeydown(event); }}); + }, + + hide: function() { + this.box.style.display = 'none'; + handlerStack.pop(); + }, + + reset: function() { + this.query = ''; + this.completions = []; + this.selection = 0; + this.update(); + }, + + updateSelection: function() { + var items = this.completionList.childNodes; + for (var i = 0; i < items.length; ++i) { + items[i].className = (i == this.selection) ? 'selected' : ''; + } + }, + + onKeydown: function(event) { + var keyChar = getKeyChar(event); + + if (isEscape(event)) { + this.hide(); + } + + // move selection with Up/Down, Tab/Shift-Tab, Ctrl-k/Ctrl-j + else if (keyChar === 'up' || (event.keyCode == 9 && event.shiftKey) + || (keyChar === 'k' && event.ctrlKey)) { + if (this.selection > 0) + this.selection -= 1; + this.updateSelection(); + } + else if (keyChar === 'down' || (event.keyCode == 9 && !event.shiftKey) + || (keyChar === 'j' && isPrimaryModifierKey(event))) { + if (this.selection < this.completions.length - 1) + this.selection += 1; + this.updateSelection(); + } + + else if (event.keyCode == keyCodes.backspace) { + if (this.query.length > 0) { + this.query = this.query.substr(0, this.query.length-1); + this.update(); + } + } + + // use primary action with Enter. Holding down Shift/Ctrl uses the alternative action + // (opening in new tab) + else if (event.keyCode == keyCodes.enter) { + var alternative = (event.shiftKey || isPrimaryModifierKey(event)); + if (this.reverseAction) + alternative = !alternative; + this.completions[this.selection].action[alternative ? 1 : 0](); + this.hide(); + this.reset(); + } + + else if (keyChar.length == 1) { + this.query += keyChar; + this.update(); + } + + event.stopPropagation(); + event.preventDefault(); + return true; + }, + + update: function() { + this.query = this.query.replace(/^\s*/, ''); + this.input.textContent = this.query; + + // clear completions + this.completions = []; + while (this.completionList.hasChildNodes()) + this.completionList.removeChild(this.completionList.firstChild); + + if (this.query.length == 0) { + this.completionList.style.display = 'none'; + return; + } + + this.completionList.style.display = 'block'; + + var li; + var counter = 0; + var self = this; + this.completer.filter(this.query, function(completion) { + self.completions.push(completion); + li = document.createElement('li'); + li.innerHTML = completion.render(); + self.completionList.appendChild(li); + return ++counter < 10; + }); + + this.updateSelection(); + }, + + initDom: function() { + this.box = document.createElement('div'); + this.box.id = 'fuzzybox'; + this.box.className = 'vimiumReset'; + + var inputBox = document.createElement('div'); + inputBox.className = 'input'; + + var promptSpan = document.createElement('span'); + promptSpan.className = 'prompt'; + promptSpan.textContent = this.prompt; + + this.input = document.createElement('span'); + this.input.className = 'query'; + + inputBox.appendChild(promptSpan); + inputBox.appendChild(this.input); + + this.completionList = document.createElement('ul'); + + this.box.appendChild(inputBox); + this.box.appendChild(this.completionList); + + this.hide(); + document.body.appendChild(this.box); + }, + } + + // public interface + return { + activate: function() { start(false); }, + activateNewTab: function() { start(true); }, + } + +})(); + diff --git a/lib/completion.js b/lib/completion.js new file mode 100644 index 00000000..cad7d455 --- /dev/null +++ b/lib/completion.js @@ -0,0 +1,324 @@ +var completion = (function() { + + //============ Helper functions and objects ============// + + /** Singleton object that provides helpers and caching for fuzzy completion. */ + var fuzzyMatcher = (function() { + var self = {}; + + self.matcherCacheSize = 300; + self.regexNonWord = /\W*/g; + + // cache generated regular expressions + self.matcherCache = {}; + // cache filtered results from recent queries + self.filterCache = {}; + + /** Normalizes the string specified in :query. Strips any non-word characters and converts + * to lower case. */ + self.normalize = function(query) { + return query.replace(self.regexNonWord, '').toLowerCase(); + } + + /** Calculates a very simple similarity value between a :query and a :string. The current + * implementation simply returns the length of the longest prefix of :query that is found within :str. + */ + self.calculateRelevancy = function(query, str) { + query = self.normalize(query); + str = self.normalize(str); + for (var i = query.length; i >= 0; --i) { + if (str.indexOf(query.slice(0, i)) >= 0) + return i; + } + return 0; + } + + /** Trims the size of the regex cache to the configured size using a FIFO algorithm. */ + self.cleanMatcherCache = function() { + // remove old matchers + queries = Object.keys(self.matcherCache); + for (var i = 0; i < queries.length - self.matcherCacheSize; ++i) + delete self.matcherCache(queries[i]); + } + + /** Returns a regex that matches a string using a fuzzy :query. Example: The :query "abc" would result + * in a regex like /^([^a])*(a)([^b])*(b)([^c])*(c)(.*)$/ + */ + self.getMatcher = function(query) { + query = self.normalize(query); + if (!(query in self.matcherCache)) { + // build up a regex for fuzzy matching + var regex = '^'; + for (var i = 0; i < query.length; ++i) + regex += '([^' + query[i] + ']*)(' + query[i] + ')'; + self.matcherCache[query] = new RegExp(regex + '(.*)$', 'i'); + } + return self.matcherCache[query]; + } + + /** Clears the filter cache with the given ID. */ + self.clearFilterCache = function(id) { + if (id in self.filterCache) + delete self.filterCache[id]; + } + + /** Filters a list :ary using fuzzy matching against an input string :query. If a query with a less + * specific query was issued before (e.g. if the user added a letter to the query), the cached results + * of the last filtering are used as a starting point, instead of :ary. + */ + self.filter = function(query, ary, getValue, id, callback) { + var filtered = []; + var source = ary; + + if (!(id in self.filterCache)) + self.filterCache[id] = {}; + + // find the most specific list of sources in the cache + var maxSpecificity = 0; + for (key in self.filterCache[id]) { + if (!self.filterCache[id].hasOwnProperty(key)) + continue; + + if ((query.indexOf(key) != 0 && key.indexOf(query) != 0) || key.length > query.length) { + // cache entry no longer needed + delete self.filterCache[id][key]; + continue; + } + + // is this cache entry the most specific so far? + var specificity = self.filterCache[id][key].length; + if (query.indexOf(key) == 0 && specificity > maxSpecificity) { + source = self.filterCache[id][key]; + maxSpecificity = specificity; + } + } + + // clean up + self.cleanMatcherCache(); + + var matcher = self.getMatcher(query); + for (var i = 0; i < source.length; ++i) { + if (!matcher.test(getValue(source[i]))) + continue; + filtered.push(source[i]); + callback(source[i]); + } + self.filterCache[id][query] = filtered; + } + + return self; + })(); + + /** Creates an action that opens :url in the current tab by default or in a new tab as an alternative. */ + function createActionOpenUrl(url) { + return [ + function() { window.location = url; }, + function() { window.open(url); }, + ] + } + + /** Creates a completion that renders by marking fuzzy-matched parts. */ + function createHighlightingCompletion(query, str, action, relevancy) { + return { + render: function() { + var match = fuzzyMatcher.getMatcher(query).exec(str); + var html = ''; + for (var i = 1; i < match.length; ++i) { + if (i % 2 == 1) + html += match[i]; + else + html += '<strong>' + match[i] + '</strong>'; + }; + return html; + }, + action: action, + relevancy: relevancy, + } + } + + /** Helper class to construct fuzzy completers for asynchronous data sources like history or bookmark + * matchers. */ + var AsyncFuzzyUrlCompleter = function() { + this.completions = null; + this.id = utils.createUniqueId(); + this.readyCallback = function(results) { + this.completions = results; + } + } + AsyncFuzzyUrlCompleter.prototype = { + // to be implemented by subclasses + refresh: function() { }, + + calculateRelevancy: function(query, match) { + return match.url.length * 10 / (fuzzyMatcher.calculateRelevancy(query, match.str)+1); + }, + + filter: function(query, callback) { + var self = this; + + var handler = function(results) { + var filtered = []; + fuzzyMatcher.filter(query, + self.completions, function(comp) { return comp.str }, + self.id, + function(match) { + filtered.push(createHighlightingCompletion( + query, match.str, + createActionOpenUrl(match.url), + self.calculateRelevancy(query, match))); + }); + callback(filtered); + } + + // are the results ready? + if (this.completions !== null) { + // yes: call the callback synchronously + handler(this.completions); + } else { + // no: push our handler to the handling chain + var oldReadyCallback = this.readyCallback; + this.readyCallback = function(results) { + handler(results); + this.readyCallback = oldReadyCallback; + oldReadyCallback(results); + } + } + }, + } + + //========== Completer implementations ===========// + + /** A simple completer that suggests to open the input string as an URL or to trigger a web search for the + * given term, depending on whether it thinks the input is an URL or not. */ + var SmartCompleter = function() { + this.refresh = function() { }; + + this.filter = function(query, callback) { + var url; + var str; + + if (utils.isUrl(query)) { + url = utils.createFullUrl(query); + str = '<em>goto</em> ' + url; + } else { + url = utils.createSearchUrl(query); + str = '<em>search</em> ' + query; + } + + // call back with exactly one suggestion + callback([{ + render: function() { return str; }, + action: createActionOpenUrl(url), + // relevancy will always be the lowest one, so the suggestion is at the top + relevancy: -1, + }]); + }; + } + + /** A fuzzy history completer */ + var FuzzyHistoryCompleter = function(maxEntries) { + AsyncFuzzyUrlCompleter.call(this); + this.maxEntries = maxEntries || 1000; + } + FuzzyHistoryCompleter.prototype = new AsyncFuzzyUrlCompleter; + FuzzyHistoryCompleter.prototype.refresh = function() { + this.completions = null; // reset completions + + // asynchronously fetch history items + var port = chrome.extension.connect({ name: "getHistory" }) ; + var self = this; + port.onMessage.addListener(function(msg) { + var results = []; + + for (var i = 0; i < msg.history.length; ++i) { + var historyItem = msg.history[i]; + var title = ''; + + if (historyItem.title.length > 0) + title = ' (' + historyItem.title + ')'; + + results.push({ + str: 'history: ' + historyItem.url + title, + url: historyItem.url, + }); + } + port = null; + self.readyCallback(results); + }); + port.postMessage({ maxResults: this.maxEntries }); + } + + /** A fuzzy bookmark completer */ + var FuzzyBookmarkCompleter = function() { + AsyncFuzzyUrlCompleter.call(this); + } + FuzzyBookmarkCompleter.prototype = new AsyncFuzzyUrlCompleter; + FuzzyBookmarkCompleter.prototype.refresh = function() { + this.completions = null; // reset completions + + var port = chrome.extension.connect({ name: "getAllBookmarks" }) ; + var self = this; + port.onMessage.addListener(function(msg) { + var results = []; + + for (var i = 0; i < msg.bookmarks.length; ++i) { + var bookmark = msg.bookmarks[i]; + + var title = ''; + if (bookmark.title.length > 0) + title = ' (' + bookmark.title + ')'; + + results.push({ + str: 'bookmark: ' + bookmark.url + title, + url: bookmark.url, + }); + } + port = null; + self.readyCallback(results); + }); + port.postMessage(); + } + + /** A meta-completer that delegates queries and merges and sorts the results of a collection of other + * completer instances. */ + var MergingCompleter = function(sources) { + this.sources = sources; + } + MergingCompleter.prototype = { + refresh: function() { + for (var i = 0; i < this.sources.length; ++i) + this.sources[i].refresh(); + }, + + filter: function(query, callback) { + var all = []; + var counter = this.sources.length; + + for (var i = 0; i < this.sources.length; ++i) { + this.sources[i].filter(query, function(results) { + all = all.concat(results); + if (--counter > 0) + return; + + // all sources have provided results by now, so we can sort and return + all.sort(function(a,b) { + return a.relevancy - b.relevancy; + }); + for (var i = 0; i < all.length; ++i) { + if (!callback(all[i])) + // the caller doesn't want any more results + return; + } + }); + } + } + } + + // public interface + return { + FuzzyHistoryCompleter: FuzzyHistoryCompleter, + FuzzyBookmarkCompleter: FuzzyBookmarkCompleter, + SmartCompleter: SmartCompleter, + MergingCompleter: MergingCompleter, + }; +})(); diff --git a/lib/keyboardUtils.js b/lib/keyboardUtils.js index 98725d95..84e860ba 100644 --- a/lib/keyboardUtils.js +++ b/lib/keyboardUtils.js @@ -47,7 +47,8 @@ function getKeyChar(event) { keyIdentifier = event.shiftKey ? correctedIdentifiers[1] : correctedIdentifiers[0]; } var unicodeKeyInHex = "0x" + keyIdentifier.substring(2); - return String.fromCharCode(parseInt(unicodeKeyInHex)).toLowerCase(); + var character = String.fromCharCode(parseInt(unicodeKeyInHex)).toLowerCase(); + return event.shiftKey ? character.toUpperCase() : character; } function isPrimaryModifierKey(event) { diff --git a/lib/utils.js b/lib/utils.js index 8aada3a1..922e7db3 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -21,17 +21,26 @@ var utils = { }, /** - * Creates a search URL from the given :query. + * Generates a unique ID */ - createSearchUrl: function(query) { - return "http://www.google.com/search?q=" + query; + createUniqueId: (function() { + id = 0; + return function() { return ++id; }; + })(), + + /** + * Completes a partial URL (without scheme) + */ + createFullUrl: function(partialUrl) { + if (!/^[a-z]{3,}:\/\//.test(partialUrl)) + partialUrl = 'http://' + partialUrl; + return partialUrl }, /** - * Tries to convert :str into a valid URL. - * We don't bother with escaping characters, however, as Chrome will do that for us. + * Tries to detect, whether :str is a valid URL. */ - ensureUrl: function(str) { + isUrl: function(str) { // more or less RFC compliant URL host part parsing. This should be sufficient // for our needs var urlRegex = new RegExp( @@ -55,38 +64,60 @@ var utils = { // it starts with a scheme, so it's definitely an URL if (/^[a-z]{3,}:\/\//.test(str)) - return str; - var strWithScheme = 'http://' + str; + return true; - // definitely not a valid URL; treat as search query + // spaces => definitely not a valid URL if (str.indexOf(' ') >= 0) - return utils.createSearchUrl(str); + return false; // assuming that this is an URL, try to parse it into its meaningful parts. If matching fails, we're // pretty sure that we don't have some kind of URL here. var match = urlRegex.exec(str.split('/')[0]); if (!match) - return utils.createSearchUrl(str); + return false; var hostname = match[3]; // allow known special host names if (specialHostNames.indexOf(hostname) >= 0) - return strWithScheme; + return true; // allow IPv6 addresses (need to be wrapped in brackets, as required by RFC). It is sufficient to check // for a colon here, as the regex wouldn't match colons in the host name unless it's an v6 address if (hostname.indexOf(':') >= 0) - return strWithScheme; + return true; // at this point we have to make a decision. As a heuristic, we check if the input has dots in it. If - // yes, and if the last part could be a TLD, treat it as an URL + // yes, and if the last part could be a TLD, treat it as an URL. var dottedParts = hostname.split('.'); var lastPart = dottedParts[dottedParts.length-1]; - if (dottedParts.length > 1 && (lastPart.length <= 3 || longTlds.indexOf(lastPart) >= 0)) - return strWithScheme; + if (dottedParts.length > 1 && ((lastPart.length >= 2 && lastPart.length <= 3) + || longTlds.indexOf(lastPart) >= 0)) + return true; + + // also allow IPv4 addresses + if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) + return true; + + // fallback: no URL + return false + }, - // fallback: use as search query - return utils.createSearchUrl(str); + /** + * Creates a search URL from the given :query. + */ + createSearchUrl: function(query) { + return "http://www.google.com/search?q=" + query; + }, + + /** + * Tries to convert :str into a valid URL. + * We don't bother with escaping characters, however, as Chrome will do that for us. + */ + ensureUrl: function(str) { + if (utils.isUrl(str)) + return utils.createFullUrl(str); + else + return utils.createSearchUrl(str); }, }; diff --git a/manifest.json b/manifest.json index 72631a94..a14e2fef 100644 --- a/manifest.json +++ b/manifest.json @@ -10,6 +10,7 @@ "permissions": [ "tabs", "bookmarks", + "history", "clipboardRead", "<all_urls>" ], @@ -20,7 +21,9 @@ "lib/keyboardUtils.js", "lib/domUtils.js", "lib/clipboard.js", + "lib/completion.js", "linkHints.js", + "fuzzyMode.js", "vimiumFrontend.js", "completionDialog.js", "bookmarks.js" @@ -282,4 +282,74 @@ div.vimium-completions div.vimium-noResults{ body.vimiumFindMode ::selection { background: #ff9632; +}; + +/* fuzzymode CSS */ + +#fuzzybox ol, #fuzzybox ul { + list-style: none; +} + +#fuzzybox { + position: fixed; + width: 80%; + top: 70px; + left: 50%; + margin: 0 0 0 -40%; + background: black; + color: white; + font-family: sans-serif; + font-size: 30px; + text-align: left; + padding: 7px 20px 7px 20px; + opacity: 0.9; + border-radius: 10px; + box-shadow: 5px 5px 5px rgba(0,0,0,0.5); + z-index: 99999998; +} + +#fuzzybox ul { + list-style: none; + padding: 7px 0 0 0; + margin: 7px 0 0 0; + border-top: 2px solid #444; +} + +#fuzzybox li { + border-bottom: 1px solid #111; + line-height: 1.1em; + padding: 7px; + margin: 0 -7px 0 -7px; + font-size: 18px; + color: #aaa; +} + +#fuzzybox li strong { + color: red; +} + +#fuzzybox li em { + color: #444; + font-weight: normal; + font-style: italic; +} + +#fuzzybox li.selected { + background: #222; + color: #ccc; + border-radius: 4px; +} +#fuzzybox li.selected em { + color: #666; +} + +#fuzzybox .input { + font-size: 28px; + padding: 0; + margin: 0; +} + +#fuzzybox .input .prompt { + color: orange; + content: '>> '; } |
