diff options
Diffstat (limited to 'vimiumFrontend.js')
| -rw-r--r-- | vimiumFrontend.js | 397 |
1 files changed, 332 insertions, 65 deletions
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 // ​ is a zero-width space - newQuery = newQuery + query[i] + "<span>​</span>"; - } - - return newQuery; + // ​ 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'>​</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. |
