diff options
| -rw-r--r-- | .gitmodules | 3 | ||||
| -rw-r--r-- | CREDITS | 1 | ||||
| -rw-r--r-- | background_page.html | 1 | ||||
| -rw-r--r-- | lib/utils.js | 54 | ||||
| -rw-r--r-- | linkHints.js | 840 | ||||
| -rw-r--r-- | manifest.json | 3 | ||||
| -rw-r--r-- | options.html | 69 | ||||
| -rw-r--r-- | test_harnesses/automated.html | 253 | ||||
| m--------- | test_harnesses/shoulda.js | 0 | ||||
| -rw-r--r-- | vimiumFrontend.js | 65 |
10 files changed, 957 insertions, 332 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 @@ -20,6 +20,7 @@ Contributors: 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) Feel free to add real names in addition to GitHub usernames. diff --git a/background_page.html b/background_page.html index d98967b4..abb91677 100644 --- a/background_page.html +++ b/background_page.html @@ -26,6 +26,7 @@ scrollStepSize: 60, defaultZoomLevel: 100, linkHintCharacters: "sadfjklewcmpgh", + filterLinkHints: false, userDefinedLinkHintCss: ".vimiumHintMarker {\n\n}\n" + ".vimiumHintMarker > .matchingCharacter {\n\n}", diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..7fc2a6b9 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,54 @@ +var utils = { + // probably doesn't handle some cases correctly, but it works fine for what + // we have now + deepCopy: function(original) { + var result; + if (typeof original == 'object') { + if (original === null) { + result = null; + } else { + result = original.constructor === Array ? [] : {}; + for (var i in original) + if (original.hasOwnProperty(i)) + result[i] = this.deepCopy(original[i]); + } + } else { + result = original; + } + + return result; + }, + + /* + * Extends 'original' with 'ext'. If a function in 'ext' also exists in + * 'original', let the 'original' function be accessible in the new object + * via a ._super(functionName as String) method. _Cannot_ be used on its + * result to achieve 'two-level' inheritance. + */ + extendWithSuper: function(original, ext) { + var result = this.deepCopy(original); + var tmpSuper = result._super; + result._superFunctions = {}; + result._super = function(fname) { return this._superFunctions[fname].bind(this); } + for (var i in ext) + if (ext.hasOwnProperty(i)) { + if (typeof ext[i] == 'function' && typeof original[i] == 'function') + result._superFunctions[i] = this.deepCopy(original[i]); + result[i] = this.deepCopy(ext[i]); + } + return result; + }, + + /* + * 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); + }, +}; diff --git a/linkHints.js b/linkHints.js index 58d6e979..6bd84826 100644 --- a/linkHints.js +++ b/linkHints.js @@ -1,337 +1,581 @@ /* - * 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"); - else - HUD.show("Open link in current tab"); - } -} +var linkHints; /* - * Builds and displays link hints for every visible clickable item on the page. + * Create the instance of linkHints, specialized based on the user settings. */ -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; i < visibleElements.length; 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 initializeLinkHints() { + if (settings.get('filterLinkHints') != "true") // the default hinting system + linkHints = utils.extendWithSuper(linkHintsBase, alphabetHints); + else + linkHints = utils.extendWithSuper(linkHintsBase, filterHints); + linkHints.init(); } -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. + * A set of common operations shared by any link-hinting system. Some methods + * are stubbed. */ -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; i < resultSet.snapshotLength; 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; j < element.children.length; j++) { - if (window.getComputedStyle(element.children[j], null).getPropertyValue('float') != 'none') { +var linkHintsBase = { + hintMarkers: [], + hintMarkerContainingDiv: null, + // The characters that were typed in while in "link hints" mode. + modeActivated: false, + shouldOpenInNewTab: false, + shouldOpenWithQueue: 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, + + /* + * To be called after linkHints has been generated from linkHintsBase. + */ + init: function() { + this.onKeyDownInMode = this.onKeyDownInMode.bind(this); + this.onKeyUpInMode = this.onKeyUpInMode.bind(this); + }, + + /* + * Generate an XPath describing what a clickable element is. + * The final expression will be something like "//button | //xhtml:button | ..." + */ + 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. + activateModeToOpenInNewTab: function() { this.activateMode(true, false); }, + + activateModeWithQueue: function() { this.activateMode(true, true); }, + + activateMode: function (openInNewTab, withQueue) { + if (!this.cssAdded) + addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js + this.linkHintCssAdded = true; + this.modeActivated = true; + this.setOpenLinkMode(openInNewTab, withQueue); + this.buildLinkHints(); + document.addEventListener("keydown", this.onKeyDownInMode, true); + document.addEventListener("keyup", this.onKeyUpInMode, true); + }, + + setOpenLinkMode: function(openInNewTab, withQueue) { + this.shouldOpenInNewTab = openInNewTab; + this.shouldOpenWithQueue = withQueue; + 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(); + + // 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 linkHintNumber = 0; + this.initSetMarkerAttributes(visibleElements); + for (var i = 0; i < visibleElements.length; i++) { + this.hintMarkers.push(this.createMarkerFor(visibleElements[i], linkHintNumber)); + 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. + 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 + this.deactivateMode(); + }, + + /* + * Sets the data attributes of the marker. Does not need to handle styling + * and positioning. MUST set the hintString and innerHTML properties. + */ + setMarkerAttributes: function(marker, linkHintNumber) {}, + + /* + * A hook for any necessary initialization for setMarkerAttributes. Takes an + * array of visible elements. Any return value is ignored. + */ + initSetMarkerAttributes: function(visibleElements) { + }, + + /* + * 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 = document.evaluate(this.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; i < resultSet.snapshotLength; i++) { + var element = resultSet.snapshotItem(i); + var clientRect = element.getClientRects()[0]; + + if (this.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; j < element.children.length; j++) { + if (window.getComputedStyle(element.children[j], null).getPropertyValue('float') == 'none') + continue; var childClientRect = element.children[j].getClientRects()[0]; - if (isVisible(element.children[j], childClientRect)) { - visibleElements.push({element: element.children[j], rect: childClientRect}); - break; - } + if (!this.isVisible(element.children[j], childClientRect)) + continue; + visibleElements.push({element: element.children[j], rect: childClientRect}); + break; } } } - } - 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; - } - - var keyChar = getKeyChar(event); - if (!keyChar) - return; + return visibleElements; + }, + + /* + * Returns true if element is visible. + */ + isVisible: function(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; + }, + + /* + * Handles shift and esc keys. The other keys are passed to normalKeyDownHandler. + */ + 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); + 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.normalKeyDownHandler(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.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(); -} + /* + * Handle all keys other than shift and esc. Return value is ignored. + */ + normalKeyDownHandler: function(event) {}, -/* - * 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 (event.keyCode == keyCodes.shiftKey && this.openLinkModeToggle) { + // Revert toggle on whether to open link in new or current tab. + this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue); + 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); } 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.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"; + }, + + 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; j < linkMarker.childNodes.length; 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); + }, + + /* + * 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.hintMarkerContainingDiv) + that.hintMarkerContainingDiv.parentNode.removeChild(that.hintMarkerContainingDiv); + that.hintMarkerContainingDiv = null; + that.hintMarkers = []; + that.hintKeystrokeQueue = []; + document.removeEventListener("keydown", that.onKeyDownInMode, true); + document.removeEventListener("keyup", that.onKeyUpInMode, true); + that.modeActivated = false; + HUD.hide(); + } + // we invoke the deactivate() function directly instead of using setTimeout(callback, 0) so that + // deactivateMode can be tested synchronously + if (!delay) + deactivate(); + else + setTimeout(function() { deactivate(); if (callback) callback(); }, delay); + }, + + /* + * Creates a link marker for the given link. + */ + createMarkerFor: function(link, linkHintNumber) { + var marker = document.createElement("div"); + marker.className = "internalVimiumHintMarker vimiumHintMarker"; + marker.clickableItem = link.element; + this.setMarkerAttributes(marker, linkHintNumber); + + // 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"; + + return marker; + }, + +}; + +var alphabetHints = { + hintKeystrokeQueue: [], + digitsNeeded: 1, + logXOfBase: function(x, base) { return Math.log(x) / Math.log(base); }, + + initSetMarkerAttributes: function(visibleElements) { + this.digitsNeeded = Math.ceil(this.logXOfBase( + visibleElements.length, settings.get('linkHintCharacters').length)); + }, + + setMarkerAttributes: function(marker, linkHintNumber) { + var hintString = this.numberToHintString(linkHintNumber, this.digitsNeeded); + marker.innerHTML = spanWrap(hintString); + marker.setAttribute("hintString", hintString); + return marker; + }, + + /* + * 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. + var hintStringLength = hintString.length; + for (var i = 0; i < numHintDigits - hintStringLength; i++) + hintString.unshift(settings.get('linkHintCharacters')[0]); + return hintString.join(""); + }, + + normalKeyDownHandler: function (event, hintMarkers) { + var keyChar = getKeyChar(event); + if (!keyChar) + return hintMarkers; + + if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { + if (this.hintKeystrokeQueue.length == 0) { + return []; + } else { + this.hintKeystrokeQueue.pop(); + var matchString = this.hintKeystrokeQueue.join(""); + var linksMatched = hintMarkers.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 = hintMarkers.filter(function(linkMarker) { + return linkMarker.getAttribute("hintString").indexOf(matchString) == 0; + }); + } + return { 'linksMatched': linksMatched }; } -} - -/* - * 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"; -} - -/* - * 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 = ""; - for (var j = 0; j < linkMarker.childNodes.length; j++) - linkMarker.childNodes[j].className = (j >= searchString.length) ? "" : "matchingCharacter"; - linksMatched.push(linkMarker.clickableItem); +}; + +var filterHints = { + hintKeystrokeQueue: [], + linkTextKeystrokeQueue: [], + labelMap: {}, + + /* + * Generate a map of input element => label + */ + initSetMarkerAttributes: function() { + var labels = document.querySelectorAll("label"); + for (var i = 0; i < labels.length; 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; -} - -/* - * 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. - var hintStringLength = hintString.length; - for (var i = 0; i < numHintDigits - hintStringLength; i++) - hintString.unshift(settings.linkHintCharacters[0]); - return hintString.join(""); -} + linkText = linkText.trim().toLowerCase(); + marker.setAttribute("hintString", hintString); + marker.innerHTML = spanWrap(hintString + (showLinkText ? ": " + linkText : "")); + marker.setAttribute("linkText", linkText); + }, + + normalKeyDownHandler: function(event, hintMarkers) { + var linksMatched; + if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { + if (this.linkTextKeystrokeQueue.length == 0 && this.hintKeystrokeQueue.length == 0) { + return []; + } else { + // backspace clears hint key queue first, then acts on link text key queue + if (this.hintKeystrokeQueue.pop()) + linksMatched = this.filterLinkHints(); + else { + this.linkTextKeystrokeQueue.pop(); + linksMatched = this.filterLinkHints(); + } + } + return linksMatched; + } else if (event.keyCode == keyCodes.enter) { + // activate the lowest-numbered link hint that is visible + for (var i = 0; i < hintMarkers.length; i++) + if (hintMarkers[i].style.display != 'none') + return [ hintMarkers[i] ]; + } else { + var keyChar = getKeyChar(event); + if (!keyChar) + return hintMarkers; + + var matchString; + if (/[0-9]/.test(keyChar)) { + this.hintKeystrokeQueue.push(keyChar); + matchString = this.hintKeystrokeQueue.join(""); + linksMatched = hintMarkers.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); + matchString = this.linkTextKeystrokeQueue.join(""); + linksMatched = this.filterLinkHints(matchString); + } -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); -} + 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(searchString) { + var linksMatched = []; + var linkSearchString = this.linkTextKeystrokeQueue.join(""); + + for (var i = 0; i < this.hintMarkers.length; i++) { + var linkMarker = this.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; + }, -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(); -} + deactivateMode: function(delay, callback) { + this.linkTextKeystrokeQueue = []; + this.labelMap = {}; + this._super('deactivateMode')(delay, callback); + } -function resetLinkHintsMode() { - deactivateLinkHintsMode(); - activateLinkHintsModeWithQueue(); -} +}; /* - * Creates a link marker for the given link. + * Make each hint character a span, so that we can highlight the typed characters as you type them. */ -function createMarkerFor(link, linkHintNumber, linkHintDigits) { - var hintString = numberToHintString(linkHintNumber, linkHintDigits); - var marker = document.createElement("div"); - marker.className = "internalVimiumHintMarker vimiumHintMarker"; +function spanWrap(hintString) { 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; + return innerHTML.join(""); } diff --git a/manifest.json b/manifest.json index 161bdb30..181c8739 100644 --- a/manifest.json +++ b/manifest.json @@ -15,7 +15,8 @@ "content_scripts": [ { "matches": ["<all_urls>"], - "js": ["lib/keyboardUtils.js", + "js": ["lib/utils.js", + "lib/keyboardUtils.js", "lib/clipboard.js", "linkHints.js", "vimiumFrontend.js" diff --git a/options.html b/options.html index bfa01450..1724f51b 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> @@ -70,7 +71,10 @@ tr.advancedOption { display:none; } - + input:read-only { + background-color: #eee; + color: #666; + } </style> <script type="text/javascript"> @@ -80,7 +84,7 @@ var defaultSettings = chrome.extension.getBackgroundPage().defaultSettings; var editableFields = ["scrollStepSize", "defaultZoomLevel", "excludedUrls", "linkHintCharacters", - "userDefinedLinkHintCss", "keyMappings"]; + "userDefinedLinkHintCss", "keyMappings", "filterLinkHints"]; var postSaveHooks = { keyMappings: function (value) { @@ -93,8 +97,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 () { showDialog("vimiumCommandListingContainer", @@ -107,6 +116,10 @@ enableSaveButton(); } + function onDataLoaded() { + $("linkHintCharacters").readOnly = $("filterLinkHints").checked; + } + function enableSaveButton() { $("saveOptions").removeAttribute("disabled"); } // Saves options to localStorage. @@ -115,15 +128,25 @@ // 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() : ""; + if (fieldValue == defaultFieldValue) delete localStorage[fieldName]; else localStorage[fieldName] = fieldValue; - $(fieldName).value = fieldValue; - $(fieldName).setAttribute("savedValue", fieldValue); + + field.setAttribute("savedValue", fieldValue); if (postSaveHooks[fieldName]) { postSaveHooks[fieldName](fieldValue); } } @@ -133,17 +156,31 @@ // Restores select box state to saved value from localStorage. function populateOptions() { for (var i = 0; i < editableFields.length; i++) { - $(editableFields[i]).value = localStorage[editableFields[i]] || defaultSettings[editableFields[i]] || ""; - $(editableFields[i]).setAttribute("savedValue", $(editableFields[i]).value); + var val = localStorage[editableFields[i]] || defaultSettings[editableFields[i]] || ""; + var field = $(editableFields[i]); + 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++) @@ -255,6 +292,18 @@ <textarea id="userDefinedLinkHintCss" type="text"></textarea> </td> </tr> + <tr class="advancedOption"> + <td class="caption">Filter link hints</td> + <td verticalAlign="top"> + <div class="help"> + <div class="example"> + Typing in link hints mode will filter link hints by the link text.<br/><br/> + Note: You <em>must</em> use numeric link hint characters in this mode + </div> + </div> + <input id="filterLinkHints" type="checkbox"/> + </td> + </tr> </table> <div id="buttonsPanel"> diff --git a/test_harnesses/automated.html b/test_harnesses/automated.html new file mode 100644 index 00000000..bbfa220f --- /dev/null +++ b/test_harnesses/automated.html @@ -0,0 +1,253 @@ +<!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; + settings.values.filterLinkHints = isFilteredMode ? "true" : "false"; + initializeLinkHints(); + }), + + 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"); + initializeLinkHints(); + // 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"); + initializeLinkHints(); + }), + + 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/shoulda.js b/test_harnesses/shoulda.js new file mode 160000 +Subproject 695d0eb2084de5380dccac8c9b188ce91d838dc diff --git a/vimiumFrontend.js b/vimiumFrontend.js index 7c90aec5..c5b7bdf8 100644 --- a/vimiumFrontend.js +++ b/vimiumFrontend.js @@ -4,8 +4,6 @@ * 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". */ -var settings = {}; -var settingsToLoad = ["scrollStepSize", "linkHintCharacters"]; var getCurrentUrlHandlers = []; // function(url) @@ -37,6 +35,35 @@ var textInputXPath = '//input[' + textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + ' or not(@type)]'; +var settings = { + values: {}, + loadedValues: 0, + valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints"], + + 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 () { + initializeLinkHints(); + } +}; + /* * Give this frame a unique id. */ @@ -44,19 +71,11 @@ 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; } - /* * Complete initialization work that sould be done prior to DOMReady, like setting the page's zoom level. */ function initializePreDomReady() { - for (var i in settingsToLoad) { getSetting(settingsToLoad[i]); } + settings.load(); checkIfEnabledForUrl(); @@ -93,11 +112,11 @@ function initializePreDomReady() { 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); } } } @@ -128,7 +147,7 @@ function initializePreDomReady() { 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); @@ -238,14 +257,14 @@ function scrollToBottom() { window.scrollTo(window.pageXOffset, document.body.sc 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, @@ -322,7 +341,7 @@ function toggleViewSourceCallback(url) { function onKeypress(event) { var keyChar = ""; - if (linkHintsModeActivated) + if (linkHints.modeActivated) return; // Ignore modifier keys by themselves. @@ -357,7 +376,7 @@ function onKeypress(event) { function onKeydown(event) { var keyChar = ""; - if (linkHintsModeActivated) + if (linkHints.modeActivated) return; // handle modifiers being pressed.don't handle shiftKey alone (to avoid / being interpreted as ? @@ -481,7 +500,7 @@ 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, @@ -494,7 +513,7 @@ function isEditable(target) { if (target.getAttribute("contentEditable") == "true") return true; var focusableInputs = ["input", "textarea", "select", "button"]; - return focusableInputs.indexOf(target.tagName.toLowerCase()) >= 0; + return focusableInputs.indexOf(target.nodeName.toLowerCase()) >= 0; } function enterInsertMode() { |
