diff options
| -rw-r--r-- | .gitmodules | 3 | ||||
| -rw-r--r-- | CREDITS | 5 | ||||
| -rw-r--r-- | README.markdown | 38 | ||||
| -rw-r--r-- | background_page.html | 137 | ||||
| -rw-r--r-- | bookmarks.js | 131 | ||||
| -rw-r--r-- | commands.js | 47 | ||||
| -rw-r--r-- | completionDialog.js | 180 | ||||
| -rw-r--r-- | helpDialog.html | 27 | ||||
| -rw-r--r-- | lib/keyboardUtils.js | 2 | ||||
| -rw-r--r-- | lib/utils.js | 33 | ||||
| -rw-r--r-- | linkHints.js | 892 | ||||
| -rw-r--r-- | manifest.json | 10 | ||||
| -rw-r--r-- | options.html | 87 | ||||
| -rw-r--r-- | test_harnesses/automated.html | 252 | ||||
| -rw-r--r-- | test_harnesses/iframe.html | 2 | ||||
| m--------- | test_harnesses/shoulda.js | 0 | ||||
| -rw-r--r-- | vimiumFrontend.js | 444 |
17 files changed, 1653 insertions, 637 deletions
diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..d496d533 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "test_harnesses/shoulda.js"] + path = test_harnesses/shoulda.js + url = git://github.com/philc/shoulda.js.git @@ -10,18 +10,23 @@ Contributors: Christian Stefanescu (github: stchris) ConradIrwin drizzd + gpurkins hogelog int3 Johannes Emerich (github: knuton) Julian Naydichev <rublind@gmail.com> (github: naydichev) lack markstos + Matthew Cline <matt@nightrealms.com> + Murph (github: pandeiro) rodimius Tim Morgan <tim@timmorgan.org> (github: seven1m) tsigo Werner Laurensse (github: ab3) Svein-Erik Larsen <feinom@gmail.com> (github: feinom) + Bill Casarin <jb@jb55.com> (github: jb55) R.T. Lechow <rtlechow@gmail.com> (github: rtlechow) + Justin Blake <justin@hentzia.com> (github: blaix) Wang Ning <daning106@gmail.com> (github:daning) Feel free to add real names in addition to GitHub usernames. diff --git a/README.markdown b/README.markdown index 6adb3714..1a9294f5 100644 --- a/README.markdown +++ b/README.markdown @@ -26,6 +26,7 @@ Modifier keys are specified as <c-x> <m-x>, <a-x> for ctrl+x, respectively. See the next section for instructions on modifying these bindings. Navigating the current page: + ? show the help dialog for a list of all available keys h scroll left j scroll down @@ -39,29 +40,34 @@ Navigating the current page: F open a link in a new tab r reload gs view source - zi zoom in - zo zoom out i enter insert mode -- all commands will be ignored until you hit esc to exit yy copy the current url to the clipboard + yf copy a link url to the clipboard gf cycle forward to the next frame Using find: + / enter find mode -- type your search query and hit enter to search or esc to cancel n cycle forward to the next find match N cycle backward to the previous find match Navigating your history: + H go back in history L go forward in history Manipulating tabs: + J, gT go one tab left K, gt go one tab right + g0 go to the first tab + g$ go to the last tab t create tab x close current tab X restore closed tab (i.e. unwind the 'x' command) Additional advanced browsing commands: + ]] Follow the link labeled 'next' or '>'. Helpful for browsing paginated sites. [[ Follow the link labeled 'previous' or '<'. Helpful for browsing paginated sites. <a-f> open multiple links in a new tab @@ -69,8 +75,6 @@ Additional advanced browsing commands: gu go up one level in the URL hierarchy zH scroll all the way left zL scroll all the way right - z0 reset zoom to default value - Vimium supports command repetition so, for example, hitting '5t' will open 5 tabs in rapid succession. ESC (or <c-[>) will clear any partial commands in the queue and will also exit insert and find modes. @@ -131,9 +135,30 @@ don't exceed 110 characters. Release Notes ------------- -1.27 +1.30 (12/04/2011) + +- Support for image maps in link hints. +- Counts now work with forward & backward navigation. +- Tab & shift-tab to navigate bookmarks dialog. +- An alternate link hints mode: type the title of a link to select it. You can enable it in Vimium's Advanced Preferences. +- Bug fixes. + +1.29 (07/30/2011) + +- `yf` to copy a link hint url to the clipboard. +- Scatter link hints to prevent clustering on dense sites. +- Don't show insert mode notification unless you specifically hit `i`. +- Remove zooming functionality now that Chrome does it all natively. + +1.28 (06/29/2011) + +- Support for opening bookmarks (`b` and `B`). +- Support for contenteditable text boxes. +- Speed improvements and bugfixes. + +1.27 (03/24/2011) -- Bugfixes. +- Improvements and bugfixes. 1.26 (02/17/2011) @@ -145,7 +170,6 @@ Release Notes - Some sites are now excluded by default. - View source (`gs`) now opens in a new tab. - Support for browsing paginated sites using `]]` and `[[` to go forward and backward respectively. -- `z0` will reset the zoom level for the current page. - Many of the less-used commands are now marked as "advanced" and hidden in the help dialog by default, so that the core command set is more focused and approachable. - Improvements to link hinting. diff --git a/background_page.html b/background_page.html index 2be1e7b2..7d8f749d 100644 --- a/background_page.html +++ b/background_page.html @@ -25,15 +25,23 @@ var defaultSettings = { scrollStepSize: 60, - defaultZoomLevel: 100, linkHintCharacters: "sadfjklewcmpgh", + filterLinkHints: false, userDefinedLinkHintCss: ".vimiumHintMarker {\n\n}\n" + ".vimiumHintMarker > .matchingCharacter {\n\n}", excludedUrls: "http*://mail.google.com/*\n" + "http*://www.google.com/reader/*\n", - previousPatterns: "\\bprev\\b,\\bprevious\\b,\\u00AB,<<,<", - nextPatterns: "\\bnext\\b,\\u00BB,>>,\\bmore\\b,>" + + // NOTE : If a page contains both a single angle-bracket link and a double angle-bracket link, then in + // most cases the single bracket link will be "prev/next page" and the double bracket link will be + // "first/last page", so we put the single bracket first in the pattern string so that it gets searched + // for first. + + // "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<" + previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<", + // "\bnext\b,\bmore\b,>,→,»,≫,>>" + nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>", }; // This is the base internal link hints CSS. It's combined with the userDefinedLinkHintCss before @@ -65,13 +73,12 @@ keyDown: handleKeyDown, returnScrollPosition: handleReturnScrollPosition, getCurrentTabUrl: getCurrentTabUrl, - getZoomLevel: getZoomLevel, - saveZoomLevel: saveZoomLevel, - getSetting: getSetting + getSetting: getSetting, + getBookmarks: getBookmarks }; var sendRequestHandlers = { - getCompletionKeys: getCompletionKeys, + getCompletionKeys: getCompletionKeysRequest, getLinkHintCss: getLinkHintCss, openUrlInNewTab: openUrlInNewTab, openUrlInCurrentTab: openUrlInCurrentTab, @@ -81,6 +88,7 @@ upgradeNotificationClosed: upgradeNotificationClosed, updateScrollPosition: handleUpdateScrollPosition, copyToClipboard: copyToClipboard, + copyLinkUrl: copyLinkUrl, isEnabledForUrl: isEnabledForUrl, saveHelpDialogSettings: saveHelpDialogSettings }; @@ -158,17 +166,6 @@ localStorage["helpDialog_showAdvancedCommands"] = request.showAdvancedCommands; } - /* - * Returns the previously saved zoom level for the current tab, or the default zoom level - */ - function getZoomLevel(args, port) { - var returnPort = chrome.tabs.connect(port.tab.id, { name: "returnZoomLevel" }); - var localStorageKey = "zoom" + args.domain; - var zoomLevelForDomain = (localStorage[localStorageKey] || "").split(",")[1]; - var zoomLevel = parseInt(zoomLevelForDomain || getSettingFromLocalStorage("defaultZoomLevel")); - returnPort.postMessage({ zoomLevel: zoomLevel }); - } - function showHelp(callback, frameId) { chrome.tabs.getSelected(null, function(tab) { chrome.tabs.sendRequest(tab.id, @@ -236,8 +233,11 @@ /** * Returns the keys that can complete a valid command given the current key queue. */ - function getCompletionKeys(request) { - return {completionKeys: generateCompletionKeys()}; + function getCompletionKeysRequest(request) { + return { name: "refreshCompletionKeys", + completionKeys: generateCompletionKeys(), + validFirstKeys: validFirstKeys + }; } /** @@ -258,6 +258,14 @@ chrome.tabs.create({ url: request.url, index: tab.index + 1, selected: request.selected }); }); } + + /** + * Copies url of selected link to the clipboard (wget ftw) + */ + function copyLinkUrl(request) { + Clipboard.copy(request.data); + } + /* * Returns the core CSS used for link hints, along with any user-provided overrides. */ @@ -290,6 +298,12 @@ returnPort.postMessage({ key: args.key, value: value }); } + function getBookmarks(args, port) { + chrome.bookmarks.search(args.query, function(bookmarks) { + port.postMessage({bookmarks:bookmarks}) + }) + } + /* * Used by everyone to get settings from local storage. */ @@ -301,16 +315,6 @@ } } - /* - * Persists the current zoom level for a given domain - */ - function saveZoomLevel(args) { - var localStorageKey = "zoom" + args.domain; - // TODO(philc): We might want to consider expiring these entries after X months as NoSquint does. - // Note(philc): We might also want to jsonify this hash instead of polluting our local storage keyspace. - localStorage[localStorageKey] = [getCurrentTimeInSeconds(), args.zoomLevel].join(","); - } - function getCurrentTimeInSeconds() { Math.floor((new Date()).getTime() / 1000); } chrome.tabs.onSelectionChanged.addListener(function(tabId, selectionInfo) { @@ -337,23 +341,34 @@ function nextTab(callback) { selectTab(callback, "next"); } function previousTab(callback) { selectTab(callback, "previous"); } + function firstTab(callback) { selectTab(callback, "first"); } + function lastTab(callback) { selectTab(callback, "last"); } /* - * Selects a tab before or after the currently selected tab. Direction is either "next" or "previous". + * Selects a tab before or after the currently selected tab. Direction is either "next", "previous", "first" or "last". */ function selectTab(callback, direction) { chrome.tabs.getAllInWindow(null, function(tabs) { if (tabs.length <= 1) return; - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].selected) { - var delta = (direction == "next") ? 1 : -1; - var toSelect = tabs[(i + delta + tabs.length) % tabs.length]; + chrome.tabs.getSelected(null, function(currentTab) { + switch (direction) { + case "next": + toSelect = tabs[(currentTab.index + 1 + tabs.length) % tabs.length]; + break; + case "previous": + toSelect = tabs[(currentTab.index - 1 + tabs.length) % tabs.length]; + break; + case "first": + toSelect = tabs[0]; + break; + case "last": + toSelect = tabs[tabs.length - 1]; + break; + } selectionChangedHandlers.push(callback); chrome.tabs.update(toSelect.id, { selected: true }); - break; - } - } + }); }); } @@ -368,6 +383,8 @@ function updateOpenTabs(tab) { openTabs[tab.id] = { url: tab.url, positionIndex: tab.index, windowId: tab.windowId }; + // Frames are recreated on refresh + delete framesForTab[tab.id]; } function handleUpdateScrollPosition(request, sender) { @@ -502,7 +519,7 @@ populateValidFirstKeys(); populateSingleKeyCommands(); - sendRequestToAllTabs({ name: "refreshCompletionKeys", completionKeys: generateCompletionKeys() }); + sendRequestToAllTabs(getCompletionKeysRequest()); } /* @@ -574,7 +591,11 @@ refreshedCompletionKeys = true; } else { - repeatFunction(this[registryEntry.command], count, 0, frameId); + if(registryEntry.passCountToFunction){ + this[registryEntry.command](count); + } else { + repeatFunction(this[registryEntry.command], count, 0, frameId); + } } newKeyQueue = ""; @@ -592,10 +613,8 @@ // If we haven't sent the completion keys piggybacked on executePageCommand, // send them by themselves. - if (!refreshedCompletionKeys) - { - var port = chrome.tabs.connect(tabId, { name: "refreshCompletionKeys" }); - port.postMessage({ completionKeys: generateCompletionKeys(newKeyQueue) }); + if (!refreshedCompletionKeys) { + chrome.tabs.sendRequest(tabId, getCompletionKeysRequest(), null); } return newKeyQueue; @@ -648,7 +667,7 @@ if (!framesForTab[sender.tab.id]) framesForTab[sender.tab.id] = { frames: [] }; - if (request.top) { + if (request.is_top) { focusedFrame = request.frameId; framesForTab[sender.tab.id].total = request.total; } @@ -681,25 +700,29 @@ focusedFrame = request.frameId; } - function nextFrame(callback, frameId) { + function nextFrame(count) { chrome.tabs.getSelected(null, function(tab) { - var index; var frames = framesForTab[tab.id].frames; + var curr_index = getCurrFrameIndex(frames); - for (index=0; index < frames.length; index++) { - if (frames[index].id == focusedFrame) - break; - } - - if (index >= frames.length-1) - index = 0; - else - index++; + // TODO: Skip the "top" frame (which doesn't actually have a <frame> tag), + // since it exists only to contain the other frames. + var new_index = (curr_index + count) % frames.length; - chrome.tabs.sendRequest(tab.id, { name: "focusFrame", frameId: frames[index].id, highlight: true }); + chrome.tabs.sendRequest(tab.id, { name: "focusFrame", frameId: frames[new_index].id, highlight: true }); }); } + function getCurrFrameIndex(frames) { + var index; + for (index=0; index < frames.length; index++) { + if (frames[index].id == focusedFrame) + break; + } + return index; + } + + function init() { clearKeyMappingsAndSetDefaults(); diff --git a/bookmarks.js b/bookmarks.js new file mode 100644 index 00000000..9056c731 --- /dev/null +++ b/bookmarks.js @@ -0,0 +1,131 @@ +function activateBookmarkFindModeToOpenInNewTab() { + BookmarkMode.openInNewTab(true); + BookmarkMode.enable(); +} + +function activateBookmarkFindMode() { + BookmarkMode.openInNewTab(false); + BookmarkMode.enable(); +} + +(function() { + // so when they let go of shift after hitting capital "B" it won't + // untoggle it + var shiftWasPressedWhileToggled = false; + + var BookmarkMode = { + isEnabled: function() { + return this.enabled; + }, + openInNewTab: function(newTab) { + this.newTab = newTab; + }, + invertNewTabSetting: function() { + this.newTab = !this.newTab; + if(this.isEnabled()) { + this.renderHUD(); + } + }, + enable: function() { + this.enabled = true; + + if(!this.initialized) { + initialize.call(this); + } + + handlerStack.push({ + keydown: this.onKeydown, + keyup: this.onKeyup + }); + + this.renderHUD(); + this.completionDialog.show(); + }, + disable: function() { + this.enabled = false; + this.completionDialog.hide(); + handlerStack.pop(); + HUD.hide(); + }, + renderHUD: function() { + if (this.newTab) + HUD.show("Open bookmark in new tab"); + else + HUD.show("Open bookmark in current tab"); + } + + } + + // private method + var initialize = function() { + var self = this; + self.initialized = true; + + self.completionDialog = new CompletionDialog({ + source: findBookmarks, + + onSelect: function(selection) { + var url = selection.url; + var isABookmarklet = function(url) { return url.indexOf("javascript:") === 0; } + + if (!self.newTab || isABookmarklet(url)) + window.location = url; + else + window.open(url); + + self.disable(); + }, + + renderOption: function(searchString, selection) { + var displaytext = selection.title + " (" + selection.url + ")" + if (displaytext.length > 70) + displaytext = displaytext.substr(0, 70) + "..."; + + return displaytext.split(new RegExp(searchString, "i")).join("<strong>"+searchString+"</strong>") + }, + + initialSearchText: "Type a bookmark name or URL" + }) + + self.onKeydown = function(event) { + // shift key will toggle between new tab/same tab + if (event.keyCode == keyCodes.shiftKey) { + self.invertNewTabSetting(); + shiftWasPressedWhileToggled = true; + return; + } + + var keyChar = getKeyChar(event); + if (!keyChar) + return; + + // TODO(philc): Ignore keys that have modifiers. + if (isEscape(event)) + self.disable(); + + event.stopPropagation(); + event.preventDefault(); + }; + + self.onKeyup = function(event) { + // shift key will toggle between new tab/same tab + if (event.keyCode == keyCodes.shiftKey && shiftWasPressedWhileToggled) { + self.invertNewTabSetting(); + shiftWasPressedWhileToggled = false; + } + event.stopPropagation(); + event.preventDefault(); + }; + } + + var findBookmarks = function(searchString, callback) { + var port = chrome.extension.connect({ name: "getBookmarks" }) ; + port.onMessage.addListener(function(msg) { + callback(msg.bookmarks); + port = null; + }) + port.postMessage({query:searchString}); + }; + + window.BookmarkMode = BookmarkMode; +}()) diff --git a/commands.js b/commands.js index be01a180..d214d2bf 100644 --- a/commands.js +++ b/commands.js @@ -111,15 +111,11 @@ function clearKeyMappingsAndSetDefaults() { "L": "goForward", "gu": "goUp", - "zi": "zoomIn", - "zo": "zoomOut", - "z0": "zoomReset", - "gi": "focusInput", - "f": "activateLinkHintsMode", - "F": "activateLinkHintsModeToOpenInNewTab", - "<a-f>": "activateLinkHintsModeWithQueue", + "f": "linkHints.activateMode", + "F": "linkHints.activateModeToOpenInNewTab", + "<a-f>": "linkHints.activateModeWithQueue", "/": "enterFindMode", "n": "performFind", @@ -129,16 +125,22 @@ function clearKeyMappingsAndSetDefaults() { "]]": "goNext", "yy": "copyCurrentUrl", + "yf": "linkHints.activateModeToCopyLinkUrl", "K": "nextTab", "J": "previousTab", "gt": "nextTab", "gT": "previousTab", + "g0": "firstTab", + "g$": "lastTab", "t": "createTab", "x": "removeTab", "X": "restoreTab", + "b": "activateBookmarkFindMode", + "B": "activateBookmarkFindModeToOpenInNewTab", + "gf": "nextFrame" }; @@ -166,18 +168,17 @@ var commandDescriptions = { reload: ["Reload the page"], toggleViewSource: ["View page source"], - zoomIn: ["Zoom in"], - zoomOut: ["Zoom out"], - zoomReset: ["Reset zoom to default value"], copyCurrentUrl: ["Copy the current URL to the clipboard"], + 'linkHints.activateModeToCopyLinkUrl': ["Copy a link URL to the clipboard"], + enterInsertMode: ["Enter insert mode"], focusInput: ["Focus the first (or n-th) text box on the page", { passCountToFunction: true }], - activateLinkHintsMode: ["Open a link in the current tab"], - activateLinkHintsModeToOpenInNewTab: ["Open a link in a new tab"], - activateLinkHintsModeWithQueue: ["Open multiple links in a new tab"], + 'linkHints.activateMode': ["Open a link in the current tab"], + 'linkHints.activateModeToOpenInNewTab': ["Open a link in a new tab"], + 'linkHints.activateModeWithQueue': ["Open multiple links in a new tab"], enterFindMode: ["Enter find mode"], performFind: ["Cycle forward to the next find match"], @@ -187,8 +188,8 @@ var commandDescriptions = { goNext: ["Follow the link labeled next or >"], // Navigating your history - goBack: ["Go back in history"], - goForward: ["Go forward in history"], + goBack: ["Go back in history", { passCountToFunction: true }], + goForward: ["Go forward in history", { passCountToFunction: true }], // Navigating the URL hierarchy goUp: ["Go up the URL hierarchy", { passCountToFunction: true }], @@ -196,11 +197,16 @@ var commandDescriptions = { // Manipulating tabs nextTab: ["Go one tab right", { background: true }], previousTab: ["Go one tab left", { background: true }], + firstTab: ["Go to the first tab", { background: true }], + lastTab: ["Go to the last tab", { background: true }], createTab: ["Create new tab", { background: true }], removeTab: ["Close current tab", { background: true }], restoreTab: ["Restore closed tab", { background: true }], - nextFrame: ["Cycle forward to the next frame on the page", { background: true }] + activateBookmarkFindMode: ["Open a bookmark in the current tab"], + activateBookmarkFindModeToOpenInNewTab: ["Open a bookmark in a new tab"], + + nextFrame: ["Cycle forward to the next frame on the page", { background: true, passCountToFunction: true }] }; for (var command in commandDescriptions) @@ -214,15 +220,16 @@ var commandGroups = { ["scrollDown", "scrollUp", "scrollLeft", "scrollRight", "scrollToTop", "scrollToBottom", "scrollToLeft", "scrollToRight", "scrollPageDown", "scrollPageUp", "scrollFullPageUp", "scrollFullPageDown", - "reload", "toggleViewSource", "zoomIn", "zoomOut", "zoomReset", "copyCurrentUrl", "goUp", + "reload", "toggleViewSource", "copyCurrentUrl", "linkHints.activateModeToCopyLinkUrl", "goUp", "enterInsertMode", "focusInput", - "activateLinkHintsMode", "activateLinkHintsModeToOpenInNewTab", "activateLinkHintsModeWithQueue", + "linkHints.activateMode", "linkHints.activateModeToOpenInNewTab", "linkHints.activateModeWithQueue", + "activateBookmarkFindMode", "activateBookmarkFindModeToOpenInNewTab", "goPrevious", "goNext", "nextFrame"], findCommands: ["enterFindMode", "performFind", "performBackwardsFind"], historyNavigation: ["goBack", "goForward"], tabManipulation: - ["nextTab", "previousTab", "createTab", "removeTab", "restoreTab"], + ["nextTab", "previousTab", "firstTab", "lastTab", "createTab", "removeTab", "restoreTab"], misc: ["showHelp"] }; @@ -232,5 +239,5 @@ var commandGroups = { // from Vimium will uncover these gems. var advancedCommands = [ "scrollToLeft", "scrollToRight", - "zoomReset", "goUp", "focusInput", "activateLinkHintsModeWithQueue", + "goUp", "focusInput", "linkHints.activateModeWithQueue", "goPrevious", "goNext"]; diff --git a/completionDialog.js b/completionDialog.js new file mode 100644 index 00000000..fae034fd --- /dev/null +++ b/completionDialog.js @@ -0,0 +1,180 @@ +(function(window, document) { + + var CompletionDialog = function(options) { this.options = options; } + + CompletionDialog.prototype = { + show: function() { + if (!this.isShown) { + this.isShown=true; + this.query = []; + if (!this.initialized) { + initialize.call(this); + this.initialized = true; + } + handlerStack.push({ keydown: this.onKeydown }); + render.call(this); + clearInterval(this._tweenId); + this.container.style.display = ""; + this._tweenId = Tween.fade(this.container, 1.0, 150); + } + }, + + hide: function() { + if (this.isShown) { + handlerStack.pop(); + this.isShown = false; + this.currentSelection = 0; + clearInterval(this._tweenId); + var completionContainer = this.container; + var cssHide = function() { completionContainer.style.display = "none"; } + this._tweenId = Tween.fade(this.container, 0, 150, cssHide); + } + }, + + getDisplayElement: function() { + if (!this.container) + this.container = createDivInside(document.body); + return this.container; + }, + + getQueryString: function() { return this.query.join(""); } + } + + var initialize = function() { + var self = this; + addCssToPage(completionCSS); + + self.currentSelection = 0; + + self.onKeydown = function(event) { + var keyChar = getKeyChar(event); + // change selection with up or Shift-Tab + if (keyChar==="up" || (event.keyCode == 9 && event.shiftKey)) { + if (self.currentSelection>0) { + self.currentSelection-=1; + } + render.call(self,self.getQueryString(), self.completions); + } + // change selection with down or Tab + else if (keyChar==="down" || (event.keyCode == 9 && !event.shiftKey)) { + if (self.currentSelection < self.completions.length - 1) { + self.currentSelection += 1; + } + render.call(self,self.getQueryString(), self.completions); + } + else if (event.keyCode == keyCodes.enter) { + self.options.onSelect(self.completions[self.currentSelection]); + } + else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { + if (self.query.length > 0) { + self.query.pop(); + self.options.source(self.getQueryString(), function(completions) { + render.call(self, self.getQueryString(), completions); + }) + } + } + else if (keyChar!=="left" && keyChar!="right") { + self.query.push(keyChar); + self.options.source(self.getQueryString(), function(completions) { + render.call(self, self.getQueryString(), completions); + }); + } + + event.stopPropagation(); + event.preventDefault(); + return true; + } + } + + var render = function(searchString, completions) { + if (this.isShown) { + this.searchString = searchString; + this.completions = completions; + var container = this.getDisplayElement(); + clearChildren(container); + + if (searchString === undefined) { + this.container.className = "vimium-dialog"; + createDivInside(container).innerHTML = this.options.initialSearchText || "Begin typing"; + } + else { + this.container.className = "vimium-dialog vimium-completions"; + var searchBar = createDivInside(container); + searchBar.innerHTML=searchString; + searchBar.className="vimium-searchBar"; + + searchResults = createDivInside(container); + searchResults.className="vimium-searchResults"; + if (completions.length<=0) { + var resultDiv = createDivInside(searchResults); + resultDiv.className="vimium-noResults"; + resultDiv.innerHTML="No results found"; + } + else { + for (var i = 0; i < completions.length; i++) { + var resultDiv = createDivInside(searchResults); + if (i === this.currentSelection) { + resultDiv.className="vimium-selected"; + } + resultDiv.innerHTML=this.options.renderOption(searchString, completions[i]); + } + } + } + + container.style.top = Math.max(0, (window.innerHeight/2-container.clientHeight/2)) + "px"; + container.style.left = (window.innerWidth/2-container.clientWidth/2) + "px"; + } + }; + var createDivInside = function(parent) { + var element = document.createElement("div"); + parent.appendChild(element); + return element; + } + + var clearChildren = function(elem) { + if (elem.hasChildNodes()) { + while (elem.childNodes.length >= 1) { + elem.removeChild(elem.firstChild); + } + } + } + + var completionCSS = ".vimium-dialog {"+ + "position:fixed;"+ + "background-color: #ebebeb;" + + "z-index: 99999998;" + + "border: 1px solid #b3b3b3;" + + "font-size: 12px;" + + "text-align:left;"+ + "color: black;" + + "padding:10px;"+ + "border-radius: 4px;" + + "font-family: Lucida Grande, Arial, Sans;" + + "}"+ + ".vimium-completions {"+ + "width:400px;"+ + "}"+ + ".vimium-completions .vimium-searchBar {"+ + "height: 15px;"+ + "border-bottom: 1px solid #b3b3b3;"+ + "}"+ + ".vimium-completions .vimium-searchResults {"+ + "}"+ + ".vimium-completions .vimium-searchResults .vimium-selected{"+ + "background-color:#aaa;"+ + "border-radius: 4px;" + + "}"+ + ".vimium-completions div{"+ + "padding:4px;"+ + "}"+ + ".vimium-completions div strong{"+ + "color: black;" + + "font-weight:bold;"+ + "}"+ + ".vimium-completions .vimium-noResults{"+ + "color:#555;"+ + "}"; + + window.CompletionDialog = CompletionDialog; + +}(window, document)) diff --git a/helpDialog.html b/helpDialog.html index 51ff1692..5ded51f2 100644 --- a/helpDialog.html +++ b/helpDialog.html @@ -28,6 +28,22 @@ top:50px; -webkit-box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 6px; z-index:99999998; + overflow-y: scroll; + } + @media screen and (max-height: 600px) { + #vimiumHelpDialog { + height: 430px; + } + } + @media screen and (max-height: 500px) { + #vimiumHelpDialog { + height: 330px; + } + } + @media screen and (max-height: 400px) { + #vimiumHelpDialog { + height: 230px; + } } #vimiumHelpDialog a { color:blue; } #vimiumTitle, #vimiumTitle * { font-size:20px; } @@ -141,7 +157,6 @@ this.dialogElement.getElementsByClassName("toggleAdvancedCommands")[0].addEventListener("click", VimiumHelpDialog.toggleAdvancedCommands, false); this.showAdvancedCommands(this.advancedCommandsVisible); - this.centerDialog(); }, /* @@ -153,7 +168,6 @@ chrome.extension.sendRequest({ handler: "saveHelpDialogSettings", showAdvancedCommands: VimiumHelpDialog.advancedCommandsVisible }); VimiumHelpDialog.showAdvancedCommands(VimiumHelpDialog.advancedCommandsVisible); - VimiumHelpDialog.centerDialog(); }, showAdvancedCommands: function(visible) { @@ -162,14 +176,7 @@ var advanced = VimiumHelpDialog.dialogElement.getElementsByClassName("advanced"); for (var i = 0; i < advanced.length; i++) advanced[i].style.display = (visible ? "table-row" : "none"); - }, - - centerDialog: function() { - var zoomFactor = currentZoomLevel / 100.0; - this.dialogElement.style.top = Math.max( - (window.innerHeight - this.dialogElement.clientHeight * zoomFactor) / 2.0, - 20) / zoomFactor + "px"; - } + } }; VimiumHelpDialog.init(); diff --git a/lib/keyboardUtils.js b/lib/keyboardUtils.js index fe3dcd59..98725d95 100644 --- a/lib/keyboardUtils.js +++ b/lib/keyboardUtils.js @@ -44,7 +44,7 @@ function getKeyChar(event) { // https://bugs.webkit.org/show_bug.cgi?id=19906 for more details. if ((platform == "Windows" || platform == "Linux") && keyIdentifierCorrectionMap[keyIdentifier]) { correctedIdentifiers = keyIdentifierCorrectionMap[keyIdentifier]; - keyIdentifier = event.shiftKey ? correctedIdentifiers[0] : correctedIdentifiers[1]; + keyIdentifier = event.shiftKey ? correctedIdentifiers[1] : correctedIdentifiers[0]; } var unicodeKeyInHex = "0x" + keyIdentifier.substring(2); return String.fromCharCode(parseInt(unicodeKeyInHex)).toLowerCase(); diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..ef961833 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,33 @@ +var utils = { + /* + * Takes a dot-notation object string and call the function + * that it points to with the correct value for 'this'. + */ + invokeCommandString: function(str, argArray) { + var components = str.split('.'); + var obj = window; + for (var i = 0; i < components.length - 1; i++) + obj = obj[components[i]]; + var func = obj[components.pop()]; + return func.apply(obj, argArray); + }, + + /* + * Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and applies them + * to the document root. The namespaceResolver in evaluateXPath should be kept in sync with the namespaces + * here. + */ + makeXPath: function(elementArray) { + var xpath = []; + for (var i in elementArray) + xpath.push("//" + elementArray[i], "//xhtml:" + elementArray[i]); + return xpath.join(" | "); + }, + + evaluateXPath: function(xpath, resultType) { + function namespaceResolver(namespace) { + return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null; + } + return document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null); + }, +}; diff --git a/linkHints.js b/linkHints.js index de476e36..d37d2d7c 100644 --- a/linkHints.js +++ b/linkHints.js @@ -1,337 +1,605 @@ /* - * This implements link hinting. Typing "F" will enter link-hinting mode, where all clickable items on - * the page have a hint marker displayed containing a sequence of letters. Typing those letters will select - * a link. + * This implements link hinting. Typing "F" will enter link-hinting mode, where all clickable items on the + * page have a hint marker displayed containing a sequence of letters. Typing those letters will select a link. * - * The characters we use to show link hints are a user-configurable option. By default they're the home row. - * The CSS which is used on the link hints is also a configurable option. - */ - -var hintMarkers = []; -var hintMarkerContainingDiv = null; -// The characters that were typed in while in "link hints" mode. -var hintKeystrokeQueue = []; -var linkHintsModeActivated = false; -var shouldOpenLinkHintInNewTab = false; -var shouldOpenLinkHintWithQueue = false; -// Whether link hint's "open in current/new tab" setting is currently toggled -var openLinkModeToggle = false; -// Whether we have added to the page the CSS needed to display link hints. -var linkHintsCssAdded = false; - -/* - * Generate an XPath describing what a clickable element is. - * The final expression will be something like "//button | //xhtml:button | ..." + * In our 'default' mode, the characters we use to show link hints are a user-configurable option. By default + * they're the home row. The CSS which is used on the link hints is also a configurable option. + * + * In 'filter' mode, our link hints are numbers, and the user can narrow down the range of possibilities by + * typing the text of the link itself. */ -var clickableElementsXPath = (function() { - var clickableElements = ["a", "textarea", "button", "select", "input[not(@type='hidden')]", - "*[@onclick or @tabindex or @role='link' or @role='button']"]; - var xpath = []; - for (var i in clickableElements) - xpath.push("//" + clickableElements[i], "//xhtml:" + clickableElements[i]); - return xpath.join(" | ") -})(); - -// We need this as a top-level function because our command system doesn't yet support arguments. -function activateLinkHintsModeToOpenInNewTab() { activateLinkHintsMode(true, false); } - -function activateLinkHintsModeWithQueue() { activateLinkHintsMode(true, true); } - -function activateLinkHintsMode(openInNewTab, withQueue) { - if (!linkHintsCssAdded) - addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js - linkHintCssAdded = true; - linkHintsModeActivated = true; - setOpenLinkMode(openInNewTab, withQueue); - buildLinkHints(); - document.addEventListener("keydown", onKeyDownInLinkHintsMode, true); - document.addEventListener("keyup", onKeyUpInLinkHintsMode, true); -} - -function setOpenLinkMode(openInNewTab, withQueue) { - shouldOpenLinkHintInNewTab = openInNewTab; - shouldOpenLinkHintWithQueue = withQueue; - if (shouldOpenLinkHintWithQueue) { - HUD.show("Open multiple links in a new tab"); - } else { - if (shouldOpenLinkHintInNewTab) - HUD.show("Open link in new tab"); +var linkHints = { + hintMarkers: [], + hintMarkerContainingDiv: null, + // The characters that were typed in while in "link hints" mode. + shouldOpenInNewTab: false, + shouldOpenWithQueue: false, + // flag for copying link instead of opening + shouldCopyLinkUrl: false, + // Whether link hint's "open in current/new tab" setting is currently toggled + openLinkModeToggle: false, + // Whether we have added to the page the CSS needed to display link hints. + cssAdded: false, + // While in delayMode, all keypresses have no effect. + delayMode: false, + // Handle the link hinting marker generation and matching. Must be initialized after settings have been + // loaded, so that we can retrieve the option setting. + markerMatcher: undefined, + + /* + * To be called after linkHints has been generated from linkHintsBase. + */ + init: function() { + this.onKeyDownInMode = this.onKeyDownInMode.bind(this); + this.onKeyPressInMode = this.onKeyPressInMode.bind(this); + this.onKeyUpInMode = this.onKeyUpInMode.bind(this); + this.markerMatcher = settings.get('filterLinkHints') == "true" ? filterHints : alphabetHints; + }, + + /* + * Generate an XPath describing what a clickable element is. + * The final expression will be something like "//button | //xhtml:button | ..." + */ + clickableElementsXPath: utils.makeXPath(["a", "area[@href]", "textarea", "button", "select","input[not(@type='hidden')]", + "*[@onclick or @tabindex or @role='link' or @role='button']"]), + + // We need this as a top-level function because our command system doesn't yet support arguments. + activateModeToOpenInNewTab: function() { this.activateMode(true, false, false); }, + + activateModeToCopyLinkUrl: function() { this.activateMode(false, false, true); }, + + activateModeWithQueue: function() { this.activateMode(true, true, false); }, + + activateMode: function(openInNewTab, withQueue, copyLinkUrl) { + if (!this.cssAdded) + addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js + this.linkHintCssAdded = true; + this.setOpenLinkMode(openInNewTab, withQueue, copyLinkUrl); + this.buildLinkHints(); + handlerStack.push({ // modeKeyHandler is declared by vimiumFrontend.js + keydown: this.onKeyDownInMode, + keypress: this.onKeyPressInMode, + keyup: this.onKeyUpInMode + }); + + this.openLinkModeToggle = false; + }, + + setOpenLinkMode: function(openInNewTab, withQueue, copyLinkUrl) { + this.shouldOpenInNewTab = openInNewTab; + this.shouldOpenWithQueue = withQueue; + this.shouldCopyLinkUrl = copyLinkUrl; + if (this.shouldCopyLinkUrl) { + HUD.show("Copy link URL to Clipboard"); + } else if (this.shouldOpenWithQueue) { + HUD.show("Open multiple links in a new tab"); + } else { + if (this.shouldOpenInNewTab) + HUD.show("Open link in new tab"); + else + HUD.show("Open link in current tab"); + } + }, + + /* + * Builds and displays link hints for every visible clickable item on the page. + */ + buildLinkHints: function() { + var visibleElements = this.getVisibleClickableElements(); + this.hintMarkers = this.markerMatcher.getHintMarkers(visibleElements); + + // Note(philc): Append these markers as top level children instead of as child nodes to the link itself, + // because some clickable elements cannot contain children, e.g. submit buttons. This has the caveat + // that if you scroll the page and the link has position=fixed, the marker will not stay fixed. + // Also note that adding these nodes to document.body all at once is significantly faster than one-by-one. + this.hintMarkerContainingDiv = document.createElement("div"); + this.hintMarkerContainingDiv.className = "internalVimiumHintMarker"; + for (var i = 0; i < this.hintMarkers.length; i++) + this.hintMarkerContainingDiv.appendChild(this.hintMarkers[i]); + + // sometimes this is triggered before documentElement is created + // TODO(int3): fail more gracefully? + if (document.documentElement) + document.documentElement.appendChild(this.hintMarkerContainingDiv); else - HUD.show("Open link in current tab"); - } -} - -/* - * Builds and displays link hints for every visible clickable item on the page. - */ -function buildLinkHints() { - var visibleElements = getVisibleClickableElements(); - - // Initialize the number used to generate the character hints to be as many digits as we need to - // highlight all the links on the page; we don't want some link hints to have more chars than others. - var digitsNeeded = Math.ceil(logXOfBase(visibleElements.length, settings.linkHintCharacters.length)); - var linkHintNumber = 0; - for (var i = 0, count = visibleElements.length; i < count; i++) { - hintMarkers.push(createMarkerFor(visibleElements[i], linkHintNumber, digitsNeeded)); - linkHintNumber++; - } - // Note(philc): Append these markers as top level children instead of as child nodes to the link itself, - // because some clickable elements cannot contain children, e.g. submit buttons. This has the caveat - // that if you scroll the page and the link has position=fixed, the marker will not stay fixed. - // Also note that adding these nodes to document.body all at once is significantly faster than one-by-one. - hintMarkerContainingDiv = document.createElement("div"); - hintMarkerContainingDiv.className = "internalVimiumHintMarker"; - for (var i = 0; i < hintMarkers.length; i++) - hintMarkerContainingDiv.appendChild(hintMarkers[i]); - document.documentElement.appendChild(hintMarkerContainingDiv); -} - -function logXOfBase(x, base) { return Math.log(x) / Math.log(base); } - -/* - * Returns all clickable elements that are not hidden and are in the current viewport. - * We prune invisible elements partly for performance reasons, but moreso it's to decrease the number - * of digits needed to enumerate all of the links on screen. - */ -function getVisibleClickableElements() { - var resultSet = document.evaluate(clickableElementsXPath, document.body, - function (namespace) { - return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null; - }, - XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); - - - var visibleElements = []; - - // Find all visible clickable elements. - for (var i = 0, count = resultSet.snapshotLength; i < count; i++) { - var element = resultSet.snapshotItem(i); - var clientRect = element.getClientRects()[0]; - - if (isVisible(element, clientRect)) - visibleElements.push({element: element, rect: clientRect}); - - // If the link has zero dimensions, it may be wrapping visible - // but floated elements. Check for this. - if (clientRect && (clientRect.width == 0 || clientRect.height == 0)) { - for (var j = 0, childrenCount = element.children.length; j < childrenCount; j++) { - if (window.getComputedStyle(element.children[j], null).getPropertyValue('float') != 'none') { - var childClientRect = element.children[j].getClientRects()[0]; - if (isVisible(element.children[j], childClientRect)) { - visibleElements.push({element: element.children[j], rect: childClientRect}); - break; - } - } + this.deactivateMode(); + }, + + /* + * Returns all clickable elements that are not hidden and are in the current viewport. + * We prune invisible elements partly for performance reasons, but moreso it's to decrease the number + * of digits needed to enumerate all of the links on screen. + */ + getVisibleClickableElements: function() { + var resultSet = utils.evaluateXPath(this.clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE); + + var visibleElements = []; + + // Find all visible clickable elements. + for (var i = 0, count = resultSet.snapshotLength; i < count; i++) { + var element = resultSet.snapshotItem(i); + var clientRect = this.getVisibleClientRect(element, clientRect); + if (clientRect !== null) + visibleElements.push({element: element, rect: clientRect}); + + if (element.localName === "area") { + var map = element.parentElement; + var img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']"); + var clientRect = img.getClientRects()[0]; + var c = element.coords.split(/,/); + var coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)]; + var rect = { + top: clientRect.top + coords[1], + left: clientRect.left + coords[0], + right: clientRect.left + coords[2], + bottom: clientRect.top + coords[3], + width: coords[2] - coords[0], + height: coords[3] - coords[1] + }; + + visibleElements.push({element: element, rect: rect}); } } - } - return visibleElements; -} - -/* - * Returns true if element is visible. - */ -function isVisible(element, clientRect) { - // Exclude links which have just a few pixels on screen, because the link hints won't show for them anyway. - var zoomFactor = currentZoomLevel / 100.0; - if (!clientRect || clientRect.top < 0 || clientRect.top * zoomFactor >= window.innerHeight - 4 || - clientRect.left < 0 || clientRect.left * zoomFactor >= window.innerWidth - 4) - return false; - - if (clientRect.width < 3 || clientRect.height < 3) - return false; - - // eliminate invisible elements (see test_harnesses/visibility_test.html) - var computedStyle = window.getComputedStyle(element, null); - if (computedStyle.getPropertyValue('visibility') != 'visible' || - computedStyle.getPropertyValue('display') == 'none') - return false; - - return true; -} - -function onKeyDownInLinkHintsMode(event) { - console.log("Key Down"); - if (event.keyCode == keyCodes.shiftKey && !openLinkModeToggle) { - // Toggle whether to open link in a new or current tab. - setOpenLinkMode(!shouldOpenLinkHintInNewTab, shouldOpenLinkHintWithQueue); - openLinkModeToggle = true; - } + return visibleElements; + }, + + /** + * Returns the first visible clientRect of an element if it exists. Otherwise it returns null. + */ + getVisibleClientRect: function(element) { + // Note: this call will be expensive if we modify the DOM in between calls. + var clientRects = element.getClientRects(); + + for (var i = 0, len = clientRects.length; i < len; i++) { + // Exclude links which have just a few pixels on screen, because the link hints won't show for them + // anyway. + if (clientRects[i].top < 0 || clientRects[i].top >= window.innerHeight - 4 || + clientRects[i].left < 0 || clientRects[i].left >= window.innerWidth - 4) + continue; + + if (clientRects[i].width < 3 || clientRects[i].height < 3) + continue; + + // eliminate invisible elements (see test_harnesses/visibility_test.html) + var computedStyle = window.getComputedStyle(element, null); + if (computedStyle.getPropertyValue('visibility') != 'visible' || + computedStyle.getPropertyValue('display') == 'none') + continue; + + // If the link has zero dimensions, it may be wrapping visible + // but floated elements. Check for this. + if (clientRects[i].width == 0 || clientRects[i].height == 0) { + for (var j = 0, childrenCount = element.children.length; j < childrenCount; j++) { + var computedStyle = window.getComputedStyle(element.children[j], null); + // Ignore child elements which are not floated and not absolutely positioned for parent elements with zero width/height + if (computedStyle.getPropertyValue('float') == 'none' && computedStyle.getPropertyValue('position') != 'absolute') + continue; + var childClientRect = this.getVisibleClientRect(element.children[j]); + if (childClientRect === null) + continue; + return childClientRect; + } + } - var keyChar = getKeyChar(event); - if (!keyChar) - return; + return clientRects[i]; + }; + return null; + }, + + /* + * Handles shift and esc keys. The other keys are passed to markerMatcher.matchHintsByKey. + */ + onKeyDownInMode: function(event) { + if (this.delayMode) + return; + + if (event.keyCode == keyCodes.shiftKey && !this.openLinkModeToggle) { + // Toggle whether to open link in a new or current tab. + this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue, false); + this.openLinkModeToggle = true; + } - // TODO(philc): Ignore keys that have modifiers. - if (isEscape(event)) { - deactivateLinkHintsMode(); - } else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { - if (hintKeystrokeQueue.length == 0) { - deactivateLinkHintsMode(); + // TODO(philc): Ignore keys that have modifiers. + if (isEscape(event)) { + this.deactivateMode(); } else { - hintKeystrokeQueue.pop(); - updateLinkHints(); + var keyResult = this.markerMatcher.matchHintsByKey(event, this.hintMarkers); + var linksMatched = keyResult.linksMatched; + var delay = keyResult.delay !== undefined ? keyResult.delay : 0; + if (linksMatched.length == 0) { + this.deactivateMode(); + } else if (linksMatched.length == 1) { + this.activateLink(linksMatched[0].clickableItem, delay); + } else { + for (var i in this.hintMarkers) + this.hideMarker(this.hintMarkers[i]); + for (var i in linksMatched) + this.showMarker(linksMatched[i], this.markerMatcher.hintKeystrokeQueue.length); + } } - } else if (settings.linkHintCharacters.indexOf(keyChar) >= 0) { - hintKeystrokeQueue.push(keyChar); - updateLinkHints(); - } else { - return; - } - event.stopPropagation(); - event.preventDefault(); -} + event.stopPropagation(); + event.preventDefault(); + }, -function onKeyUpInLinkHintsMode(event) { - if (event.keyCode == keyCodes.shiftKey && openLinkModeToggle) { - // Revert toggle on whether to open link in new or current tab. - setOpenLinkMode(!shouldOpenLinkHintInNewTab, shouldOpenLinkHintWithQueue); - openLinkModeToggle = false; - } - event.stopPropagation(); - event.preventDefault(); -} + onKeyPressInMode: function(event) { + return !this.delayMode; + }, -/* - * Updates the visibility of link hints on screen based on the keystrokes typed thus far. If only one - * link hint remains, click on that link and exit link hints mode. - */ -function updateLinkHints() { - var matchString = hintKeystrokeQueue.join(""); - var linksMatched = highlightLinkMatches(matchString); - if (linksMatched.length == 0) - deactivateLinkHintsMode(); - else if (linksMatched.length == 1) { - var matchedLink = linksMatched[0]; - if (isSelectable(matchedLink)) { - matchedLink.focus(); - // When focusing a textbox, put the selection caret at the end of the textbox's contents. - matchedLink.setSelectionRange(matchedLink.value.length, matchedLink.value.length); - deactivateLinkHintsMode(); + onKeyUpInMode: function(event) { + if (this.delayMode) + return; + + if (event.keyCode == keyCodes.shiftKey && this.openLinkModeToggle) { + // Revert toggle on whether to open link in new or current tab. + this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue, false); + this.openLinkModeToggle = false; + } + event.stopPropagation(); + event.preventDefault(); + }, + + /* + * When only one link hint remains, this function activates it in the appropriate way. + */ + activateLink: function(matchedLink, delay) { + var that = this; + this.delayMode = true; + if (this.isSelectable(matchedLink)) { + this.simulateSelect(matchedLink); + this.deactivateMode(delay, function() { that.delayMode = false; }); } else { - // When we're opening the link in the current tab, don't navigate to the selected link immediately; - // we want to give the user some feedback depicting which link they've selected by focusing it. - if (shouldOpenLinkHintWithQueue) { - simulateClick(matchedLink); - resetLinkHintsMode(); - } else if (shouldOpenLinkHintInNewTab) { - simulateClick(matchedLink); + if (this.shouldOpenWithQueue) { + this.simulateClick(matchedLink); + this.deactivateMode(delay, function() { + that.delayMode = false; + that.activateModeWithQueue(); + }); + } else if (this.shouldCopyLinkUrl) { + this.copyLinkUrl(matchedLink); + this.deactivateMode(delay, function() { that.delayMode = false; }); + } else if (this.shouldOpenInNewTab) { + this.simulateClick(matchedLink); matchedLink.focus(); - deactivateLinkHintsMode(); + this.deactivateMode(delay, function() { that.delayMode = false; }); } else { - setTimeout(function() { simulateClick(matchedLink); }, 400); + // When we're opening the link in the current tab, don't navigate to the selected link immediately; + // we want to give the user some feedback depicting which link they've selected by focusing it. + setTimeout(this.simulateClick.bind(this, matchedLink), 400); matchedLink.focus(); - deactivateLinkHintsMode(); + this.deactivateMode(delay, function() { that.delayMode = false; }); } } - } -} + }, + + /* + * Selectable means the element has a text caret; this is not the same as "focusable". + */ + isSelectable: function(element) { + var selectableTypes = ["search", "text", "password"]; + return (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) || + element.nodeName.toLowerCase() == "textarea"; + }, + + copyLinkUrl: function(link) { + chrome.extension.sendRequest({handler: 'copyLinkUrl', data: link.href}); + }, + + simulateSelect: function(element) { + element.focus(); + // When focusing a textbox, put the selection caret at the end of the textbox's contents. + element.setSelectionRange(element.value.length, element.value.length); + }, + + /* + * Shows the marker, highlighting matchingCharCount characters. + */ + showMarker: function(linkMarker, matchingCharCount) { + linkMarker.style.display = ""; + for (var j = 0, count = linkMarker.childNodes.length; j < count; j++) + linkMarker.childNodes[j].className = (j >= matchingCharCount) ? "" : "matchingCharacter"; + }, + + hideMarker: function(linkMarker) { + linkMarker.style.display = "none"; + }, + + simulateClick: function(link) { + var event = document.createEvent("MouseEvents"); + // When "clicking" on a link, dispatch the event with the appropriate meta key (CMD on Mac, CTRL on windows) + // to open it in a new tab if necessary. + var metaKey = (platform == "Mac" && linkHints.shouldOpenInNewTab); + var ctrlKey = (platform != "Mac" && linkHints.shouldOpenInNewTab); + event.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, ctrlKey, false, false, metaKey, 0, null); + + // Debugging note: Firefox will not execute the link's default action if we dispatch this click event, + // but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately + link.dispatchEvent(event); + + // TODO(int3): do this for @role='link' and similar elements as well + var nodeName = link.nodeName.toLowerCase(); + if (nodeName == 'a' || nodeName == 'button') + link.blur(); + }, + + /* + * If called without arguments, it executes immediately. Othewise, it + * executes after 'delay' and invokes 'callback' when it is finished. + */ + deactivateMode: function(delay, callback) { + var that = this; + function deactivate() { + if (that.markerMatcher.deactivate) + that.markerMatcher.deactivate(); + if (that.hintMarkerContainingDiv) + that.hintMarkerContainingDiv.parentNode.removeChild(that.hintMarkerContainingDiv); + that.hintMarkerContainingDiv = null; + that.hintMarkers = []; + handlerStack.pop(); + HUD.hide(); + } + // we invoke the deactivate() function directly instead of using setTimeout(callback, 0) so that + // deactivateMode can be tested synchronously + if (!delay) { + deactivate(); + if (callback) callback(); + } else { + setTimeout(function() { deactivate(); if (callback) callback(); }, delay); + } + }, + +}; + +var alphabetHints = { + hintKeystrokeQueue: [], + logXOfBase: function(x, base) { return Math.log(x) / Math.log(base); }, + + getHintMarkers: function(visibleElements) { + //Initialize the number used to generate the character hints to be as many digits as we need to highlight + //all the links on the page; we don't want some link hints to have more chars than others. + var digitsNeeded = Math.ceil(this.logXOfBase( + visibleElements.length, settings.get('linkHintCharacters').length)); + var hintMarkers = []; + + for (var i = 0, count = visibleElements.length; i < count; i++) { + var hintString = this.numberToHintString(i, digitsNeeded); + var marker = hintUtils.createMarkerFor(visibleElements[i]); + marker.innerHTML = hintUtils.spanWrap(hintString); + marker.setAttribute("hintString", hintString); + hintMarkers.push(marker); + } -/* - * Selectable means the element has a text caret; this is not the same as "focusable". - */ -function isSelectable(element) { - var selectableTypes = ["search", "text", "password"]; - return (element.tagName == "INPUT" && selectableTypes.indexOf(element.type) >= 0) || - element.tagName == "TEXTAREA"; -} + return hintMarkers; + }, + /* + * Converts a number like "8" into a hint string like "JK". This is used to sequentially generate all of + * the hint text. The hint string will be "padded with zeroes" to ensure its length is equal to numHintDigits. + */ + numberToHintString: function(number, numHintDigits) { + var base = settings.get('linkHintCharacters').length; + var hintString = []; + var remainder = 0; + do { + remainder = number % base; + hintString.unshift(settings.get('linkHintCharacters')[remainder]); + number -= remainder; + number /= Math.floor(base); + } while (number > 0); + + // Pad the hint string we're returning so that it matches numHintDigits. + // Note: the loop body changes hintString.length, so the original length must be cached! + var hintStringLength = hintString.length; + for (var i = 0; i < numHintDigits - hintStringLength; i++) + hintString.unshift(settings.get('linkHintCharacters')[0]); + + // Reversing the hint string has the advantage of making the link hints + // appear to spread out after the first key is hit. This is helpful on a + // page that has http links that are close to each other where link hints + // of 2 characters or more occlude each other. + hintString.reverse(); + return hintString.join(""); + }, + + matchHintsByKey: function(event, hintMarkers) { + var linksMatched = hintMarkers; + var keyChar = getKeyChar(event); + if (!keyChar) + return { 'linksMatched': linksMatched }; + + if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { + if (this.hintKeystrokeQueue.length == 0) { + var linksMatched = []; + } else { + this.hintKeystrokeQueue.pop(); + var matchString = this.hintKeystrokeQueue.join(""); + var linksMatched = linksMatched.filter(function(linkMarker) { + return linkMarker.getAttribute("hintString").indexOf(matchString) == 0; + }); + } + } else if (settings.get('linkHintCharacters').indexOf(keyChar) >= 0) { + this.hintKeystrokeQueue.push(keyChar); + var matchString = this.hintKeystrokeQueue.join(""); + var linksMatched = linksMatched.filter(function(linkMarker) { + return linkMarker.getAttribute("hintString").indexOf(matchString) == 0; + }); + } + return { 'linksMatched': linksMatched }; + }, -/* - * Hides link hints which do not match the given search string. To allow the backspace key to work, this - * will also show link hints which do match but were previously hidden. - */ -function highlightLinkMatches(searchString) { - var linksMatched = []; - for (var i = 0; i < hintMarkers.length; i++) { - var linkMarker = hintMarkers[i]; - if (linkMarker.getAttribute("hintString").indexOf(searchString) == 0) { - if (linkMarker.style.display == "none") - linkMarker.style.display = ""; - var childNodes = linkMarker.childNodes; - for (var j = 0, childNodesCount = childNodes.length; j < childNodesCount; j++) - childNodes[j].className = (j >= searchString.length) ? "" : "matchingCharacter"; - linksMatched.push(linkMarker.clickableItem); + deactivate: function() { + this.hintKeystrokeQueue = []; + } + +}; + +var filterHints = { + hintKeystrokeQueue: [], + linkTextKeystrokeQueue: [], + labelMap: {}, + + /* + * Generate a map of input element => label + */ + generateLabelMap: function() { + var labels = document.querySelectorAll("label"); + for (var i = 0, count = labels.length; i < count; i++) { + var forElement = labels[i].getAttribute("for"); + if (forElement) { + var labelText = labels[i].textContent.trim(); + // remove trailing : commonly found in labels + if (labelText[labelText.length-1] == ":") + labelText = labelText.substr(0, labelText.length-1); + this.labelMap[forElement] = labelText; + } + } + }, + + setMarkerAttributes: function(marker, linkHintNumber) { + var hintString = (linkHintNumber + 1).toString(); + var linkText = ""; + var showLinkText = false; + var element = marker.clickableItem; + // toLowerCase is necessary as html documents return 'IMG' + // and xhtml documents return 'img' + var nodeName = element.nodeName.toLowerCase(); + + if (nodeName == "input") { + if (this.labelMap[element.id]) { + linkText = this.labelMap[element.id]; + showLinkText = true; + } else if (element.type != "password") { + linkText = element.value; + } + // check if there is an image embedded in the <a> tag + } else if (nodeName == "a" && !element.textContent.trim() + && element.firstElementChild + && element.firstElementChild.nodeName.toLowerCase() == "img") { + linkText = element.firstElementChild.alt || element.firstElementChild.title; + if (linkText) + showLinkText = true; } else { - linkMarker.style.display = "none"; + linkText = element.textContent || element.innerHTML; } - } - return linksMatched; -} + linkText = linkText.trim().toLowerCase(); + marker.setAttribute("hintString", hintString); + marker.innerHTML = hintUtils.spanWrap(hintString + (showLinkText ? ": " + linkText : "")); + marker.setAttribute("linkText", linkText); + }, + + getHintMarkers: function(visibleElements) { + this.generateLabelMap(); + var hintMarkers = []; + for (var i = 0, count = visibleElements.length; i < count; i++) { + var marker = hintUtils.createMarkerFor(visibleElements[i]); + this.setMarkerAttributes(marker, i); + hintMarkers.push(marker); + } + return hintMarkers; + }, + + matchHintsByKey: function(event, hintMarkers) { + var linksMatched = hintMarkers; + var delay = 0; + var keyChar = getKeyChar(event); + + if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { + // backspace clears hint key queue first, then acts on link text key queue + if (this.hintKeystrokeQueue.pop()) + linksMatched = this.filterLinkHints(linksMatched); + else if (this.linkTextKeystrokeQueue.pop()) + linksMatched = this.filterLinkHints(linksMatched); + else // both queues are empty. exit hinting mode + linksMatched = []; + } else if (event.keyCode == keyCodes.enter) { + // activate the lowest-numbered link hint that is visible + for (var i = 0, count = linksMatched.length; i < count; i++) + if (linksMatched[i].style.display != 'none') { + linksMatched = [ linksMatched[i] ]; + break; + } + } else if (keyChar) { + var matchString; + if (/[0-9]/.test(keyChar)) { + this.hintKeystrokeQueue.push(keyChar); + matchString = this.hintKeystrokeQueue.join(""); + linksMatched = linksMatched.filter(function(linkMarker) { + return linkMarker.getAttribute('filtered') != 'true' + && linkMarker.getAttribute("hintString").indexOf(matchString) == 0; + }); + } else { + // since we might renumber the hints, the current hintKeyStrokeQueue + // should be rendered invalid (i.e. reset). + this.hintKeystrokeQueue = []; + this.linkTextKeystrokeQueue.push(keyChar); + linksMatched = this.filterLinkHints(linksMatched); + } -/* - * Converts a number like "8" into a hint string like "JK". This is used to sequentially generate all of - * the hint text. The hint string will be "padded with zeroes" to ensure its length is equal to numHintDigits. - */ -function numberToHintString(number, numHintDigits) { - var base = settings.linkHintCharacters.length; - var hintString = []; - var remainder = 0; - do { - remainder = number % base; - hintString.unshift(settings.linkHintCharacters[remainder]); - number -= remainder; - number /= Math.floor(base); - } while (number > 0); - - // Pad the hint string we're returning so that it matches numHintDigits. - for (var i = 0, count = numHintDigits - hintString.length; i < count; i++) - hintString.unshift(settings.linkHintCharacters[0]); - return hintString.join(""); -} - -function simulateClick(link) { - var event = document.createEvent("MouseEvents"); - // When "clicking" on a link, dispatch the event with the appropriate meta key (CMD on Mac, CTRL on windows) - // to open it in a new tab if necessary. - var metaKey = (platform == "Mac" && shouldOpenLinkHintInNewTab); - var ctrlKey = (platform != "Mac" && shouldOpenLinkHintInNewTab); - event.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, ctrlKey, false, false, metaKey, 0, null); - - // Debugging note: Firefox will not execute the link's default action if we dispatch this click event, - // but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately - link.dispatchEvent(event); -} - -function deactivateLinkHintsMode() { - if (hintMarkerContainingDiv) - hintMarkerContainingDiv.parentNode.removeChild(hintMarkerContainingDiv); - hintMarkerContainingDiv = null; - hintMarkers = []; - hintKeystrokeQueue = []; - document.removeEventListener("keydown", onKeyDownInLinkHintsMode, true); - document.removeEventListener("keyup", onKeyUpInLinkHintsMode, true); - linkHintsModeActivated = false; - HUD.hide(); -} - -function resetLinkHintsMode() { - deactivateLinkHintsMode(); - activateLinkHintsModeWithQueue(); -} + if (linksMatched.length == 1 && !/[0-9]/.test(keyChar)) { + // In filter mode, people tend to type out words past the point + // needed for a unique match. Hence we should avoid passing + // control back to command mode immediately after a match is found. + var delay = 200; + } + } + return { 'linksMatched': linksMatched, 'delay': delay }; + }, + + /* + * Hides the links that do not match the linkText search string and marks them with the 'filtered' DOM + * property. Renumbers the remainder. Should only be called when there is a change in + * linkTextKeystrokeQueue, to avoid undesired renumbering. + */ + filterLinkHints: function(hintMarkers) { + var linksMatched = []; + var linkSearchString = this.linkTextKeystrokeQueue.join(""); + + for (var i = 0; i < hintMarkers.length; i++) { + var linkMarker = hintMarkers[i]; + var matchedLink = linkMarker.getAttribute("linkText").toLowerCase() + .indexOf(linkSearchString.toLowerCase()) >= 0; + + if (!matchedLink) { + linkMarker.setAttribute("filtered", "true"); + } else { + this.setMarkerAttributes(linkMarker, linksMatched.length); + linkMarker.setAttribute("filtered", "false"); + linksMatched.push(linkMarker); + } + } + return linksMatched; + }, -/* - * Creates a link marker for the given link. - */ -function createMarkerFor(link, linkHintNumber, linkHintDigits) { - var hintString = numberToHintString(linkHintNumber, linkHintDigits); - var marker = document.createElement("div"); - marker.className = "internalVimiumHintMarker vimiumHintMarker"; - var innerHTML = []; - // Make each hint character a span, so that we can highlight the typed characters as you type them. - for (var i = 0; i < hintString.length; i++) - innerHTML.push("<span>" + hintString[i].toUpperCase() + "</span>"); - marker.innerHTML = innerHTML.join(""); - marker.setAttribute("hintString", hintString); - - // Note: this call will be expensive if we modify the DOM in between calls. - var clientRect = link.rect; - // The coordinates given by the window do not have the zoom factor included since the zoom is set only on - // the document node. - var zoomFactor = currentZoomLevel / 100.0; - marker.style.left = clientRect.left + window.scrollX / zoomFactor + "px"; - marker.style.top = clientRect.top + window.scrollY / zoomFactor + "px"; - - marker.clickableItem = link.element; - return marker; -} + deactivate: function(delay, callback) { + this.hintKeystrokeQueue = []; + this.linkTextKeystrokeQueue = []; + this.labelMap = {}; + } + +}; + +var hintUtils = { + /* + * Make each hint character a span, so that we can highlight the typed characters as you type them. + */ + spanWrap: function(hintString) { + var innerHTML = []; + for (var i = 0; i < hintString.length; i++) + innerHTML.push("<span>" + hintString[i].toUpperCase() + "</span>"); + return innerHTML.join(""); + }, + + /* + * Creates a link marker for the given link. + */ + createMarkerFor: function(link) { + var marker = document.createElement("div"); + marker.className = "internalVimiumHintMarker vimiumHintMarker"; + marker.clickableItem = link.element; + + var clientRect = link.rect; + marker.style.left = clientRect.left + window.scrollX + "px"; + marker.style.top = clientRect.top + window.scrollY + "px"; + + return marker; + } +}; diff --git a/manifest.json b/manifest.json index 38c21b2d..a79fd90c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "Vimium", - "version": "1.26", + "version": "1.30", "description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.", "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", @@ -9,16 +9,20 @@ "options_page": "options.html", "permissions": [ "tabs", + "bookmarks", "http://*/*", "https://*/*" ], "content_scripts": [ { "matches": ["<all_urls>"], - "js": ["lib/keyboardUtils.js", + "js": ["lib/utils.js", + "lib/keyboardUtils.js", "lib/clipboard.js", "linkHints.js", - "vimiumFrontend.js" + "vimiumFrontend.js", + "completionDialog.js", + "bookmarks.js" ], "run_at": "document_start", "all_frames": true diff --git a/options.html b/options.html index 627560f8..8dfd23e8 100644 --- a/options.html +++ b/options.html @@ -1,6 +1,7 @@ <html> <head> <title>Vimium Options</title> + <script src="lib/utils.js"></script> <script src="lib/keyboardUtils.js"></script> <script src="linkHints.js"></script> <script src="lib/clipboard.js"></script> @@ -17,12 +18,13 @@ border:1px solid red; } .example { - font-size:80%; + font-size: 12px; color:#555; margin-left:20px; } .caption { margin-right:10px; + min-width: 130px; } td { padding:5px 0; @@ -70,15 +72,20 @@ tr.advancedOption { display:none; } - + input:read-only { + background-color: #eee; + color: #666; + } + /* Boolean options have a tighter form representation than text options. */ + td.booleanOption { font-size: 12px; } </style> <script type="text/javascript"> $ = function(id) { return document.getElementById(id); }; var defaultSettings = chrome.extension.getBackgroundPage().defaultSettings; - var editableFields = ["scrollStepSize", "defaultZoomLevel", "excludedUrls", "linkHintCharacters", - "userDefinedLinkHintCss", "keyMappings", "previousPatterns", "nextPatterns"]; + var editableFields = ["scrollStepSize", "excludedUrls", "linkHintCharacters", "userDefinedLinkHintCss", + "keyMappings", "filterLinkHints", "previousPatterns", "nextPatterns"]; var canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss"]; @@ -93,8 +100,13 @@ function initializeOptions() { populateOptions(); - for (var i = 0; i < editableFields.length; i++) + + for (var i = 0; i < editableFields.length; i++) { $(editableFields[i]).addEventListener("keyup", onOptionKeyup, false); + $(editableFields[i]).addEventListener("change", enableSaveButton, false); + $(editableFields[i]).addEventListener("change", onDataLoaded, false); + } + $("advancedOptions").addEventListener("click", openAdvancedOptions, false); $("showCommands").addEventListener("click", function () { showHelpDialog( @@ -107,6 +119,10 @@ enableSaveButton(); } + function onDataLoaded() { + $("linkHintCharacters").readOnly = $("filterLinkHints").checked; + } + function enableSaveButton() { $("saveOptions").removeAttribute("disabled"); } // Saves options to localStorage. @@ -115,7 +131,16 @@ // the freedom to change the defaults in the future. for (var i = 0; i < editableFields.length; i++) { var fieldName = editableFields[i]; - var fieldValue = $(fieldName).value.trim(); + var field = $(fieldName); + + var fieldValue; + if (field.getAttribute("type") == "checkbox") { + fieldValue = field.checked ? "true" : "false"; + } else { + fieldValue = field.value.trim(); + field.value = fieldValue; + } + var defaultFieldValue = (defaultSettings[fieldName] != null) ? defaultSettings[fieldName].toString() : ""; @@ -142,20 +167,33 @@ for (var i = 0; i < editableFields.length; i++) { // If it's null or undefined, let's go to the default. We want to allow empty strings in certain cases. if (localStorage[editableFields[i]] != "" && !localStorage[editableFields[i]]) { - $(editableFields[i]).value = defaultSettings[editableFields[i]] || ""; + var val = defaultSettings[editableFields[i]] || ""; } else { - $(editableFields[i]).value = localStorage[editableFields[i]]; + var val = localStorage[editableFields[i]]; } - $(editableFields[i]).setAttribute("savedValue", $(editableFields[i]).value); + setFieldValue($(editableFields[i]), val); } + onDataLoaded(); } function restoreToDefaults() { - for (var i = 0; i < editableFields.length; i++) - $(editableFields[i]).value = defaultSettings[editableFields[i]] || ""; + for (var i = 0; i < editableFields.length; i++) { + var val = defaultSettings[editableFields[i]] || ""; + setFieldValue($(editableFields[i]), val); + } + onDataLoaded(); enableSaveButton(); } + function setFieldValue(field, value) { + if (field.getAttribute('type') == 'checkbox') + field.checked = value == "true"; + else + field.value = value; + + field.setAttribute("savedValue", value); + } + function openAdvancedOptions(event) { var elements = document.getElementsByClassName("advancedOption"); for (var i = 0; i < elements.length; i++) @@ -176,12 +214,6 @@ </td> </tr> <tr> - <td><span class="caption">Default zoom level</span></td> - <td> - <input id="defaultZoomLevel" type="text" value="100" style="width:50px" />% - </td> - </tr> - <tr> <td colspan="3"> Excluded URLs<br/> <div class="help"> @@ -200,7 +232,7 @@ </td> </tr> <tr class="advancedOption"> - <td class="caption">Key mappings</td> + <td class="caption">Custom key<br/>mappings</td> <td id="mappingsHelp" verticalAlign="top"> <div class="help"> <div class="example"> @@ -244,11 +276,26 @@ </td> </tr> <tr class="advancedOption"> + <td class="caption"></td> + <td verticalAlign="top" class="booleanOption"> + <div class="help"> + <div class="example"> + After typing "F" to enter link hinting mode, this option lets you type the text of a link + to select it. + </div> + </div> + <label> + <input id="filterLinkHints" type="checkbox"/> + Use the link's name and numbers for link hint filtering + </label> + </td> + </tr> + <tr class="advancedOption"> <td class="caption">Previous Patterns</td> <td verticalAlign="top"> <div class="help"> <div class="example"> - The Patterns split by ','. + Vimium will match against these patterns to navigate to a 'previous' page. </div> </div> <input id="previousPatterns" type="text" style="width:320px" /> @@ -259,7 +306,7 @@ <td verticalAlign="top"> <div class="help"> <div class="example"> - The Patterns split by ','. + Vimium will match against these patterns to navigate to a 'next' page. </div> </div> <input id="nextPatterns" type="text" style="width:320px" /> diff --git a/test_harnesses/automated.html b/test_harnesses/automated.html new file mode 100644 index 00000000..9f1b8007 --- /dev/null +++ b/test_harnesses/automated.html @@ -0,0 +1,252 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" + "http://www.w3.org/TR/html4/strict.dtd"> +<html> + <head> + <style type="text/css"> + body { + font-family:"helvetica neue", "helvetica", "arial", "sans"; + width: 800px; + margin: 0px auto; + } + #output-div { + white-space: pre-wrap; + background-color: #eee; + font-family: monospace; + margin: 0 0 50px 0; + border-style: dashed; + border-width: 1px 1px 0 1px; + border-color: #999; + } + .errorPosition { + color: #f33; + font-weight: bold; + } + .output-section { + padding: 10px 15px 10px 15px; + border-bottom: dashed 1px #999; + } + </style> + <script type="text/javascript" src="../lib/utils.js"></script> + <script type="text/javascript" src="../lib/keyboardUtils.js"></script> + <script type="text/javascript" src="../linkHints.js"></script> + <script type="text/javascript" src="../lib/clipboard.js"></script> + <script type="text/javascript" src="../vimiumFrontend.js"></script> + <script type="text/javascript" src="shoulda.js/shoulda.js"></script> + <script type="text/javascript"> + /* + * Dispatching keyboard events via the DOM would require async tests, + * which tend to be more complicated. Here we create mock events and + * invoke the handlers directly. + */ + function mockKeyboardEvent(keyChar) { + var event = {}; + event.charCode = keyCodes[keyChar] !== undefined ? keyCodes[keyChar] : keyChar.charCodeAt(0); + event.keyIdentifier = "U+00" + event.charCode.toString(16); + event.keyCode = event.charCode; + event.stopPropagation = function(){}; + event.preventDefault = function(){}; + return event; + } + + /* + * Generate tests that are common to both default and filtered + * link hinting modes. + */ + function createGeneralHintTests(isFilteredMode) { + context("Link hints", + + setup(function() { + var testContent = + "<a>test</a>" + + "<a>tress</a>"; + document.getElementById("test-div").innerHTML = testContent; + linkHints.markerMatcher = alphabetHints; + }), + + tearDown(function() { + document.getElementById("test-div").innerHTML = ""; + }), + + should("create hints when activated, discard them when deactivated", function() { + linkHints.activateMode(); + assert.isFalse(linkHints.hintMarkerContainingDiv == null); + linkHints.deactivateMode(); + assert.isTrue(linkHints.hintMarkerContainingDiv == null); + }), + + should("position items correctly", function() { + function assertStartPosition(element1, element2) { + assert.equal(element1.getClientRects()[0].left, element2.getClientRects()[0].left); + assert.equal(element1.getClientRects()[0].top, element2.getClientRects()[0].top); + } + stub(document.body, "style", "static"); + linkHints.activateMode(); + assertStartPosition(document.getElementsByTagName("a")[0], linkHints.hintMarkers[0]); + assertStartPosition(document.getElementsByTagName("a")[1], linkHints.hintMarkers[1]); + linkHints.deactivateMode(); + + stub(document.body.style, "position", "relative"); + linkHints.activateMode(); + assertStartPosition(document.getElementsByTagName("a")[0], linkHints.hintMarkers[0]); + assertStartPosition(document.getElementsByTagName("a")[1], linkHints.hintMarkers[1]); + linkHints.deactivateMode(); + }) + + ); + } + createGeneralHintTests(false); + createGeneralHintTests(true); + + context("Alphabetical link hints", + + setup(function() { + stub(settings.values, "filterLinkHints", "false"); + linkHints.markerMatcher = alphabetHints; + // we need at least 16 elements to have double-character link hints + for (var i = 0; i < 16; i++) { + var link = document.createElement("a"); + link.textContent = "test"; + document.getElementById("test-div").appendChild(link); + } + linkHints.activateMode(); + }), + + tearDown(function() { + linkHints.deactivateMode(); + document.getElementById("test-div").innerHTML = ""; + }), + + should("label the hints correctly", function() { + var hintStrings = ["ss", "sa", "sd"]; + for (var i = 0; i < 3; i++) + assert.equal(hintStrings[i], linkHints.hintMarkers[i].getAttribute("hintString")); + }), + + should("narrow the hints", function() { + linkHints.onKeyDownInMode(mockKeyboardEvent("A")); + assert.equal("none", linkHints.hintMarkers[0].style.display); + assert.equal("", linkHints.hintMarkers[15].style.display); + }) + + ); + + context("Filtered link hints", + + setup(function() { + stub(settings.values, "filterLinkHints", "true"); + linkHints.markerMatcher = filterHints; + }), + + context("Text hints", + + setup(function() { + var testContent = + "<a>test</a>" + + "<a>tress</a>" + + "<a>trait</a>" + + "<a>track<img alt='alt text'/></a>"; + document.getElementById("test-div").innerHTML = testContent; + linkHints.activateMode(); + }), + + tearDown(function() { + document.getElementById("test-div").innerHTML = ""; + linkHints.deactivateMode(); + }), + + should("label the hints", function() { + for (var i = 0; i < 4; i++) + assert.equal((i + 1).toString(), linkHints.hintMarkers[i].textContent.toLowerCase()); + }), + + should("narrow the hints", function() { + linkHints.onKeyDownInMode(mockKeyboardEvent("T")); + linkHints.onKeyDownInMode(mockKeyboardEvent("R")); + assert.equal("none", linkHints.hintMarkers[0].style.display); + assert.equal("1", linkHints.hintMarkers[1].getAttribute("hintString")); + assert.equal("", linkHints.hintMarkers[1].style.display); + linkHints.onKeyDownInMode(mockKeyboardEvent("A")); + assert.equal("2", linkHints.hintMarkers[3].getAttribute("hintString")); + }) + + ), + + context("Image hints", + + setup(function() { + var testContent = + "<a><img alt='alt text'/></a>" + + "<a><img alt='alt text' title='some title'/></a>" + + "<a><img title='some title'/></a>" + + "<a><img src='blah' width='320px' height='100px'/></a>"; + document.getElementById("test-div").innerHTML = testContent; + linkHints.activateMode(); + }), + + tearDown(function() { + document.getElementById("test-div").innerHTML = ""; + linkHints.deactivateMode(); + }), + + should("label the images", function() { + assert.equal("1: alt text", linkHints.hintMarkers[0].textContent.toLowerCase()); + assert.equal("2: alt text", linkHints.hintMarkers[1].textContent.toLowerCase()); + assert.equal("3: some title", linkHints.hintMarkers[2].textContent.toLowerCase()); + assert.equal("4", linkHints.hintMarkers[3].textContent.toLowerCase()); + }) + + ), + + context("Input hints", + + setup(function() { + var testContent = + "<input type='text' value='some value'/>" + + "<input type='password' value='some value'/>" + + "<textarea>some text</textarea>" + + "<label for='test-input'/>a label</label><input type='text' id='test-input' value='some value'/>" + + "<label for='test-input-2'/>a label: </label><input type='text' id='test-input-2' value='some value'/>"; + document.getElementById("test-div").innerHTML = testContent; + linkHints.activateMode(); + }), + + tearDown(function() { + document.getElementById("test-div").innerHTML = ""; + linkHints.deactivateMode(); + }), + + should("label the input elements", function() { + assert.equal("1", linkHints.hintMarkers[0].textContent.toLowerCase()); + assert.equal("2", linkHints.hintMarkers[1].textContent.toLowerCase()); + assert.equal("3", linkHints.hintMarkers[2].textContent.toLowerCase()); + assert.equal("4: a label", linkHints.hintMarkers[3].textContent.toLowerCase()); + assert.equal("5: a label", linkHints.hintMarkers[4].textContent.toLowerCase()); + }) + + ) + ); + + Tests.outputMethod = function(output) { + var newOutput = Array.prototype.join.call(arguments, "\n"); + newOutput = newOutput.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); // escape html + // highlight the source of the error + newOutput = newOutput.replace(/\/([^:/]+):([0-9]+):([0-9]+)/, "/<span class='errorPosition'>$1:$2</span>:$3"); + document.getElementById("output-div").innerHTML += "<div class='output-section'>" + newOutput + "</div>"; + console.log.apply(console, arguments); + } + // ensure the extension has time to load before commencing the tests + document.addEventListener("DOMContentLoaded", function(){ + setTimeout(Tests.run, 200); + }); + </script> + </head> + <body> + <!-- should always be the first element on the page --> + <div id="test-div"></div> + + <h1>Vimium Tests</h1> + + <div id="output-div"></div> + + </body> +</html> diff --git a/test_harnesses/iframe.html b/test_harnesses/iframe.html index 1de9b75f..750ad32d 100644 --- a/test_harnesses/iframe.html +++ b/test_harnesses/iframe.html @@ -21,6 +21,6 @@ </head> <body> <h2>IFrame test page</h2> - <iframe src="http://www.google.com"></iframe> + <iframe src="http://www.bing.com"></iframe> </body> </html>
\ No newline at end of file diff --git a/test_harnesses/shoulda.js b/test_harnesses/shoulda.js new file mode 160000 +Subproject 695d0eb2084de5380dccac8c9b188ce91d838dc diff --git a/vimiumFrontend.js b/vimiumFrontend.js index 0e8bdbb5..a9dc1d37 100644 --- a/vimiumFrontend.js +++ b/vimiumFrontend.js @@ -1,41 +1,69 @@ /* * This content script takes input from its webpage and executes commands locally on behalf of the background - * page. It must be run prior to domReady so that we perform some operations very early, like setting - * the page's zoom level. We tell the background page that we're in domReady and ready to accept normal - * commands by connectiong to a port named "domReady". + * page. It must be run prior to domReady so that we perform some operations very early. We tell the + * background page that we're in domReady and ready to accept normal commands by connectiong to a port named + * "domReady". */ -var settings = {}; -var settingsToLoad = ["scrollStepSize", "linkHintCharacters", "previousPatterns", "nextPatterns"]; - var getCurrentUrlHandlers = []; // function(url) -var insertMode = false; +var insertModeLock = null; var findMode = false; var findModeQuery = ""; var findModeQueryHasResults = false; var isShowingHelpDialog = false; +var handlerStack = []; var keyPort; var settingPort; -var saveZoomLevelPort; // Users can disable Vimium on URL patterns via the settings page. var isEnabledForUrl = true; // The user's operating system. var currentCompletionKeys; +var validFirstKeys; var linkHintCss; -// TODO(philc): This should be pulled from the extension's storage when the page loads. -var currentZoomLevel = 100; - // The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in // each content script. Alternatively we could calculate it once in the background page and use a request to // fetch it each time. // // Should we include the HTML5 date pickers here? -var textInputTypes = ["text", "search", "email", "url", "number"]; + // The corresponding XPath for such elements. -var textInputXPath = '//input[' + - textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + - ' or not(@type)]'; +var textInputXPath = (function() { + var textInputTypes = ["text", "search", "email", "url", "number"]; + var inputElements = ["input[" + + textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + "or not(@type)]", + "textarea"]; + return utils.makeXPath(inputElements); +})(); + +var settings = { + values: {}, + loadedValues: 0, + valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints", "previousPatterns", "nextPatterns"], + + get: function (key) { return this.values[key]; }, + + load: function() { + for (var i in this.valuesToLoad) { this.sendMessage(this.valuesToLoad[i]); } + }, + + sendMessage: function (key) { + if (!settingPort) + settingPort = chrome.extension.connect({ name: "getSetting" }); + settingPort.postMessage({ key: key }); + }, + + receiveMessage: function (args) { + // not using 'this' due to issues with binding on callback + settings.values[args.key] = args.value; + if (++settings.loadedValues == settings.valuesToLoad.length) + settings.initializeOnReady(); + }, + + initializeOnReady: function () { + linkHints.init(); + } +}; /* * Give this frame a unique id. @@ -43,27 +71,16 @@ var textInputXPath = '//input[' + frameId = Math.floor(Math.random()*999999999) var hasModifiersRegex = /^<([amc]-)+.>/; - -function getSetting(key) { - if (!settingPort) - settingPort = chrome.extension.connect({ name: "getSetting" }); - settingPort.postMessage({ key: key }); -} - -function setSetting(args) { settings[args.key] = args.value; } +var googleRegex = /:\/\/[^/]*google[^/]+/; /* - * Complete initialization work that sould be done prior to DOMReady, like setting the page's zoom level. + * Complete initialization work that sould be done prior to DOMReady. */ function initializePreDomReady() { - for (var i in settingsToLoad) { getSetting(settingsToLoad[i]); } + settings.load(); checkIfEnabledForUrl(); - var getZoomLevelPort = chrome.extension.connect({ name: "getZoomLevel" }); - if (window.self == window.parent) - getZoomLevelPort.postMessage({ domain: window.location.host }); - chrome.extension.sendRequest({handler: "getLinkHintCss"}, function (response) { linkHintCss = response.linkHintCss; }); @@ -74,35 +91,36 @@ function initializePreDomReady() { keyPort = chrome.extension.connect({ name: "keyDown" }); chrome.extension.onRequest.addListener(function(request, sender, sendResponse) { - if (request.name == "hideUpgradeNotification") + if (request.name == "hideUpgradeNotification") { HUD.hideUpgradeNotification(); - else if (request.name == "showUpgradeNotification" && isEnabledForUrl) + } else if (request.name == "showUpgradeNotification" && isEnabledForUrl) { HUD.showUpgradeNotification(request.version); - else if (request.name == "showHelpDialog") + } else if (request.name == "showHelpDialog") { if (isShowingHelpDialog) hideHelpDialog(); else showHelpDialog(request.dialogHtml, request.frameId); - else if (request.name == "focusFrame") - if(frameId == request.frameId) + } else if (request.name == "focusFrame") { + if (frameId == request.frameId) focusThisFrame(request.highlight); - else if (request.name == "refreshCompletionKeys") - refreshCompletionKeys(request.completionKeys); + } else if (request.name == "refreshCompletionKeys") { + refreshCompletionKeys(request); + } sendResponse({}); // Free up the resources used by this open connection. }); chrome.extension.onConnect.addListener(function(port, name) { if (port.name == "executePageCommand") { port.onMessage.addListener(function(args) { - if (this[args.command] && frameId == args.frameId) { + if (frameId == args.frameId) { if (args.passCountToFunction) { - this[args.command].call(null, args.count); + utils.invokeCommandString(args.command, [args.count]); } else { - for (var i = 0; i < args.count; i++) { this[args.command].call(); } + for (var i = 0; i < args.count; i++) { utils.invokeCommandString(args.command); } } } - refreshCompletionKeys(args.completionKeys); + refreshCompletionKeys(args); }); } else if (port.name == "getScrollPosition") { @@ -122,14 +140,8 @@ function initializePreDomReady() { port.onMessage.addListener(function(args) { if (getCurrentUrlHandlers.length > 0) { getCurrentUrlHandlers.pop()(args.url); } }); - } else if (port.name == "returnZoomLevel") { - port.onMessage.addListener(function(args) { - currentZoomLevel = args.zoomLevel; - if (isEnabledForUrl) - setPageZoomLevel(currentZoomLevel); - }); } else if (port.name == "returnSetting") { - port.onMessage.addListener(setSetting); + port.onMessage.addListener(settings.receiveMessage); } else if (port.name == "refreshCompletionKeys") { port.onMessage.addListener(function (args) { refreshCompletionKeys(args.completionKeys); @@ -144,6 +156,7 @@ function initializePreDomReady() { function initializeWhenEnabled() { document.addEventListener("keydown", onKeydown, true); document.addEventListener("keypress", onKeypress, true); + document.addEventListener("keyup", onKeyup, true); document.addEventListener("focus", onFocusCapturePhase, true); document.addEventListener("blur", onBlurCapturePhase, true); enterInsertModeIfElementIsFocused(); @@ -183,100 +196,59 @@ function initializeOnDomReady() { }; // This is a little hacky but sometimes the size wasn't available on domReady? -function registerFrameIfSizeAvailable (top) { +function registerFrameIfSizeAvailable (is_top) { if (innerWidth != undefined && innerWidth != 0 && innerHeight != undefined && innerHeight != 0) chrome.extension.sendRequest({ handler: "registerFrame", frameId: frameId, - area: innerWidth * innerHeight, top: top, total: frames.length + 1 }); + area: innerWidth * innerHeight, is_top: is_top, total: frames.length + 1 }); else - setTimeout(function () { registerFrameIfSizeAvailable(top); }, 100); + setTimeout(function () { registerFrameIfSizeAvailable(is_top); }, 100); } /* - * Checks the currently focused element of the document and will enter insert mode if that element is focusable. + * Enters insert mode if the currently focused element in the DOM is focusable. */ function enterInsertModeIfElementIsFocused() { - // Enter insert mode automatically if there's already a text box focused. if (document.activeElement && isEditable(document.activeElement)) - enterInsertMode(); -} - -/* - * Asks the background page to persist the zoom level for the given domain to localStorage. - */ -function saveZoomLevel(domain, zoomLevel) { - if (!saveZoomLevelPort) - saveZoomLevelPort = chrome.extension.connect({ name: "saveZoomLevel" }); - saveZoomLevelPort.postMessage({ domain: domain, zoomLevel: zoomLevel }); -} - -/* - * Zoom in increments of 20%; this matches chrome's CMD+ and CMD- keystrokes. - * Set the zoom style on documentElement because document.body does not exist pre-page load. - */ -function setPageZoomLevel(zoomLevel, showUINotification) { - document.documentElement.style.zoom = zoomLevel + "%"; - if (document.body) - HUD.updatePageZoomLevel(zoomLevel); - if (showUINotification) - HUD.showForDuration("Zoom: " + currentZoomLevel + "%", 1000); -} - -function zoomIn() { - currentZoomLevel += 20; - setAndSaveZoom(); -} - -function zoomOut() { - currentZoomLevel -= 20; - setAndSaveZoom(); -} - -function zoomReset() { - currentZoomLevel = 100; - setAndSaveZoom(); -} - -function setAndSaveZoom() { - setPageZoomLevel(currentZoomLevel, true); - saveZoomLevel(window.location.host, currentZoomLevel); + enterInsertModeWithoutShowingIndicator(document.activeElement); } function scrollToBottom() { window.scrollTo(window.pageXOffset, document.body.scrollHeight); } function scrollToTop() { window.scrollTo(window.pageXOffset, 0); } function scrollToLeft() { window.scrollTo(0, window.pageYOffset); } function scrollToRight() { window.scrollTo(document.body.scrollWidth, window.pageYOffset); } -function scrollUp() { window.scrollBy(0, -1 * settings["scrollStepSize"]); } -function scrollDown() { window.scrollBy(0, settings["scrollStepSize"]); } +function scrollUp() { window.scrollBy(0, -1 * settings.get("scrollStepSize")); } +function scrollDown() { window.scrollBy(0, settings.get("scrollStepSize")); } function scrollPageUp() { window.scrollBy(0, -1 * window.innerHeight / 2); } function scrollPageDown() { window.scrollBy(0, window.innerHeight / 2); } function scrollFullPageUp() { window.scrollBy(0, -window.innerHeight); } function scrollFullPageDown() { window.scrollBy(0, window.innerHeight); } -function scrollLeft() { window.scrollBy(-1 * settings["scrollStepSize"], 0); } -function scrollRight() { window.scrollBy(settings["scrollStepSize"], 0); } +function scrollLeft() { window.scrollBy(-1 * settings.get("scrollStepSize"), 0); } +function scrollRight() { window.scrollBy(settings.get("scrollStepSize"), 0); } function focusInput(count) { - var results = document.evaluate(textInputXPath, - document.documentElement, null, - XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); + var results = utils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_ITERATOR_TYPE); var lastInputBox; var i = 0; while (i < count) { - i += 1; - var currentInputBox = results.iterateNext(); if (!currentInputBox) { break; } + if (linkHints.getVisibleClientRect(currentInputBox) === null) + continue; + lastInputBox = currentInputBox; + + i += 1; } if (lastInputBox) { lastInputBox.focus(); } } function reload() { window.location.reload(); } -function goBack() { history.back(); } -function goForward() { history.forward(); } +function goBack(count) { history.go(-count); } +function goForward(count) { history.go(count); } function goUp(count) { var url = window.location.href; @@ -308,6 +280,8 @@ function copyCurrentUrl() { // TODO(ilya): Convert to sendRequest. var getCurrentUrlPort = chrome.extension.connect({ name: "getCurrentTabUrl" }); getCurrentUrlPort.postMessage({}); + + HUD.showForDuration("Yanked URL", 1000); } function toggleViewSourceCallback(url) { @@ -327,18 +301,18 @@ function toggleViewSourceCallback(url) { * Note that some keys will only register keydown events and not keystroke events, e.g. ESC. */ function onKeypress(event) { - var keyChar = ""; - - if (linkHintsModeActivated) + if (!bubbleEvent('keypress', event)) return; + var keyChar = ""; + // Ignore modifier keys by themselves. if (event.keyCode > 31) { keyChar = String.fromCharCode(event.charCode); // Enter insert mode when the user enables the native find interface. if (keyChar == "f" && isPrimaryModifierKey(event)) { - enterInsertMode(); + enterInsertModeWithoutShowingIndicator(); return; } @@ -349,7 +323,7 @@ function onKeypress(event) { // Don't let the space scroll us if we're searching. if (event.keyCode == keyCodes.space) event.preventDefault(); - } else if (!insertMode && !findMode) { + } else if (!isInsertMode() && !findMode) { if (currentCompletionKeys.indexOf(keyChar) != -1) { event.preventDefault(); event.stopPropagation(); @@ -361,18 +335,32 @@ function onKeypress(event) { } } -function onKeydown(event) { - var keyChar = ""; +/** + * Called whenever we receive a key event. Each individual handler has the option to stop the event's + * propagation by returning a falsy value. + */ +function bubbleEvent(type, event) { + for (var i = handlerStack.length-1; i >= 0; i--) { + // We need to check for existence of handler because the last function call may have caused the release of + // more than one handler. + if (handlerStack[i] && handlerStack[i][type] && !handlerStack[i][type](event)) + return false; + } + return true; +} - if (linkHintsModeActivated) +function onKeydown(event) { + if (!bubbleEvent('keydown', event)) return; + var keyChar = ""; + // handle modifiers being pressed.don't handle shiftKey alone (to avoid / being interpreted as ? - if (event.metaKey && event.keyCode > 31 || event.ctrlKey && event.keyCode > 31 || event.altKey && event.keyCode > 31) { + if (event.metaKey && event.keyCode > 31 || event.ctrlKey && event.keyCode > 31 || + event.altKey && event.keyCode > 31) { keyChar = getKeyChar(event); - if (keyChar != "") // Again, ignore just modifiers. Maybe this should replace the keyCode > 31 condition. - { + if (keyChar != "") { // Again, ignore just modifiers. Maybe this should replace the keyCode>31 condition. var modifiers = []; if (event.shiftKey) @@ -392,44 +380,45 @@ function onKeydown(event) { } } - if (insertMode && isEscape(event)) - { + if (isInsertMode() && isEscape(event)) { // Note that we can't programmatically blur out of Flash embeds from Javascript. if (!isEmbed(event.srcElement)) { - // Remove focus so the user can't just get himself back into insert mode by typing in the same input box. - if (isEditable(event.srcElement)) { event.srcElement.blur(); } + // Remove focus so the user can't just get himself back into insert mode by typing in the same input + // box. + if (isEditable(event.srcElement)) + event.srcElement.blur(); exitInsertMode(); - // Added to prevent Google Instant from reclaiming the keystroke and putting us back into the search box. - // TOOD(ilya): Revisit this. Not sure it's the absolute best approach. - event.stopPropagation(); + // Added to prevent Google Instant from reclaiming the keystroke and putting us back into the search + // box. + if (isGoogleSearch()) + event.stopPropagation(); } } - else if (findMode) - { - if (isEscape(event)) + else if (findMode) { + if (isEscape(event)) { exitFindMode(); // Don't let backspace take us back in history. - else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) - { + } + else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { handleDeleteForFindMode(); event.preventDefault(); } - else if (event.keyCode == keyCodes.enter) + else if (event.keyCode == keyCodes.enter) { handleEnterForFindMode(); + } } - else if (isShowingHelpDialog && isEscape(event)) - { + else if (isShowingHelpDialog && isEscape(event)) { hideHelpDialog(); } - else if (!insertMode && !findMode) { + else if (!isInsertMode() && !findMode) { if (keyChar) { - if (currentCompletionKeys.indexOf(keyChar) != -1) { - event.preventDefault(); - event.stopPropagation(); - } + if (currentCompletionKeys.indexOf(keyChar) != -1) { + event.preventDefault(); + event.stopPropagation(); + } - keyPort.postMessage({keyChar:keyChar, frameId:frameId}); + keyPort.postMessage({keyChar:keyChar, frameId:frameId}); } else if (isEscape(event)) { keyPort.postMessage({keyChar:"<ESC>", frameId:frameId}); @@ -443,40 +432,55 @@ function onKeydown(event) { // Subject to internationalization issues since we're using keyIdentifier instead of charCode (in keypress). // // TOOD(ilya): Revisit this. Not sure it's the absolute best approach. - if (keyChar == "" && !insertMode && currentCompletionKeys.indexOf(getKeyChar(event)) != -1) + if (keyChar == "" && !isInsertMode() && (currentCompletionKeys.indexOf(getKeyChar(event)) != -1 || + validFirstKeys[getKeyChar(event)])) event.stopPropagation(); } +function onKeyup() { + if (!bubbleEvent('keyup', event)) + return; +} + function checkIfEnabledForUrl() { - var url = window.location.toString(); - - chrome.extension.sendRequest({ handler: "isEnabledForUrl", url: url }, function (response) { - isEnabledForUrl = response.isEnabledForUrl; - if (isEnabledForUrl) - initializeWhenEnabled(); - else if (HUD.isReady()) - // Quickly hide any HUD we might already be showing, e.g. if we entered insertMode on page load. - HUD.hide(); - }); + var url = window.location.toString(); + + chrome.extension.sendRequest({ handler: "isEnabledForUrl", url: url }, function (response) { + isEnabledForUrl = response.isEnabledForUrl; + if (isEnabledForUrl) + initializeWhenEnabled(); + else if (HUD.isReady()) + // Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load. + HUD.hide(); + }); } -function refreshCompletionKeys(completionKeys) { - if (completionKeys) - currentCompletionKeys = completionKeys; - else - chrome.extension.sendRequest({handler: "getCompletionKeys"}, function (response) { - currentCompletionKeys = response.completionKeys; - }); +// TODO(ilya): This just checks if "google" is in the domain name. Probably should be more targeted. +function isGoogleSearch() { + var url = window.location.toString(); + return !!url.match(googleRegex); +} + +function refreshCompletionKeys(response) { + if (response) { + currentCompletionKeys = response.completionKeys; + + if (response.validFirstKeys) + validFirstKeys = response.validFirstKeys; + } + else { + chrome.extension.sendRequest({ handler: "getCompletionKeys" }, refreshCompletionKeys); + } } function onFocusCapturePhase(event) { if (isFocusable(event.target)) - enterInsertMode(); + enterInsertModeWithoutShowingIndicator(event.target); } function onBlurCapturePhase(event) { if (isFocusable(event.target)) - exitInsertMode(); + exitInsertMode(event.target); } /* @@ -488,32 +492,52 @@ function isFocusable(element) { return isEditable(element) || isEmbed(element); * Embedded elements like Flash and quicktime players can obtain focus but cannot be programmatically * unfocused. */ -function isEmbed(element) { return ["EMBED", "OBJECT"].indexOf(element.tagName) > 0; } +function isEmbed(element) { return ["embed", "object"].indexOf(element.nodeName.toLowerCase()) > 0; } /* * Input or text elements are considered focusable and able to receieve their own keyboard events, * and will enter enter mode if focused. Also note that the "contentEditable" attribute can be set on * any element which makes it a rich text editor, like the notes on jjot.com. - * Note: we used to discriminate for text-only inputs, but this is not accurate since all input fields - * can be controlled via the keyboard, particuarlly SELECT combo boxes. */ function isEditable(target) { - if (target.getAttribute("contentEditable") == "true") + if (target.isContentEditable) + return true; + var nodeName = target.nodeName.toLowerCase(); + // use a blacklist instead of a whitelist because new form controls are still being implemented for html5 + var noFocus = ["radio", "checkbox"]; + if (nodeName == "input" && noFocus.indexOf(target.type) == -1) return true; - var focusableInputs = ["input", "textarea", "select", "button"]; - return focusableInputs.indexOf(target.tagName.toLowerCase()) >= 0; + var focusableElements = ["textarea", "select"]; + return focusableElements.indexOf(nodeName) >= 0; } -function enterInsertMode() { - insertMode = true; +/* + * Enters insert mode and show an "Insert mode" message. Showing the UI is only useful when entering insert + * mode manually by pressing "i". In most cases we do not show any UI (enterInsertModeWithoutShowingIndicator) + */ +function enterInsertMode(target) { + enterInsertModeWithoutShowingIndicator(target); HUD.show("Insert mode"); } -function exitInsertMode() { - insertMode = false; - HUD.hide(); +/* + * We cannot count on 'focus' and 'blur' events to happen sequentially. For example, if blurring element A + * causes element B to come into focus, we may get "B focus" before "A blur". Thus we only leave insert mode + * when the last editable element that came into focus -- which insertModeLock points to -- has been blurred. + * If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only + * leave insert mode when the user presses <ESC>. + */ +function enterInsertModeWithoutShowingIndicator(target) { insertModeLock = target; } + +function exitInsertMode(target) { + if (target === undefined || insertModeLock === target) { + insertModeLock = null; + HUD.hide(); + } } +function isInsertMode() { return insertModeLock !== null; } + function handleKeyCharForFindMode(keyChar) { findModeQuery = findModeQuery + keyChar; performFindInPlace(); @@ -521,13 +545,11 @@ function handleKeyCharForFindMode(keyChar) { } function handleDeleteForFindMode() { - if (findModeQuery.length == 0) - { + if (findModeQuery.length == 0) { exitFindMode(); performFindInPlace(); } - else - { + else { findModeQuery = findModeQuery.substring(0, findModeQuery.length - 1); performFindInPlace(); showFindModeHUDForQuery(); @@ -551,31 +573,51 @@ function performFindInPlace() { // backwards. window.scrollTo(cachedScrollX, cachedScrollY); - performFind(); + executeFind(); } -function performFind() { - findModeQueryHasResults = window.find(findModeQuery, false, false, true, false, true, false); +function executeFind(backwards) { + findModeQueryHasResults = window.find(findModeQuery, false, backwards, true, false, true, false); } -function performBackwardsFind() { - findModeQueryHasResults = window.find(findModeQuery, false, true, true, false, true, false); +function focusFoundLink() { + if (findModeQueryHasResults) { + var link = getLinkFromSelection(); + if (link) + link.focus(); + } +} + +function findAndFocus(backwards) { + executeFind(backwards); + focusFoundLink(); +} + +function performFind() { findAndFocus(); } + +function performBackwardsFind() { findAndFocus(true); } + +function getLinkFromSelection() { + var node = window.getSelection().anchorNode; + while (node.nodeName.toLowerCase() !== 'body') { + if (node.nodeName.toLowerCase() === 'a') return node; + node = node.parentNode; + } + return null; } function findAndFollowLink(linkStrings) { for (i = 0; i < linkStrings.length; i++) { - var findModeQueryHasResults = window.find(linkStrings[i], false, true, true, false, true, false); - if (findModeQueryHasResults) { - var node = window.getSelection().anchorNode; - while (node.nodeName != 'BODY') { - if (node.nodeName == 'A') { - window.location = node.href; - return true; - } - node = node.parentNode; + var hasResults = window.find(linkStrings[i], false, true, true, false, true, false); + if (hasResults) { + var link = getLinkFromSelection(); + if (link) { + window.location = link.href; + return true; } } } + return false; } function findAndFollowRel(value) { @@ -592,13 +634,13 @@ function findAndFollowRel(value) { } function goPrevious() { - var previousPatterns = settings["previousPatterns"] || ""; + var previousPatterns = settings.get("previousPatterns") || ""; var previousStrings = previousPatterns.split(","); findAndFollowRel('prev') || findAndFollowLink(previousStrings); } function goNext() { - var nextPatterns = settings["nextPatterns"] || ""; + var nextPatterns = settings.get("nextPatterns") || ""; var nextStrings = nextPatterns.split(","); findAndFollowRel('next') || findAndFollowLink(nextStrings); } @@ -616,12 +658,11 @@ function showFindModeHUDForQuery() { function insertSpaces(query) { var newQuery = ""; - for (var i = 0; i < query.length; i++) - { + for (var i = 0; i < query.length; i++) { if (query[i] == " " || (i + 1 < query.length && query[i + 1] == " ")) newQuery = newQuery + query[i]; - else - newQuery = newQuery + query[i] + "<span style=\"font-size: 0px;\"> </span>"; + else // ​ is a zero-width space + newQuery = newQuery + query[i] + "<span>​</span>"; } return newQuery; @@ -635,6 +676,7 @@ function enterFindMode() { function exitFindMode() { findMode = false; + focusFoundLink(); HUD.hide(); } @@ -648,13 +690,14 @@ function showHelpDialog(html, fid) { document.body.appendChild(container); container.innerHTML = html; + container.getElementsByClassName("closeButton")[0].addEventListener("click", hideHelpDialog, false); + container.getElementsByClassName("optionsPage")[0].addEventListener("click", + function() { chrome.extension.sendRequest({ handler: "openOptionsPageInNewTab" }); }, false); + // This is necessary because innerHTML does not evaluate javascript embedded in <script> tags. var scripts = Array.prototype.slice.call(container.getElementsByTagName("script")); scripts.forEach(function(script) { eval(script.text); }); - container.getElementsByClassName("closeButton")[0].addEventListener("click", hideHelpDialog, false); - container.getElementsByClassName("optionsPage")[0].addEventListener("click", - function() { chrome.extension.sendRequest({ handler: "openOptionsPageInNewTab" }); }, false); } function hideHelpDialog(clickEvent) { @@ -763,15 +806,6 @@ HUD = { function() { HUD.upgradeNotificationElement().style.display = "none"; }); }, - updatePageZoomLevel: function(pageZoomLevel) { - // Since the chrome HUD does not scale with the page's zoom level, neither will this HUD. - var inverseZoomLevel = (100.0 / pageZoomLevel) * 100; - if (HUD._displayElement) - HUD.displayElement().style.zoom = inverseZoomLevel + "%"; - if (HUD._upgradeNotificationElement) - HUD.upgradeNotificationElement().style.zoom = inverseZoomLevel + "%"; - }, - /* * Retrieves the HUD HTML element. */ @@ -780,7 +814,6 @@ HUD = { HUD._displayElement = HUD.createHudElement(); // Keep this far enough to the right so that it doesn't collide with the "popups blocked" chrome HUD. HUD._displayElement.style.right = "150px"; - HUD.updatePageZoomLevel(currentZoomLevel); } return HUD._displayElement; }, @@ -790,7 +823,6 @@ HUD = { HUD._upgradeNotificationElement = HUD.createHudElement(); // Position this just to the left of our normal HUD. HUD._upgradeNotificationElement.style.right = "315px"; - HUD.updatePageZoomLevel(currentZoomLevel); } return HUD._upgradeNotificationElement; }, |
