aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitmodules3
-rw-r--r--CREDITS1
-rw-r--r--background_page.html10
-rw-r--r--bookmarks.js138
-rw-r--r--commands.js22
-rw-r--r--completionDialog.js177
-rw-r--r--lib/utils.js14
-rw-r--r--linkHints.js850
-rw-r--r--manifest.json8
-rw-r--r--options.html65
-rw-r--r--test_harnesses/automated.html252
m---------test_harnesses/shoulda.js0
-rw-r--r--vimiumFrontend.js93
13 files changed, 1273 insertions, 360 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
diff --git a/CREDITS b/CREDITS
index 2b9a2ef6..eb8147d0 100644
--- a/CREDITS
+++ b/CREDITS
@@ -21,6 +21,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)
Justin Blake <justin@hentzia.com> (github: blaix)
diff --git a/background_page.html b/background_page.html
index 5849bde3..71bbcf7a 100644
--- a/background_page.html
+++ b/background_page.html
@@ -27,6 +27,7 @@
scrollStepSize: 60,
defaultZoomLevel: 100,
linkHintCharacters: "sadfjklewcmpgh",
+ filterLinkHints: false,
userDefinedLinkHintCss:
".vimiumHintMarker {\n\n}\n" +
".vimiumHintMarker > .matchingCharacter {\n\n}",
@@ -65,7 +66,8 @@
getCurrentTabUrl: getCurrentTabUrl,
getZoomLevel: getZoomLevel,
saveZoomLevel: saveZoomLevel,
- getSetting: getSetting
+ getSetting: getSetting,
+ getBookmarks: getBookmarks
};
var sendRequestHandlers = {
@@ -291,6 +293,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.
*/
diff --git a/bookmarks.js b/bookmarks.js
new file mode 100644
index 00000000..99e54e9c
--- /dev/null
+++ b/bookmarks.js
@@ -0,0 +1,138 @@
+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});
+ };
+
+ //export global
+ window.BookmarkMode = BookmarkMode;
+
+}())
diff --git a/commands.js b/commands.js
index c360729b..d4613c12 100644
--- a/commands.js
+++ b/commands.js
@@ -117,9 +117,9 @@ function clearKeyMappingsAndSetDefaults() {
"gi": "focusInput",
- "f": "activateLinkHintsMode",
- "F": "activateLinkHintsModeToOpenInNewTab",
- "<a-f>": "activateLinkHintsModeWithQueue",
+ "f": "linkHints.activateMode",
+ "F": "linkHints.activateModeToOpenInNewTab",
+ "<a-f>": "linkHints.activateModeWithQueue",
"/": "enterFindMode",
"n": "performFind",
@@ -139,6 +139,9 @@ function clearKeyMappingsAndSetDefaults() {
"x": "removeTab",
"X": "restoreTab",
+ "b": "activateBookmarkFindMode",
+ "B": "activateBookmarkFindModeToOpenInNewTab",
+
"gf": "nextFrame"
};
@@ -175,9 +178,9 @@ var commandDescriptions = {
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"],
@@ -200,6 +203,9 @@ var commandDescriptions = {
removeTab: ["Close current tab", { background: true }],
restoreTab: ["Restore closed tab", { 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 }]
};
@@ -216,7 +222,7 @@ var commandGroups = {
"scrollPageUp", "scrollFullPageUp", "scrollFullPageDown",
"reload", "toggleViewSource", "zoomIn", "zoomOut", "zoomReset", "copyCurrentUrl", "goUp",
"enterInsertMode", "focusInput",
- "activateLinkHintsMode", "activateLinkHintsModeToOpenInNewTab", "activateLinkHintsModeWithQueue",
+ "linkHints.activateMode", "linkHints.activateModeToOpenInNewTab", "linkHints.activateModeWithQueue",
"goPrevious", "goNext", "nextFrame"],
findCommands: ["enterFindMode", "performFind", "performBackwardsFind"],
historyNavigation:
@@ -232,5 +238,5 @@ var commandGroups = {
// from Vimium will uncover these gems.
var advancedCommands = [
"scrollToLeft", "scrollToRight",
- "zoomReset", "goUp", "focusInput", "activateLinkHintsModeWithQueue",
+ "zoomReset", "goUp", "focusInput", "linkHints.activateModeWithQueue",
"goPrevious", "goNext"];
diff --git a/completionDialog.js b/completionDialog.js
new file mode 100644
index 00000000..1b2d3646
--- /dev/null
+++ b/completionDialog.js
@@ -0,0 +1,177 @@
+(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._tweenId = Tween.fade(this.container, 1.0, 150);
+ }
+ },
+ hide: function() {
+ if(this.isShown) {
+ handlerStack.pop();
+ this.isShown=false;
+ this.currentSelection=0;
+ clearInterval(this._tweenId);
+ this._tweenId = Tween.fade(this.container, 0, 150);
+ }
+ },
+ 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);
+ if(keyChar==="up") {
+ if(self.currentSelection>0) {
+ self.currentSelection-=1;
+ }
+ render.call(self,self.getQueryString(), self.completions);
+ }
+ else if(keyChar==="down") {
+ 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=(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/lib/utils.js b/lib/utils.js
new file mode 100644
index 00000000..0c992ad4
--- /dev/null
+++ b/lib/utils.js
@@ -0,0 +1,14 @@
+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);
+ },
+};
diff --git a/linkHints.js b/linkHints.js
index f8e079bf..7eb40b71 100644
--- a/linkHints.js
+++ b/linkHints.js
@@ -1,339 +1,563 @@
/*
- * 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.
+ * 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 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 | ..."
- */
-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,
+ // 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.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: (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.setOpenLinkMode(openInNewTab, withQueue);
+ this.buildLinkHints();
+ handlerStack.push({ // modeKeyHandler is declared by vimiumFrontend.js
+ keydown: this.onKeyDownInMode,
+ keyup: this.onKeyUpInMode
+ });
+ },
+
+ 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();
+ 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') {
+ 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 = 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, count = resultSet.snapshotLength; i < count; i++) {
+ var element = resultSet.snapshotItem(i);
+ // Note: this call will be expensive if we modify the DOM in between calls.
+ 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, childrenCount = element.children.length; j < childrenCount; 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 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);
+ 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();
-}
-
-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();
-}
+ event.stopPropagation();
+ event.preventDefault();
+ },
-/*
- * 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);
+ 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.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, 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]);
+ 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] ];
+ } 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);
- if (document.activeElement.nodeName.toLowerCase() == "a")
- document.activeElement.blur();
- 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;
+ // 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;
+ }
+};
diff --git a/manifest.json b/manifest.json
index 648b7b34..c5a2834d 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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 4e2f6ecc..f1194554 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">
@@ -78,7 +82,7 @@
var defaultSettings = chrome.extension.getBackgroundPage().defaultSettings;
var editableFields = ["scrollStepSize", "defaultZoomLevel", "excludedUrls", "linkHintCharacters",
- "userDefinedLinkHintCss", "keyMappings"];
+ "userDefinedLinkHintCss", "keyMappings", "filterLinkHints"];
var canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss"];
@@ -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 () {
showHelpDialog(
@@ -107,6 +116,10 @@
enableSaveButton();
}
+ function onDataLoaded() {
+ $("linkHintCharacters").readOnly = $("filterLinkHints").checked;
+ }
+
function enableSaveButton() { $("saveOptions").removeAttribute("disabled"); }
// Saves options to localStorage.
@@ -115,7 +128,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 +164,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++)
@@ -243,6 +278,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..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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); // 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 c9c81381..eb0216f4 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)
@@ -14,6 +12,7 @@ var findMode = false;
var findModeQuery = "";
var findModeQueryHasResults = false;
var isShowingHelpDialog = false;
+var handlerStack = [];
var keyPort;
var settingPort;
var saveZoomLevelPort;
@@ -38,6 +37,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 () {
+ linkHints.init();
+ }
+};
+
/*
* Give this frame a unique id.
*/
@@ -46,19 +74,11 @@ frameId = Math.floor(Math.random()*999999999)
var hasModifiersRegex = /^<([amc]-)+.>/;
var googleRegex = /:\/\/[^/]*google[^/]+/;
-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();
@@ -97,11 +117,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); }
}
}
@@ -132,7 +152,11 @@ 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);
+ });
}
});
}
@@ -244,14 +268,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,
@@ -326,11 +350,11 @@ 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);
@@ -360,12 +384,22 @@ function onKeypress(event) {
}
}
-function onKeydown(event) {
- var keyChar = "";
+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) {
keyChar = getKeyChar(event);
@@ -447,6 +481,11 @@ function onKeydown(event) {
event.stopPropagation();
}
+function onKeyup() {
+ if (!bubbleEvent('keyup', event))
+ return;
+}
+
function checkIfEnabledForUrl() {
var url = window.location.toString();
@@ -496,7 +535,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,
@@ -509,7 +548,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() {