aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CREDITS1
-rw-r--r--background/settings.js76
-rw-r--r--background_page.html95
-rw-r--r--bookmarks.js6
-rw-r--r--lib/domUtils.js94
-rw-r--r--lib/utils.js74
-rw-r--r--linkHints.js91
-rw-r--r--manifest.json6
-rw-r--r--options.html37
-rw-r--r--test_harnesses/automated.html7
-rw-r--r--vimium.css7
-rw-r--r--vimiumFrontend.js397
12 files changed, 620 insertions, 271 deletions
diff --git a/CREDITS b/CREDITS
index a60d0a7c..b7a8d149 100644
--- a/CREDITS
+++ b/CREDITS
@@ -30,5 +30,6 @@ Contributors:
Wang Ning <daning106@gmail.com> (github:daning)
Bernardo B. Marques <bernardo.fire@gmail.com> (github: bernardofire)
Niklas Baumstark <niklas.baumstark@gmail.com> (github: niklasb)
+ Ângelo Otávio Nuffer Nunes <angelonuffer@gmail.com> (github: angelonuffer)
Feel free to add real names in addition to GitHub usernames.
diff --git a/background/settings.js b/background/settings.js
new file mode 100644
index 00000000..a57b546d
--- /dev/null
+++ b/background/settings.js
@@ -0,0 +1,76 @@
+/*
+ * Used by everyone to manipulate localStorage.
+ */
+var settings = {
+
+ defaults: {
+ scrollStepSize: 60,
+ linkHintCharacters: "sadfjklewcmpgh",
+ filterLinkHints: false,
+ userDefinedLinkHintCss:
+ "div > .vimiumHintMarker {" + "\n" +
+ "/* linkhint boxes */ " + "\n" +
+ "background-color: yellow;" + "\n" +
+ "border: 1px solid #E3BE23;" + "\n" +
+ "}" + "\n\n" +
+ "div > .vimiumHintMarker span {" + "\n" +
+ "/* linkhint text */ " + "\n" +
+ "color: black;" + "\n" +
+ "font-weight: bold;" + "\n" +
+ "font-size: 12px;" + "\n" +
+ "}" + "\n\n" +
+ "div > .vimiumHintMarker > .matchingCharacter {" + "\n" +
+ "}",
+ excludedUrls: "http*://mail.google.com/*\n" +
+ "http*://www.google.com/reader/*\n",
+
+ // NOTE : If a page contains both a single angle-bracket link and a double angle-bracket link, then in
+ // most cases the single bracket link will be "prev/next page" and the double bracket link will be
+ // "first/last page", so we put the single bracket first in the pattern string so that it gets searched
+ // for first.
+
+ // "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<"
+ previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<",
+ // "\bnext\b,\bmore\b,>,→,»,≫,>>"
+ nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>",
+ },
+
+ init: function() {
+ // settingsVersion was introduced in v1.31, and is used to coordinate data migration. We do not use
+ // previousVersion as it is used to coordinate the display of the upgrade message, and is not updated
+ // early enough when the extension loads.
+ // 1.31 was also the version where we converted all localStorage values to JSON.
+ if (!this.has("settingsVersion")) {
+ for (var key in localStorage) {
+ localStorage[key] = JSON.stringify(localStorage[key]);
+ }
+ this.set("settingsVersion", utils.getCurrentVersion());
+ }
+ },
+
+ get: function(key) {
+ if (!(key in localStorage))
+ return this.defaults[key];
+ else
+ return JSON.parse(localStorage[key]);
+ },
+
+ set: function(key, value) {
+ // don't store the value if it is equal to the default, so we can change the defaults in the future
+ if (value === this.defaults[key])
+ this.clear(key);
+ else
+ localStorage[key] = JSON.stringify(value);
+ },
+
+ clear: function(key) {
+ delete localStorage[key];
+ },
+
+ has: function(key) {
+ return key in localStorage;
+ },
+
+};
+
+settings.init();
diff --git a/background_page.html b/background_page.html
index 71208d12..706aeb1f 100644
--- a/background_page.html
+++ b/background_page.html
@@ -3,13 +3,9 @@
<script type="text/javascript" src="commands.js"></script>
<script type="text/javascript" src="lib/clipboard.js"></script>
<script type="text/javascript" src="lib/utils.js"></script>
+<script type="text/javascript" src="background/settings.js"></script>
<script type="text/javascript" charset="utf-8">
- // Chromium #15242 will make this XHR request to access the manifest unnecessary.
- var manifestRequest = new XMLHttpRequest();
- manifestRequest.open("GET", chrome.extension.getURL("manifest.json"), false);
- manifestRequest.send(null);
-
- var currentVersion = JSON.parse(manifestRequest.responseText).version;
+ var currentVersion = utils.getCurrentVersion();
var tabQueue = {}; // windowId -> Array
var openTabs = {}; // tabId -> object with various tab properties
@@ -25,36 +21,12 @@
// the string.
var namedKeyRegex = /^(<(?:[amc]-.|(?:[amc]-)?[a-z0-9]{2,5})>)(.*)$/;
- var defaultSettings = {
- scrollStepSize: 60,
- linkHintCharacters: "sadfjklewcmpgh",
- filterLinkHints: false,
- userDefinedLinkHintCss:
- "#vimiumHintMarkerContainer .vimiumHintMarker \n/* linkhint boxes */ " +
- "{\nborder: 1px solid #AA852F;\n}\n\n" +
- "#vimiumHintMarkerContainer .vimiumHintMarker span \n/* linkhint text */ " +
- "{\nfont-weight: bold;\n}\n\n" +
- "#vimiumHintMarkerContainer .vimiumHintMarker > .matchingCharacter {\n\n}",
- excludedUrls: "http*://mail.google.com/*\n" +
- "http*://www.google.com/reader/*\n",
-
- // NOTE : If a page contains both a single angle-bracket link and a double angle-bracket link, then in
- // most cases the single bracket link will be "prev/next page" and the double bracket link will be
- // "first/last page", so we put the single bracket first in the pattern string so that it gets searched
- // for first.
-
- // "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<"
- previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<",
- // "\bnext\b,\bmore\b,>,→,»,≫,>>"
- nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>",
- };
-
// Port handler mapping
var portHandlers = {
keyDown: handleKeyDown,
returnScrollPosition: handleReturnScrollPosition,
getCurrentTabUrl: getCurrentTabUrl,
- getSetting: getSetting,
+ settings: handleSettings,
getBookmarks: getBookmarks
};
@@ -66,6 +38,7 @@
openOptionsPageInNewTab: openOptionsPageInNewTab,
registerFrame: registerFrame,
frameFocused: handleFrameFocused,
+ focusTopFrame: focusTopFrame,
upgradeNotificationClosed: upgradeNotificationClosed,
updateScrollPosition: handleUpdateScrollPosition,
copyToClipboard: copyToClipboard,
@@ -132,7 +105,7 @@
*/
function isEnabledForUrl(request) {
// excludedUrls are stored as a series of URL expressions separated by newlines.
- var excludedUrls = getSettingFromLocalStorage("excludedUrls").split("\n");
+ var excludedUrls = settings.get("excludedUrls").split("\n");
var isEnabled = true;
for (var i = 0; i < excludedUrls.length; i++) {
// The user can add "*" to the URL which means ".*"
@@ -144,7 +117,7 @@
}
function saveHelpDialogSettings(request) {
- localStorage["helpDialog_showAdvancedCommands"] = request.showAdvancedCommands;
+ settings.set("helpDialog_showAdvancedCommands", request.showAdvancedCommands);
}
function showHelp(callback, frameId) {
@@ -170,8 +143,7 @@
showUnboundCommands, showCommandNames));
dialogHtml = dialogHtml.replace("{{version}}", currentVersion);
dialogHtml = dialogHtml.replace("{{title}}", customTitle || "Help");
- dialogHtml = dialogHtml.replace("{{showAdvancedCommands}}",
- localStorage["helpDialog_showAdvancedCommands"] == "true");
+ dialogHtml = dialogHtml.replace("{{showAdvancedCommands}}", settings.get("helpDialog_showAdvancedCommands"));
return dialogHtml;
}
@@ -259,7 +231,7 @@
* Returns the user-provided CSS overrides.
*/
function getLinkHintCss(request) {
- return { linkHintCss: (localStorage['userDefinedLinkHintCss'] || "") };
+ return { linkHintCss: (settings.get("userDefinedLinkHintCss") || "") };
}
/*
@@ -267,7 +239,7 @@
* We should now dismiss that message in all tabs.
*/
function upgradeNotificationClosed(request) {
- localStorage.previousVersion = currentVersion;
+ settings.set("previousVersion", currentVersion);
sendRequestToAllTabs({ name: "hideUpgradeNotification" });
}
@@ -281,10 +253,14 @@
/*
* Used by the content scripts to get settings from the local storage.
*/
- function getSetting(args, port) {
- var value = getSettingFromLocalStorage(args.key);
- var returnPort = chrome.tabs.connect(port.tab.id, { name: "returnSetting" });
- returnPort.postMessage({ key: args.key, value: value });
+ function handleSettings(args, port) {
+ if (args.operation == "get") {
+ var value = settings.get(args.key);
+ port.postMessage({ key: args.key, value: value });
+ }
+ else { // operation == "set"
+ settings.set(args.key, args.value);
+ }
}
function getBookmarks(args, port) {
@@ -293,17 +269,6 @@
})
}
- /*
- * Used by everyone to get settings from local storage.
- */
- function getSettingFromLocalStorage(setting) {
- if (localStorage[setting] != "" && !localStorage[setting]) {
- return defaultSettings[setting];
- } else {
- return localStorage[setting];
- }
- }
-
function getCurrentTimeInSeconds() { Math.floor((new Date()).getTime() / 1000); }
chrome.tabs.onSelectionChanged.addListener(function(tabId, selectionInfo) {
@@ -644,11 +609,11 @@
* localStorage, and false otherwise.
*/
function shouldShowUpgradeMessage() {
- // Avoid showing the upgrade notification when localStorage.previousVersion is undefined, which is the
- // case for new installs.
- if (!localStorage.previousVersion)
- localStorage.previousVersion = currentVersion;
- return compareVersions(currentVersion, localStorage.previousVersion) == 1;
+ // Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new
+ // installs.
+ if (!settings.get("previousVersion"))
+ settings.set("previousVersion", currentVersion);
+ return compareVersions(currentVersion, settings.get("previousVersion")) == 1;
}
function openOptionsPageInNewTab() {
@@ -663,6 +628,7 @@
if (request.is_top) {
focusedFrame = request.frameId;
+ framesForTab[sender.tab.id].topId = request.frameId;
framesForTab[sender.tab.id].total = request.total;
}
@@ -690,6 +656,11 @@
chrome.tabs.sendRequest(tabId, { name: "focusFrame", frameId: mainFrameId, highlight: false });
}
+ function focusTopFrame(request, sender) {
+ var tabId = sender.tab.id;
+ chrome.tabs.sendRequest(tabId, { name: "focusFrame", frameId: framesForTab[tabId].topId, highlight: true });
+ }
+
function handleFrameFocused(request, sender) {
focusedFrame = request.frameId;
}
@@ -720,20 +691,20 @@
function init() {
clearKeyMappingsAndSetDefaults();
- if (localStorage["keyMappings"])
- parseCustomKeyMappings(localStorage["keyMappings"]);
+ if (settings.has("keyMappings"))
+ parseCustomKeyMappings(settings.get("keyMappings"));
// In version 1.22, we changed the mapping for "d" and "u" to be scroll page down/up instead of close
// and restore tab. For existing users, we want to preserve existing behavior for them by adding some
// custom key mappings on their behalf.
- if (localStorage.previousVersion == "1.21") {
- var customKeyMappings = localStorage["keyMappings"] || "";
+ if (settings.get("previousVersion") == "1.21") {
+ var customKeyMappings = settings.get("keyMappings") || "";
if ((keyToCommandRegistry["d"] || {}).command == "scrollPageDown")
customKeyMappings += "\nmap d removeTab";
if ((keyToCommandRegistry["u"] || {}).command == "scrollPageUp")
customKeyMappings += "\nmap u restoreTab";
if (customKeyMappings != "") {
- localStorage["keyMappings"] = customKeyMappings;
+ settings.set("keyMappings", customKeyMappings);
parseCustomKeyMappings(customKeyMappings);
}
}
diff --git a/bookmarks.js b/bookmarks.js
index 67ef1cb3..85d12aac 100644
--- a/bookmarks.js
+++ b/bookmarks.js
@@ -69,10 +69,12 @@ function activateBookmarkFindMode() {
var url = selection.url;
var isABookmarklet = function(url) { return url.indexOf("javascript:") === 0; }
- if (!self.newTab || isABookmarklet(url))
+ if (isABookmarklet(url))
window.location = url;
+ else if (!self.newTab)
+ chrome.extension.sendRequest({ handler: "openUrlInCurrentTab", url: url });
else
- window.open(url);
+ chrome.extension.sendRequest({ handler: "openUrlInNewTab", url: url });
self.disable();
},
diff --git a/lib/domUtils.js b/lib/domUtils.js
new file mode 100644
index 00000000..fd182c59
--- /dev/null
+++ b/lib/domUtils.js
@@ -0,0 +1,94 @@
+var domUtils = {
+ /*
+ * Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and applies them
+ * to the document root. The namespaceResolver in evaluateXPath should be kept in sync with the namespaces
+ * here.
+ */
+ makeXPath: function(elementArray) {
+ var xpath = [];
+ for (var i in elementArray)
+ xpath.push("//" + elementArray[i], "//xhtml:" + elementArray[i]);
+ return xpath.join(" | ");
+ },
+
+ evaluateXPath: function(xpath, resultType) {
+ function namespaceResolver(namespace) {
+ return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null;
+ }
+ return document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null);
+ },
+
+ /**
+ * Returns the first visible clientRect of an element if it exists. Otherwise it returns null.
+ */
+ getVisibleClientRect: function(element) {
+ // Note: this call will be expensive if we modify the DOM in between calls.
+ var clientRects = element.getClientRects();
+ var clientRectsLength = clientRects.length;
+
+ for (var i = 0; i < clientRectsLength; i++) {
+ if (clientRects[i].top < 0 || clientRects[i].top >= window.innerHeight - 4 ||
+ clientRects[i].left < 0 || clientRects[i].left >= window.innerWidth - 4)
+ continue;
+
+ if (clientRects[i].width < 3 || clientRects[i].height < 3)
+ continue;
+
+ // eliminate invisible elements (see test_harnesses/visibility_test.html)
+ var computedStyle = window.getComputedStyle(element, null);
+ if (computedStyle.getPropertyValue('visibility') != 'visible' ||
+ computedStyle.getPropertyValue('display') == 'none')
+ continue;
+
+ return clientRects[i];
+ }
+
+ for (var i = 0; i < clientRectsLength; i++) {
+ // If the link has zero dimensions, it may be wrapping visible
+ // but floated elements. Check for this.
+ if (clientRects[i].width == 0 || clientRects[i].height == 0) {
+ for (var j = 0, childrenCount = element.children.length; j < childrenCount; j++) {
+ var computedStyle = window.getComputedStyle(element.children[j], null);
+ // Ignore child elements which are not floated and not absolutely positioned for parent elements with zero width/height
+ if (computedStyle.getPropertyValue('float') == 'none' && computedStyle.getPropertyValue('position') != 'absolute')
+ continue;
+ var childClientRect = this.getVisibleClientRect(element.children[j]);
+ if (childClientRect === null)
+ continue;
+ return childClientRect;
+ }
+ }
+ };
+ return null;
+ },
+
+ /*
+ * 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);
+ },
+
+ simulateClick: function(element, modifiers) {
+ modifiers = modifiers || {};
+
+ var eventSequence = [ "mouseover", "mousedown", "mouseup", "click" ];
+ for (var i = 0; i < eventSequence.length; i++) {
+ var event = document.createEvent("MouseEvents");
+ event.initMouseEvent(eventSequence[i], true, true, window, 1, 0, 0, 0, 0, modifiers.ctrlKey, false, false,
+ modifiers.metaKey, 0, null);
+ // Debugging note: Firefox will not execute the element'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
+ element.dispatchEvent(event);
+ }
+ },
+
+};
diff --git a/lib/utils.js b/lib/utils.js
index efdb49fb..8aada3a1 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -1,4 +1,12 @@
var utils = {
+ getCurrentVersion: function() {
+ // Chromium #15242 will make this XHR request to access the manifest unnecessary.
+ var manifestRequest = new XMLHttpRequest();
+ manifestRequest.open("GET", chrome.extension.getURL("manifest.json"), false);
+ manifestRequest.send(null);
+ return JSON.parse(manifestRequest.responseText).version;
+ },
+
/*
* Takes a dot-notation object string and call the function
* that it points to with the correct value for 'this'.
@@ -12,69 +20,6 @@ var utils = {
return func.apply(obj, argArray);
},
- /*
- * Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and applies them
- * to the document root. The namespaceResolver in evaluateXPath should be kept in sync with the namespaces
- * here.
- */
- makeXPath: function(elementArray) {
- var xpath = [];
- for (var i in elementArray)
- xpath.push("//" + elementArray[i], "//xhtml:" + elementArray[i]);
- return xpath.join(" | ");
- },
-
- evaluateXPath: function(xpath, resultType) {
- function namespaceResolver(namespace) {
- return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null;
- }
- return document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null);
- },
-
- /**
- * Returns the first visible clientRect of an element if it exists. Otherwise it returns null.
- */
- getVisibleClientRect: function(element) {
- // Note: this call will be expensive if we modify the DOM in between calls.
- var clientRects = element.getClientRects();
- var clientRectsLength = clientRects.length;
-
- for (var i = 0; i < clientRectsLength; i++) {
- if (clientRects[i].top < 0 || clientRects[i].top >= window.innerHeight - 4 ||
- clientRects[i].left < 0 || clientRects[i].left >= window.innerWidth - 4)
- continue;
-
- if (clientRects[i].width < 3 || clientRects[i].height < 3)
- continue;
-
- // eliminate invisible elements (see test_harnesses/visibility_test.html)
- var computedStyle = window.getComputedStyle(element, null);
- if (computedStyle.getPropertyValue('visibility') != 'visible' ||
- computedStyle.getPropertyValue('display') == 'none')
- continue;
-
- return clientRects[i];
- }
-
- for (var i = 0; i < clientRectsLength; i++) {
- // If the link has zero dimensions, it may be wrapping visible
- // but floated elements. Check for this.
- if (clientRects[i].width == 0 || clientRects[i].height == 0) {
- for (var j = 0, childrenCount = element.children.length; j < childrenCount; j++) {
- var computedStyle = window.getComputedStyle(element.children[j], null);
- // Ignore child elements which are not floated and not absolutely positioned for parent elements with zero width/height
- if (computedStyle.getPropertyValue('float') == 'none' && computedStyle.getPropertyValue('position') != 'absolute')
- continue;
- var childClientRect = this.getVisibleClientRect(element.children[j]);
- if (childClientRect === null)
- continue;
- return childClientRect;
- }
- }
- };
- return null;
- },
-
/**
* Creates a search URL from the given :query.
*/
@@ -105,6 +50,9 @@ var utils = {
// trim str
str = str.replace(/^\s+|\s+$/g, '');
+ if (str[0] === '/')
+ return "file://" + str;
+
// it starts with a scheme, so it's definitely an URL
if (/^[a-z]{3,}:\/\//.test(str))
return str;
diff --git a/linkHints.js b/linkHints.js
index 24fa7946..509b6c0d 100644
--- a/linkHints.js
+++ b/linkHints.js
@@ -41,7 +41,7 @@ var linkHints = {
* The final expression will be something like "//button | //xhtml:button | ..."
* We use translate() instead of lower-case() because Chrome only supports XPath 1.0.
*/
- clickableElementsXPath: utils.makeXPath(["a", "area[@href]", "textarea", "button", "select","input[not(@type='hidden')]",
+ clickableElementsXPath: domUtils.makeXPath(["a", "area[@href]", "textarea", "button", "select","input[not(@type='hidden')]",
"*[@onclick or @tabindex or @role='link' or @role='button' or " +
"@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]),
@@ -97,7 +97,7 @@ var linkHints = {
// 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.id = "vimiumHintMarkerContainer";
- this.hintMarkerContainingDiv.className = "vimiumReset internalVimiumHintMarker";
+ this.hintMarkerContainingDiv.className = "vimiumReset";
for (var i = 0; i < this.hintMarkers.length; i++)
this.hintMarkerContainingDiv.appendChild(this.hintMarkers[i]);
@@ -115,14 +115,14 @@ var linkHints = {
* of digits needed to enumerate all of the links on screen.
*/
getVisibleClickableElements: function() {
- var resultSet = utils.evaluateXPath(this.clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
+ var resultSet = domUtils.evaluateXPath(this.clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
var visibleElements = [];
// Find all visible clickable elements.
for (var i = 0, count = resultSet.snapshotLength; i < count; i++) {
var element = resultSet.snapshotItem(i);
- var clientRect = utils.getVisibleClientRect(element, clientRect);
+ var clientRect = domUtils.getVisibleClientRect(element, clientRect);
if (clientRect !== null)
visibleElements.push({element: element, rect: clientRect});
@@ -132,7 +132,7 @@ var linkHints = {
var img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']");
if (!img) continue;
var imgClientRects = img.getClientRects();
- if (!imgClientRects) continue;
+ if (imgClientRects.length == 0) continue;
var c = element.coords.split(/,/);
var coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)];
var rect = {
@@ -204,8 +204,8 @@ var linkHints = {
activateLink: function(matchedLink, delay) {
var that = this;
this.delayMode = true;
- if (this.isSelectable(matchedLink)) {
- this.simulateSelect(matchedLink);
+ if (domUtils.isSelectable(matchedLink)) {
+ domUtils.simulateSelect(matchedLink);
this.deactivateMode(delay, function() { that.delayMode = false; });
} else {
if (this.shouldOpenWithQueue) {
@@ -231,32 +231,18 @@ var linkHints = {
}
},
- /*
- * Selectable means the element has a text caret; this is not the same as "focusable".
- */
- isSelectable: function(element) {
- var selectableTypes = ["search", "text", "password"];
- return (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) ||
- element.nodeName.toLowerCase() == "textarea";
- },
-
copyLinkUrl: function(link) {
chrome.extension.sendRequest({handler: 'copyLinkUrl', data: link.href});
},
- simulateSelect: function(element) {
- element.focus();
- // When focusing a textbox, put the selection caret at the end of the textbox's contents.
- element.setSelectionRange(element.value.length, element.value.length);
- },
-
/*
* Shows the marker, highlighting matchingCharCount characters.
*/
showMarker: function(linkMarker, matchingCharCount) {
linkMarker.style.display = "";
for (var j = 0, count = linkMarker.childNodes.length; j < count; j++)
- linkMarker.childNodes[j].className = (j >= matchingCharCount) ? "" : "matchingCharacter";
+ (j < matchingCharCount) ? linkMarker.childNodes[j].classList.add("matchingCharacter") :
+ linkMarker.childNodes[j].classList.remove("matchingCharacter");
},
hideMarker: function(linkMarker) {
@@ -264,16 +250,11 @@ var linkHints = {
},
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);
+ domUtils.simulateClick(link, { metaKey: metaKey, ctrlKey: ctrlKey });
// TODO(int3): do this for @role='link' and similar elements as well
var nodeName = link.nodeName.toLowerCase();
@@ -317,10 +298,9 @@ var alphabetHints = {
var hintStrings = this.hintStrings(visibleElements.length);
var hintMarkers = [];
for (var i = 0, count = visibleElements.length; i < count; i++) {
- var hintString = hintStrings[i];
var marker = hintUtils.createMarkerFor(visibleElements[i]);
- marker.innerHTML = hintUtils.spanWrap(hintString);
- marker.setAttribute("hintString", hintString);
+ marker.hintString = hintStrings[i];
+ marker.innerHTML = hintUtils.spanWrap(marker.hintString.toUpperCase());
hintMarkers.push(marker);
}
@@ -407,7 +387,7 @@ var alphabetHints = {
var matchString = this.hintKeystrokeQueue.join("");
var linksMatched = hintMarkers.filter(function(linkMarker) {
- return linkMarker.getAttribute("hintString").indexOf(matchString) == 0;
+ return linkMarker.hintString.indexOf(matchString) == 0;
});
return { linksMatched: linksMatched };
},
@@ -440,11 +420,13 @@ var filterHints = {
}
},
- setMarkerAttributes: function(marker, linkHintNumber) {
- var hintString = (linkHintNumber + 1).toString();
+ generateHintString: function(linkHintNumber) {
+ return (linkHintNumber + 1).toString();
+ },
+
+ generateLinkText: function(element) {
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();
@@ -466,10 +448,12 @@ var filterHints = {
} else {
linkText = element.textContent || element.innerHTML;
}
- linkText = linkText.trim().toLowerCase();
- marker.setAttribute("hintString", hintString);
- marker.innerHTML = hintUtils.spanWrap(hintString + (showLinkText ? ": " + linkText : ""));
- marker.setAttribute("linkText", linkText);
+ return { text: linkText, show: showLinkText };
+ },
+
+ renderMarker: function(marker) {
+ marker.innerHTML = hintUtils.spanWrap(marker.hintString +
+ (marker.showLinkText ? ": " + marker.linkText : ""));
},
getHintMarkers: function(visibleElements) {
@@ -477,7 +461,11 @@ var filterHints = {
var hintMarkers = [];
for (var i = 0, count = visibleElements.length; i < count; i++) {
var marker = hintUtils.createMarkerFor(visibleElements[i]);
- this.setMarkerAttributes(marker, i);
+ marker.hintString = this.generateHintString(i);
+ var linkTextObject = this.generateLinkText(marker.clickableItem);
+ marker.linkText = linkTextObject.text;
+ marker.showLinkText = linkTextObject.show;
+ this.renderMarker(marker);
hintMarkers.push(marker);
}
return hintMarkers;
@@ -516,8 +504,7 @@ var filterHints = {
var linksMatched = this.filterLinkHints(hintMarkers);
var matchString = this.hintKeystrokeQueue.join("");
linksMatched = linksMatched.filter(function(linkMarker) {
- return linkMarker.getAttribute('filtered') != 'true'
- && linkMarker.getAttribute("hintString").indexOf(matchString) == 0;
+ return !linkMarker.filtered && linkMarker.hintString.indexOf(matchString) == 0;
});
if (linksMatched.length == 1 && userIsTypingLinkText) {
@@ -531,8 +518,8 @@ var filterHints = {
},
/*
- * Hides the links that do not match the linkText search string and marks them with the 'filtered' DOM
- * property. Renumbers the remainder.
+ * Marks the links that do not match the linkText search string with the 'filtered' DOM property. Renumbers
+ * the remainder if necessary.
*/
filterLinkHints: function(hintMarkers) {
var linksMatched = [];
@@ -540,14 +527,16 @@ var filterHints = {
for (var i = 0; i < hintMarkers.length; i++) {
var linkMarker = hintMarkers[i];
- var matchedLink = linkMarker.getAttribute("linkText").toLowerCase()
- .indexOf(linkSearchString.toLowerCase()) >= 0;
+ var matchedLink = linkMarker.linkText.toLowerCase().indexOf(linkSearchString.toLowerCase()) >= 0;
if (!matchedLink) {
- linkMarker.setAttribute("filtered", "true");
+ linkMarker.filtered = true;
} else {
- this.setMarkerAttributes(linkMarker, linksMatched.length);
- linkMarker.setAttribute("filtered", "false");
+ linkMarker.filtered = false;
+ var oldHintString = linkMarker.hintString;
+ linkMarker.hintString = this.generateHintString(linksMatched.length);
+ if (linkMarker.hintString != oldHintString)
+ this.renderMarker(linkMarker);
linksMatched.push(linkMarker);
}
}
@@ -569,7 +558,7 @@ var hintUtils = {
spanWrap: function(hintString) {
var innerHTML = [];
for (var i = 0; i < hintString.length; i++)
- innerHTML.push("<span class='vimiumReset'>" + hintString[i].toUpperCase() + "</span>");
+ innerHTML.push("<span class='vimiumReset'>" + hintString[i] + "</span>");
return innerHTML.join("");
},
diff --git a/manifest.json b/manifest.json
index 4b14f444..2f863bc3 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,6 +1,6 @@
{
"name": "Vimium",
- "version": "1.30",
+ "version": "1.31",
"description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",
"icons": { "16": "icons/icon16.png",
"48": "icons/icon48.png",
@@ -11,14 +11,14 @@
"tabs",
"bookmarks",
"clipboardRead",
- "http://*/*",
- "https://*/*"
+ "<all_urls>"
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["lib/utils.js",
"lib/keyboardUtils.js",
+ "lib/domUtils.js",
"lib/clipboard.js",
"linkHints.js",
"vimiumFrontend.js",
diff --git a/options.html b/options.html
index 3d4033e3..037aabbc 100644
--- a/options.html
+++ b/options.html
@@ -3,9 +3,12 @@
<title>Vimium Options</title>
<script src="lib/utils.js"></script>
<script src="lib/keyboardUtils.js"></script>
- <script src="linkHints.js"></script>
+ <script src="lib/domUtils.js"></script>
<script src="lib/clipboard.js"></script>
+ <script src="linkHints.js"></script>
<script src="vimiumFrontend.js"></script>
+ <script src="completionDialog.js"></script>
+ <script src="bookmarks.js"></script>
<style type="text/css" media="screen">
body {
font-family:"helvetica neue", "helvetica", "arial", "sans";
@@ -83,7 +86,7 @@
<script type="text/javascript">
$ = function(id) { return document.getElementById(id); };
- var defaultSettings = chrome.extension.getBackgroundPage().defaultSettings;
+ var defaultSettings = chrome.extension.getBackgroundPage().settings.defaults;
var editableFields = ["scrollStepSize", "excludedUrls", "linkHintCharacters", "userDefinedLinkHintCss",
"keyMappings", "filterLinkHints", "previousPatterns", "nextPatterns"];
@@ -100,7 +103,10 @@
};
function initializeOptions() {
- populateOptions();
+ if (settings.isLoaded)
+ populateOptions();
+ else
+ settings.addEventListener("load", populateOptions);
for (var i = 0; i < editableFields.length; i++) {
$(editableFields[i]).addEventListener("keyup", onOptionKeyup, false);
@@ -142,18 +148,12 @@
field.value = fieldValue;
}
- var defaultFieldValue = (defaultSettings[fieldName] != null) ?
- defaultSettings[fieldName].toString() : "";
-
- // Don't save to storage if it's equal to the default
- if (fieldValue == defaultFieldValue)
- delete localStorage[fieldName];
- // ..or if it's empty and not a field that we allow to be empty.
- else if (!fieldValue && canBeEmptyFields.indexOf(fieldName) == -1) {
- delete localStorage[fieldName];
- fieldValue = defaultFieldValue;
+ // If it's empty and not a field that we allow to be empty, restore to the default value
+ if (!fieldValue && canBeEmptyFields.indexOf(fieldName) == -1) {
+ settings.clear(fieldName);
+ fieldValue = settings.get(fieldName);
} else
- localStorage[fieldName] = fieldValue;
+ settings.set(fieldName, fieldValue);
$(fieldName).value = fieldValue;
$(fieldName).setAttribute("savedValue", fieldValue);
@@ -166,13 +166,8 @@
// Restores select box state to saved value from localStorage.
function populateOptions() {
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]]) {
- var val = defaultSettings[editableFields[i]] || "";
- } else {
- var val = localStorage[editableFields[i]];
- }
- setFieldValue($(editableFields[i]), val);
+ var val = settings.get(editableFields[i]) || "";
+ setFieldValue($(editableFields[i]), val);
}
onDataLoaded();
}
diff --git a/test_harnesses/automated.html b/test_harnesses/automated.html
index e57f1513..c8e8070a 100644
--- a/test_harnesses/automated.html
+++ b/test_harnesses/automated.html
@@ -29,6 +29,7 @@
<link rel="stylesheet" type="text/css" href="../vimium.css" />
<script type="text/javascript" src="../lib/utils.js"></script>
<script type="text/javascript" src="../lib/keyboardUtils.js"></script>
+ <script type="text/javascript" src="../lib/domUtils.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>
@@ -120,7 +121,7 @@
should("label the hints correctly", function() {
var hintStrings = ["ss", "as", "ds"];
for (var i = 0; i < 3; i++)
- assert.equal(hintStrings[i], linkHints.hintMarkers[i].getAttribute("hintString"));
+ assert.equal(hintStrings[i], linkHints.hintMarkers[i].hintString);
}),
should("narrow the hints", function() {
@@ -164,10 +165,10 @@
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("1", linkHints.hintMarkers[1].hintString);
assert.equal("", linkHints.hintMarkers[1].style.display);
linkHints.onKeyDownInMode(mockKeyboardEvent("A"));
- assert.equal("2", linkHints.hintMarkers[3].getAttribute("hintString"));
+ assert.equal("2", linkHints.hintMarkers[3].hintString);
})
),
diff --git a/vimium.css b/vimium.css
index 5211410d..fc619874 100644
--- a/vimium.css
+++ b/vimium.css
@@ -56,6 +56,7 @@ div.internalVimiumHintMarker {
display: block;
top: -1px;
left: -1px;
+ white-space: nowrap;
font-size: 10px;
padding: 2px 4px 3px 4px;
@@ -278,4 +279,8 @@ div.vimium-completions div strong{
div.vimium-completions div.vimium-noResults{
color:#555;
-};
+}
+
+body.vimiumFindMode ::selection {
+ background: #ff9632;
+}
diff --git a/vimiumFrontend.js b/vimiumFrontend.js
index e217e955..1a46a58a 100644
--- a/vimiumFrontend.js
+++ b/vimiumFrontend.js
@@ -8,12 +8,12 @@ var getCurrentUrlHandlers = []; // function(url)
var insertModeLock = null;
var findMode = false;
-var findModeQuery = "";
+var findModeQuery = { rawQuery: "" };
var findModeQueryHasResults = false;
+var findModeAnchorNode = null;
var isShowingHelpDialog = false;
var handlerStack = [];
var keyPort;
-var settingPort;
// Users can disable Vimium on URL patterns via the settings page.
var isEnabledForUrl = true;
// The user's operating system.
@@ -34,36 +34,65 @@ var textInputXPath = (function() {
var inputElements = ["input[" +
textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + "or not(@type)]",
"textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"];
- return utils.makeXPath(inputElements);
+ return domUtils.makeXPath(inputElements);
})();
+/**
+ * settings provides a browser-global localStorage-backed dict. get() and set() are synchronous, but load()
+ * must be called beforehand to ensure get() will return up-to-date values.
+ */
var settings = {
+ port: null,
values: {},
loadedValues: 0,
- valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints", "previousPatterns", "nextPatterns"],
+ valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints", "previousPatterns", "nextPatterns",
+ "findModeRawQuery"],
+ isLoaded: false,
+ eventListeners: {},
+
+ init: function () {
+ this.port = chrome.extension.connect({ name: "settings" });
+ this.port.onMessage.addListener(this.receiveMessage);
+ },
get: function (key) { return this.values[key]; },
- load: function() {
- for (var i in this.valuesToLoad) { this.sendMessage(this.valuesToLoad[i]); }
+ set: function (key, value) {
+ if (!this.port)
+ this.init();
+
+ this.values[key] = value;
+ this.port.postMessage({ operation: "set", key: key, value: value });
},
- sendMessage: function (key) {
- if (!settingPort)
- settingPort = chrome.extension.connect({ name: "getSetting" });
- settingPort.postMessage({ key: key });
+ load: function() {
+ if (!this.port)
+ this.init();
+
+ for (var i in this.valuesToLoad) {
+ this.port.postMessage({ operation: "get", key: this.valuesToLoad[i] });
+ }
},
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();
+ // since load() can be called more than once, loadedValues can be greater than valuesToLoad, but we test
+ // for equality so initializeOnReady only runs once
+ if (++settings.loadedValues == settings.valuesToLoad.length) {
+ settings.isLoaded = true;
+ var listener;
+ while (listener = settings.eventListeners["load"].pop())
+ listener();
+ }
+ },
+
+ addEventListener: function(eventName, callback) {
+ if (!(eventName in this.eventListeners))
+ this.eventListeners[eventName] = [];
+ this.eventListeners[eventName].push(callback);
},
- initializeOnReady: function () {
- linkHints.init();
- }
};
/*
@@ -78,6 +107,7 @@ var googleRegex = /:\/\/[^/]*google[^/]+/;
* Complete initialization work that sould be done prior to DOMReady.
*/
function initializePreDomReady() {
+ settings.addEventListener("load", linkHints.init.bind(linkHints));
settings.load();
checkIfEnabledForUrl();
@@ -146,8 +176,6 @@ function initializePreDomReady() {
port.onMessage.addListener(function(args) {
if (getCurrentUrlHandlers.length > 0) { getCurrentUrlHandlers.pop()(args.url); }
});
- } else if (port.name == "returnSetting") {
- port.onMessage.addListener(settings.receiveMessage);
} else if (port.name == "refreshCompletionKeys") {
port.onMessage.addListener(function (args) {
refreshCompletionKeys(args.completionKeys);
@@ -174,6 +202,8 @@ function initializeWhenEnabled() {
* The backend needs to know which frame has focus.
*/
window.addEventListener("focus", function(e) {
+ // settings may have changed since the frame last had focus
+ settings.load();
chrome.extension.sendRequest({ handler: "frameFocused", frameId: frameId });
});
@@ -215,7 +245,7 @@ function registerFrameIfSizeAvailable (is_top) {
* Enters insert mode if the currently focused element in the DOM is focusable.
*/
function enterInsertModeIfElementIsFocused() {
- if (document.activeElement && isEditable(document.activeElement))
+ if (document.activeElement && isEditable(document.activeElement) && !findMode)
enterInsertModeWithoutShowingIndicator(document.activeElement);
}
@@ -234,7 +264,7 @@ function scrollActivatedElementBy(x, y) {
return;
}
- if (!activatedElement || utils.getVisibleClientRect(activatedElement) === null)
+ if (!activatedElement || domUtils.getVisibleClientRect(activatedElement) === null)
activatedElement = document.body;
// Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149).
@@ -275,16 +305,16 @@ 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() { scrollActivatedElementBy(0, -1 * settings.get("scrollStepSize")); }
-function scrollDown() { scrollActivatedElementBy(0, settings.get("scrollStepSize")); }
+function scrollDown() { scrollActivatedElementBy(0, parseFloat(settings.get("scrollStepSize"))); }
function scrollPageUp() { scrollActivatedElementBy(0, -1 * window.innerHeight / 2); }
function scrollPageDown() { scrollActivatedElementBy(0, window.innerHeight / 2); }
function scrollFullPageUp() { scrollActivatedElementBy(0, -window.innerHeight); }
function scrollFullPageDown() { scrollActivatedElementBy(0, window.innerHeight); }
function scrollLeft() { scrollActivatedElementBy(-1 * settings.get("scrollStepSize"), 0); }
-function scrollRight() { scrollActivatedElementBy(settings.get("scrollStepSize"), 0); }
+function scrollRight() { scrollActivatedElementBy(parseFloat(settings.get("scrollStepSize")), 0); }
function focusInput(count) {
- var results = utils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
+ var results = domUtils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
var lastInputBox;
var i = 0;
@@ -293,7 +323,7 @@ function focusInput(count) {
var currentInputBox = results.iterateNext();
if (!currentInputBox) { break; }
- if (utils.getVisibleClientRect(currentInputBox) === null)
+ if (domUtils.getVisibleClientRect(currentInputBox) === null)
continue;
lastInputBox = currentInputBox;
@@ -339,7 +369,7 @@ function copyCurrentUrl() {
var getCurrentUrlPort = chrome.extension.connect({ name: "getCurrentTabUrl" });
getCurrentUrlPort.postMessage({});
- HUD.showForDuration("Yanked URL", 1000);
+ HUD.showForDuration("Yanked URL", 1000);
}
function toggleViewSourceCallback(url) {
@@ -377,10 +407,7 @@ function onKeypress(event) {
if (keyChar) {
if (findMode) {
handleKeyCharForFindMode(keyChar);
-
- // Don't let the space scroll us if we're searching.
- if (event.keyCode == keyCodes.space)
- event.preventDefault();
+ suppressEvent(event);
} else if (!isInsertMode() && !findMode) {
if (currentCompletionKeys.indexOf(keyChar) != -1) {
event.preventDefault();
@@ -410,6 +437,11 @@ function bubbleEvent(type, event) {
return true;
}
+function suppressEvent(event) {
+ event.preventDefault();
+ event.stopPropagation();
+}
+
function onKeydown(event) {
if (!bubbleEvent('keydown', event))
return;
@@ -459,15 +491,19 @@ function onKeydown(event) {
}
else if (findMode) {
if (isEscape(event)) {
- exitFindMode();
- // Don't let backspace take us back in history.
+ handleEscapeForFindMode();
+ suppressEvent(event);
}
else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) {
handleDeleteForFindMode();
- event.preventDefault();
+ suppressEvent(event);
}
else if (event.keyCode == keyCodes.enter) {
handleEnterForFindMode();
+ suppressEvent(event);
+ }
+ else if (!modifiers) {
+ event.stopPropagation();
}
}
else if (isShowingHelpDialog && isEscape(event)) {
@@ -484,6 +520,8 @@ function onKeydown(event) {
}
else if (isEscape(event)) {
keyPort.postMessage({keyChar:"<ESC>", frameId:frameId});
+ handleEscapeForNormalMode();
+ suppressEvent(event);
}
}
@@ -536,7 +574,7 @@ function refreshCompletionKeys(response) {
}
function onFocusCapturePhase(event) {
- if (isFocusable(event.target))
+ if (isFocusable(event.target) && !findMode)
enterInsertModeWithoutShowingIndicator(event.target);
}
@@ -600,46 +638,145 @@ function exitInsertMode(target) {
function isInsertMode() { return insertModeLock !== null; }
+// should be called whenever rawQuery is modified.
+function updateFindModeQuery() {
+ // the query can be treated differently (e.g. as a plain string versus regex depending on the presence of
+ // escape sequences. '\' is the escape character and needs to be escaped itself to be used as a normal
+ // character. here we grep for the relevant escape sequences.
+ findModeQuery.isRegex = false;
+ var hasNoIgnoreCaseFlag = false;
+ findModeQuery.parsedQuery = findModeQuery.rawQuery.replace(/\\./g, function(match) {
+ switch (match) {
+ case "\\r":
+ findModeQuery.isRegex = true;
+ return '';
+ case "\\I":
+ hasNoIgnoreCaseFlag = true;
+ return '';
+ case "\\\\":
+ return "\\";
+ default:
+ return match;
+ }
+ });
+
+ // default to 'smartcase' mode, unless noIgnoreCase is explicitly specified
+ findModeQuery.ignoreCase = !hasNoIgnoreCaseFlag && !/[A-Z]/.test(findModeQuery.parsedQuery);
+
+ // if we are dealing with a regex, grep for all matches in the text, and then call window.find() on them
+ // sequentially so the browser handles the scrolling / text selection.
+ if (findModeQuery.isRegex) {
+ try {
+ var pattern = new RegExp(findModeQuery.parsedQuery, "g" + (findModeQuery.ignoreCase ? "i" : ""));
+ }
+ catch (e) {
+ // if we catch a SyntaxError, assume the user is not done typing yet and return quietly
+ return;
+ }
+ // innerText will not return the text of hidden elements, and strip out tags while preserving newlines
+ var text = document.body.innerText;
+ findModeQuery.regexMatches = text.match(pattern);
+ findModeQuery.activeRegexIndex = 0;
+ }
+}
+
function handleKeyCharForFindMode(keyChar) {
- findModeQuery = findModeQuery + keyChar;
+ findModeQuery.rawQuery += keyChar;
+ updateFindModeQuery();
performFindInPlace();
showFindModeHUDForQuery();
}
+function handleEscapeForFindMode() {
+ exitFindMode();
+ document.body.classList.remove("vimiumFindMode");
+ // removing the class does not re-color existing selections. we recreate the current selection so it reverts
+ // back to the default color.
+ var selection = window.getSelection();
+ if (!selection.isCollapsed) {
+ var range = window.getSelection().getRangeAt(0);
+ window.getSelection().removeAllRanges();
+ window.getSelection().addRange(range);
+ }
+ focusFoundLink() || selectFoundInputElement();
+}
+
function handleDeleteForFindMode() {
- if (findModeQuery.length == 0) {
+ if (findModeQuery.rawQuery.length == 0) {
exitFindMode();
performFindInPlace();
}
else {
- findModeQuery = findModeQuery.substring(0, findModeQuery.length - 1);
+ findModeQuery.rawQuery = findModeQuery.rawQuery.substring(0, findModeQuery.rawQuery.length - 1);
+ updateFindModeQuery();
performFindInPlace();
showFindModeHUDForQuery();
}
}
+// <esc> sends us into insert mode if possible, but <cr> does not.
+// <esc> corresponds approximately to 'nevermind, I have found it already' while <cr> means 'I want to save
+// this query and do more searches with it'
function handleEnterForFindMode() {
exitFindMode();
- performFindInPlace();
+ focusFoundLink();
+ document.body.classList.add("vimiumFindMode");
+ settings.set("findModeRawQuery", findModeQuery.rawQuery);
}
function performFindInPlace() {
var cachedScrollX = window.scrollX;
var cachedScrollY = window.scrollY;
+ if (findModeQuery.isRegex) {
+ if (!findModeQuery.regexMatches) {
+ findModeQueryHasResults = false;
+ return;
+ }
+ else
+ var query = findModeQuery.regexMatches[0];
+ }
+ else
+ var query = findModeQuery.parsedQuery;
+
// Search backwards first to "free up" the current word as eligible for the real forward search. This allows
// us to search in place without jumping around between matches as the query grows.
- window.find(findModeQuery, false, true, true, false, true, false);
+ executeFind(query, { backwards: true, caseSensitive: !findModeQuery.ignoreCase });
// We need to restore the scroll position because we might've lost the right position by searching
// backwards.
window.scrollTo(cachedScrollX, cachedScrollY);
- executeFind();
+ findModeQueryHasResults = executeFind(query, { caseSensitive: !findModeQuery.ignoreCase });
+}
+
+// :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.
+function executeFind(query, options) {
+ options = options || {};
+
+ // rather hacky, but this is our way of signalling to the insertMode listener not to react to the focus
+ // changes that find() induces.
+ var oldFindMode = findMode;
+ findMode = true;
+
+ document.body.classList.add("vimiumFindMode");
+
+ // ignore the selectionchange event generated by find()
+ document.removeEventListener("selectionchange",restoreDefaultSelectionHighlight, true);
+ var rv = window.find(query, options.caseSensitive, options.backwards, true, false, true, false);
+ setTimeout(function() {
+ document.addEventListener("selectionchange", restoreDefaultSelectionHighlight, true);
+ }, 0);
+
+ findMode = oldFindMode;
+ // we need to save the anchor node here because <esc> seems to nullify it, regardless of whether we do
+ // preventDefault()
+ findModeAnchorNode = document.getSelection().anchorNode;
+ return rv;
}
-function executeFind(backwards) {
- findModeQueryHasResults = window.find(findModeQuery, false, backwards, true, false, true, false);
+function restoreDefaultSelectionHighlight() {
+ document.body.classList.remove("vimiumFindMode");
}
function focusFoundLink() {
@@ -650,8 +787,78 @@ function focusFoundLink() {
}
}
+function isDOMDescendant(parent, child) {
+ var node = child;
+ while (node !== null) {
+ if (node === parent)
+ return true;
+ node = node.parentNode;
+ }
+ return false;
+}
+
+function selectFoundInputElement() {
+ // if the found text is in an input element, getSelection().anchorNode will be null, so we use activeElement
+ // instead. however, since the last focused element might not be the one currently pointed to by find (e.g.
+ // the current one might be disabled and therefore unable to receive focus), we use the approximate
+ // heuristic of checking that the last anchor node is an ancestor of our element.
+ if (findModeQueryHasResults && domUtils.isSelectable(document.activeElement) &&
+ isDOMDescendant(findModeAnchorNode, document.activeElement)) {
+ domUtils.simulateSelect(document.activeElement);
+ // the element has already received focus via find(), so invoke insert mode manually
+ enterInsertModeWithoutShowingIndicator(document.activeElement);
+ }
+}
+
function findAndFocus(backwards) {
- executeFind(backwards);
+ // check if the query has been changed by a script in another frame
+ var mostRecentQuery = settings.get("findModeRawQuery") || "";
+ if (mostRecentQuery !== findModeQuery.rawQuery) {
+ findModeQuery.rawQuery = mostRecentQuery;
+ updateFindModeQuery();
+ performFindInPlace();
+ return;
+ }
+
+ if (!findModeQueryHasResults) {
+ HUD.showForDuration("No matches for '" + findModeQuery.rawQuery + "'", 1000);
+ return;
+ }
+
+ if (findModeQuery.isRegex) {
+ if (!backwards) {
+ if (++findModeQuery.activeRegexIndex == findModeQuery.regexMatches.length)
+ findModeQuery.activeRegexIndex = 0;
+ }
+ else {
+ if (--findModeQuery.activeRegexIndex == -1)
+ findModeQuery.activeRegexIndex = findModeQuery.regexMatches.length - 1;
+ }
+ var query = findModeQuery.regexMatches[findModeQuery.activeRegexIndex];
+ }
+ else
+ var query = findModeQuery.parsedQuery;
+
+ findModeQueryHasResults = executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase });
+
+ // if we have found an input element via 'n', pressing <esc> immediately afterwards sends us into insert
+ // mode
+ var elementCanTakeInput = findModeQueryHasResults && domUtils.isSelectable(document.activeElement) &&
+ isDOMDescendant(findModeAnchorNode, document.activeElement);
+ if (elementCanTakeInput) {
+ handlerStack.push({
+ keydown: function(event) {
+ handlerStack.pop();
+ if (isEscape(event)) {
+ domUtils.simulateSelect(document.activeElement);
+ enterInsertModeWithoutShowingIndicator(document.activeElement);
+ return false; // we have 'consumed' this event, so do not propagate
+ }
+ return true;
+ }
+ });
+ }
+
focusFoundLink();
}
@@ -661,24 +868,83 @@ function performBackwardsFind() { findAndFocus(true); }
function getLinkFromSelection() {
var node = window.getSelection().anchorNode;
- while (node.nodeName.toLowerCase() !== 'body') {
+ while (node && node.nodeName.toLowerCase() !== 'body') {
if (node.nodeName.toLowerCase() === 'a') return node;
node = node.parentNode;
}
return null;
}
+// used by the findAndFollow* functions.
+function followLink(link) {
+ link.scrollIntoView();
+ link.focus();
+ domUtils.simulateClick(link);
+}
+
+/**
+ * Find and follow the shortest link (shortest == fewest words) which matches any one of a list of strings.
+ * If there are multiple shortest links, strings are prioritized for exact word matches, followed by their
+ * position in :linkStrings. Practically speaking, this means we favor 'next page' over 'the next big thing',
+ * and 'more' over 'nextcompany', even if 'next' occurs before 'more' in :linkStrings.
+ */
function findAndFollowLink(linkStrings) {
- for (i = 0; i < linkStrings.length; i++) {
- var hasResults = window.find(linkStrings[i], false, true, true, false, true, false);
- if (hasResults) {
- var link = getLinkFromSelection();
- if (link) {
- window.location = link.href;
- return true;
+ var linksXPath = domUtils.makeXPath(["a", "*[@onclick or @role='link']"]);
+ var links = domUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
+ var shortestLinks = [];
+ var shortestLinkLength = null;
+
+ // at the end of this loop, shortestLinks will be populated with a list of candidates
+ // links lower in the page are more likely to be the ones we want, so we loop through the snapshot backwards
+ for (var i = links.snapshotLength - 1; i >= 0; i--) {
+ var link = links.snapshotItem(i);
+
+ // ensure link is visible (we don't mind if it is scrolled offscreen)
+ var boundingClientRect = link.getBoundingClientRect();
+ if (boundingClientRect.width == 0 || boundingClientRect.height == 0)
+ continue;
+ var computedStyle = window.getComputedStyle(link, null);
+ if (computedStyle.getPropertyValue('visibility') != 'visible' ||
+ computedStyle.getPropertyValue('display') == 'none')
+ continue;
+
+ var linkMatches = false;
+ for (var j = 0; j < linkStrings.length; j++) {
+ if (link.innerText.toLowerCase().indexOf(linkStrings[j]) !== -1) {
+ linkMatches = true;
+ break;
}
}
+ if (!linkMatches) continue;
+
+ var wordCount = link.innerText.trim().split(/\s+/).length;
+ if (shortestLinkLength === null || wordCount < shortestLinkLength) {
+ shortestLinkLength = wordCount;
+ shortestLinks = [ link ];
+ }
+ else if (wordCount === shortestLinkLength) {
+ shortestLinks.push(link);
+ }
}
+
+ // try to get exact word matches first
+ for (var i = 0; i < linkStrings.length; i++)
+ for (var j = 0; j < shortestLinks.length; j++) {
+ var exactWordRegex = new RegExp("\\b" + linkStrings[i] + "\\b", "i");
+ if (exactWordRegex.test(shortestLinks[j].innerText)) {
+ followLink(shortestLinks[j]);
+ return true;
+ }
+ }
+
+ for (var i = 0; i < linkStrings.length; i++)
+ for (var j = 0; j < shortestLinks.length; j++) {
+ if (shortestLinks[j].innerText.toLowerCase().indexOf(linkStrings[i]) !== -1) {
+ followLink(shortestLinks[j]);
+ return true;
+ }
+ }
+
return false;
}
@@ -688,7 +954,7 @@ function findAndFollowRel(value) {
var elements = document.getElementsByTagName(relTags[i]);
for (j = 0; j < elements.length; j++) {
if (elements[j].hasAttribute('rel') && elements[j].rel == value) {
- window.location = elements[j].href;
+ followLink(elements[j]);
return true;
}
}
@@ -708,37 +974,29 @@ function goNext() {
}
function showFindModeHUDForQuery() {
- if (findModeQueryHasResults || findModeQuery.length == 0)
- HUD.show("/" + insertSpaces(findModeQuery));
+ if (findModeQueryHasResults || findModeQuery.parsedQuery.length == 0)
+ HUD.show("/" + insertSpaces(findModeQuery.rawQuery));
else
- HUD.show("/" + insertSpaces(findModeQuery + " (No Matches)"));
+ HUD.show("/" + insertSpaces(findModeQuery.rawQuery + " (No Matches)"));
}
/*
* We need this so that the find mode HUD doesn't match its own searches.
*/
function insertSpaces(query) {
- var newQuery = "";
-
- for (var i = 0; i < query.length; i++) {
- if (query[i] == " " || (i + 1 < query.length && query[i + 1] == " "))
- newQuery = newQuery + query[i];
- else // &#8203; is a zero-width space
- newQuery = newQuery + query[i] + "<span>&#8203;</span>";
- }
-
- return newQuery;
+ // &#8203; is a zero-width space. the <span>s are necessary because the zero-width space tends to interfere
+ // with subsequent characters in the same text node.
+ return query.split("").join("<span class='vimiumReset'>&#8203</span>");
}
function enterFindMode() {
- findModeQuery = "";
+ findModeQuery = { rawQuery: "" };
findMode = true;
HUD.show("/");
}
function exitFindMode() {
findMode = false;
- focusFoundLink();
HUD.hide();
}
@@ -772,6 +1030,15 @@ function hideHelpDialog(clickEvent) {
clickEvent.preventDefault();
}
+// do our best to return the document to its 'default' state.
+function handleEscapeForNormalMode() {
+ window.getSelection().collapse();
+ if (document.activeElement !== document.body)
+ document.activeElement.blur();
+ else if (window.top !== window.self)
+ chrome.extension.sendRequest({ handler: "focusTopFrame" });
+}
+
/*
* A heads-up-display (HUD) for showing Vimium page operations.
* Note: you cannot interact with the HUD until document.body is available.