/* * This content script takes input from its webpage and executes commands locally on behalf of the background * page. It must be run prior to domReady so that we perform some operations very early. We tell the * background page that we're in domReady and ready to accept normal commands by connectiong to a port named * "domReady". */ var getCurrentUrlHandlers = []; // function(url) var insertModeLock = null; var findMode = false; var findModeQuery = { rawQuery: "" }; var findModeQueryHasResults = false; var findModeAnchorNode = null; var isShowingHelpDialog = false; var handlerStack = []; var keyPort; // Users can disable Vimium on URL patterns via the settings page. var isEnabledForUrl = true; // The user's operating system. var currentCompletionKeys; var validFirstKeys; var linkHintCss; var activatedElement; // The types in that we consider for focusInput command. Right now this is recalculated in // each content script. Alternatively we could calculate it once in the background page and use a request to // fetch it each time. // // Should we include the HTML5 date pickers here? // The corresponding XPath for such elements. var textInputXPath = (function() { var textInputTypes = ["text", "search", "email", "url", "number", "password"]; var inputElements = ["input[" + "(" + textInputTypes.map(function(type) {return '@type="' + type + '"'}).join(" or ") + "or not(@type))" + " and not(@disabled or @readonly)]", "textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]; 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", "hideHud", "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]; }, set: function (key, value) { if (!this.port) this.init(); this.values[key] = value; this.port.postMessage({ operation: "set", key: key, value: value }); }, 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; // 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); }, }; /* * Give this frame a unique id. */ frameId = Math.floor(Math.random()*999999999) var hasModifiersRegex = /^<([amc]-)+.>/; /* * Complete initialization work that sould be done prior to DOMReady. */ function initializePreDomReady() { settings.addEventListener("load", linkHints.init.bind(linkHints)); settings.load(); checkIfEnabledForUrl(); chrome.extension.sendRequest({handler: "getLinkHintCss"}, function (response) { linkHintCss = response.linkHintCss; }); refreshCompletionKeys(); // Send the key to the key handler in the background page. keyPort = chrome.extension.connect({ name: "keyDown" }); chrome.extension.onRequest.addListener(function(request, sender, sendResponse) { if (request.name == "hideUpgradeNotification") { HUD.hideUpgradeNotification(); } else if (request.name == "showUpgradeNotification" && isEnabledForUrl) { HUD.showUpgradeNotification(request.version); } else if (request.name == "showHelpDialog") { if (isShowingHelpDialog) hideHelpDialog(); else showHelpDialog(request.dialogHtml, request.frameId); } else if (request.name == "focusFrame") { if (frameId == request.frameId) focusThisFrame(request.highlight); } else if (request.name == "refreshCompletionKeys") { refreshCompletionKeys(request); } sendResponse({}); // Free up the resources used by this open connection. }); chrome.extension.onConnect.addListener(function(port, name) { if (port.name == "executePageCommand") { port.onMessage.addListener(function(args) { if (frameId == args.frameId) { if (args.passCountToFunction) { Utils.invokeCommandString(args.command, [args.count]); } else { for (var i = 0; i < args.count; i++) { Utils.invokeCommandString(args.command); } } } refreshCompletionKeys(args); }); } else if (port.name == "getScrollPosition") { port.onMessage.addListener(function(args) { var scrollPort = chrome.extension.connect({ name: "returnScrollPosition" }); scrollPort.postMessage({ scrollX: window.scrollX, scrollY: window.scrollY, currentTab: args.currentTab }); }); } else if (port.name == "setScrollPosition") { port.onMessage.addListener(function(args) { if (args.scrollX > 0 || args.scrollY > 0) { DomUtils.documentReady(function() { window.scrollBy(args.scrollX, args.scrollY); }); } }); } else if (port.name == "returnCurrentTabUrl") { port.onMessage.addListener(function(args) { if (getCurrentUrlHandlers.length > 0) { getCurrentUrlHandlers.pop()(args.url); } }); } else if (port.name == "refreshCompletionKeys") { port.onMessage.addListener(function (args) { refreshCompletionKeys(args.completionKeys); }); } else if (port.name == "getActiveState") { port.onMessage.addListener(function(args) { port.postMessage({ enabled: isEnabledForUrl }); }); } else if (port.name == "disableVimium") { port.onMessage.addListener(function(args) { disableVimium(); }); } }); } /* * This is called once the background page has told us that Vimium should be enabled for the current URL. */ function initializeWhenEnabled() { document.addEventListener("keydown", onKeydown, true); document.addEventListener("keypress", onKeypress, true); document.addEventListener("keyup", onKeyup, true); document.addEventListener("focus", onFocusCapturePhase, true); document.addEventListener("blur", onBlurCapturePhase, true); document.addEventListener("DOMActivate", onDOMActivate, true); enterInsertModeIfElementIsFocused(); } /* * Used to disable Vimium without needing to reload the page. * This is called if the current page's url is blacklisted using the popup UI. */ function disableVimium() { document.removeEventListener("keydown", onKeydown, true); document.removeEventListener("keypress", onKeypress, true); document.removeEventListener("keyup", onKeyup, true); document.removeEventListener("focus", onFocusCapturePhase, true); document.removeEventListener("blur", onBlurCapturePhase, true); document.removeEventListener("DOMActivate", onDOMActivate, true); isEnabledForUrl = false; } /* * 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 }); }); /* * Called from the backend in order to change frame focus. */ function focusThisFrame(shouldHighlight) { window.focus(); if (document.body && shouldHighlight) { var borderWas = document.body.style.border; document.body.style.border = '5px solid yellow'; setTimeout(function(){document.body.style.border = borderWas}, 200); } } /* * Initialization tasks that must wait for the document to be ready. */ function initializeOnDomReady() { registerFrameIfSizeAvailable(window.top == window.self); if (isEnabledForUrl) enterInsertModeIfElementIsFocused(); // Tell the background page we're in the dom ready state. chrome.extension.connect({ name: "domReady" }); }; // This is a little hacky but sometimes the size wasn't available on domReady? function registerFrameIfSizeAvailable (is_top) { if (innerWidth != undefined && innerWidth != 0 && innerHeight != undefined && innerHeight != 0) chrome.extension.sendRequest({ handler: "registerFrame", frameId: frameId, area: innerWidth * innerHeight, is_top: is_top, total: frames.length + 1 }); else setTimeout(function () { registerFrameIfSizeAvailable(is_top); }, 100); } /* * Enters insert mode if the currently focused element in the DOM is focusable. */ function enterInsertModeIfElementIsFocused() { if (document.activeElement && isEditable(document.activeElement) && !findMode) enterInsertModeWithoutShowingIndicator(document.activeElement); } function onDOMActivate(event) { activatedElement = event.target; } /** * activatedElement is different from document.activeElement -- the latter seems to be reserved mostly for * input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document. */ function scrollActivatedElementBy(direction, amount) { // if this is called before domReady, just use the window scroll function if (!document.body) { if (direction === "x") window.scrollBy(amount, 0); else // "y" window.scrollBy(0, amount); return; } // TODO refactor and put this together with the code in getVisibleClientRect function isRendered(element) { var computedStyle = window.getComputedStyle(element, null); return !(computedStyle.getPropertyValue('visibility') != 'visible' || computedStyle.getPropertyValue('display') == 'none'); } if (!activatedElement || !isRendered(activatedElement)) activatedElement = document.body; scrollName = direction === "x" ? "scrollLeft" : "scrollTop"; // Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149). // Therefore we just try to increase scrollTop blindly -- if it fails we know we have reached the end of the // content. if (amount !== 0) { var element = activatedElement; do { var oldScrollValue = element[scrollName]; element[scrollName] += amount; var lastElement = element; // we may have an orphaned element. if so, just scroll the body element. element = element.parentElement || document.body; } while(lastElement[scrollName] == oldScrollValue && lastElement != document.body); } // if the activated element has been scrolled completely offscreen, subsequent changes in its scroll // position will not provide any more visual feedback to the user. therefore we deactivate it so that // subsequent scrolls only move the parent element. var rect = activatedElement.getBoundingClientRect(); if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) activatedElement = lastElement; } function scrollToBottom() { window.scrollTo(window.pageXOffset, document.body.scrollHeight); } function scrollToTop() { window.scrollTo(window.pageXOffset, 0); } function scrollToLeft() { window.scrollTo(0, window.pageYOffset); } function scrollToRight() { window.scrollTo(document.body.scrollWidth, window.pageYOffset); } function scrollUp() { scrollActivatedElementBy("y", -1 * settings.get("scrollStepSize")); } function scrollDown() { scrollActivatedElementBy("y", parseFloat(settings.get("scrollStepSize"))); } function scrollPageUp() { scrollActivatedElementBy("y", -1 * window.innerHeight / 2); } function scrollPageDown() { scrollActivatedElementBy("y", window.innerHeight / 2); } function scrollFullPageUp() { scrollActivatedElementBy("y", -window.innerHeight); } function scrollFullPageDown() { scrollActivatedElementBy("y", window.innerHeight); } function scrollLeft() { scrollActivatedElementBy("x", -1 * settings.get("scrollStepSize")); } function scrollRight() { scrollActivatedElementBy("x", parseFloat(settings.get("scrollStepSize"))); } function focusInput(count) { var results = DomUtils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_ITERATOR_TYPE); var lastInputBox; var i = 0; while (i < count) { var currentInputBox = results.iterateNext(); if (!currentInputBox) { break; } if (DomUtils.getVisibleClientRect(currentInputBox) === null) continue; lastInputBox = currentInputBox; i += 1; } if (lastInputBox) { lastInputBox.focus(); } } function reload() { window.location.reload(); } function goBack(count) { history.go(-count); } function goForward(count) { history.go(count); } function goUp(count) { var url = window.location.href; if (url[url.length-1] == '/') url = url.substring(0, url.length - 1); var urlsplit = url.split('/'); // make sure we haven't hit the base domain yet if (urlsplit.length > 3) { urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - count)); window.location.href = urlsplit.join('/'); } } function toggleViewSource() { getCurrentUrlHandlers.push(toggleViewSourceCallback); var getCurrentUrlPort = chrome.extension.connect({ name: "getCurrentTabUrl" }); getCurrentUrlPort.postMessage({}); } function copyCurrentUrl() { // TODO(ilya): When the following bug is fixed, revisit this approach of sending back to the background page // to copy. // http://code.google.com/p/chromium/issues/detail?id=55188 //getCurrentUrlHandlers.push(function (url) { Clipboard.copy(url); }); getCurrentUrlHandlers.push(function (url) { chrome.extension.sendRequest({ handler: "copyToClipboard", data: url }); }); // TODO(ilya): Convert to sendRequest. var getCurrentUrlPort = chrome.extension.connect({ name: "getCurrentTabUrl" }); getCurrentUrlPort.postMessage({}); HUD.showForDuration("Yanked URL", 1000); } function toggleViewSourceCallback(url) { if (url.substr(0, 12) == "view-source:") { url = url.substr(12, url.length - 12); } else { url = "view-source:" + url; } chrome.extension.sendRequest({handler: "openUrlInNewTab", url: url, selected: true}); } /** * Sends everything except i & ESC to the handler in background_page. i & ESC are special because they control * insert mode which is local state to the page. The key will be are either a single ascii letter or a * key-modifier pair, e.g. for control a. * * Note that some keys will only register keydown events and not keystroke events, e.g. ESC. */ function onKeypress(event) { if (!bubbleEvent('keypress', event)) return; var keyChar = ""; // Ignore modifier keys by themselves. if (event.keyCode > 31) { keyChar = String.fromCharCode(event.charCode); // Enter insert mode when the user enables the native find interface. if (keyChar == "f" && KeyboardUtils.isPrimaryModifierKey(event)) { enterInsertModeWithoutShowingIndicator(); return; } if (keyChar) { if (findMode) { handleKeyCharForFindMode(keyChar); suppressEvent(event); } else if (!isInsertMode() && !findMode) { if (currentCompletionKeys.indexOf(keyChar) != -1) suppressEvent(event); keyPort.postMessage({keyChar:keyChar, frameId:frameId}); } } } } /** * Called whenever we receive a key event. Each individual handler has the option to stop the event's * propagation by returning a falsy value. */ function bubbleEvent(type, event) { for (var i = handlerStack.length-1; i >= 0; i--) { // We need to check for existence of handler because the last function call may have caused the release of // more than one handler. if (handlerStack[i] && handlerStack[i][type] && !handlerStack[i][type](event)) { suppressEvent(event); return false; } } return true; } function suppressEvent(event) { event.preventDefault(); event.stopPropagation(); } function onKeydown(event) { if (!bubbleEvent('keydown', event)) return; var keyChar = ""; // handle special keys, and normal input keys with modifiers being pressed. don't handle shiftKey alone (to // avoid / being interpreted as ? if (((event.metaKey || event.ctrlKey || event.altKey) && event.keyCode > 31) || event.keyIdentifier.slice(0, 2) != "U+") { keyChar = KeyboardUtils.getKeyChar(event); if (keyChar != "") { // Again, ignore just modifiers. Maybe this should replace the keyCode>31 condition. var modifiers = []; if (event.shiftKey) keyChar = keyChar.toUpperCase(); if (event.metaKey) modifiers.push("m"); if (event.ctrlKey) modifiers.push("c"); if (event.altKey) modifiers.push("a"); for (var i in modifiers) keyChar = modifiers[i] + "-" + keyChar; if (modifiers.length > 0 || keyChar.length > 1) keyChar = "<" + keyChar + ">"; } } if (isInsertMode() && KeyboardUtils.isEscape(event)) { // Note that we can't programmatically blur out of Flash embeds from Javascript. if (!isEmbed(event.srcElement)) { // Remove focus so the user can't just get himself back into insert mode by typing in the same input // box. if (isEditable(event.srcElement)) event.srcElement.blur(); exitInsertMode(); suppressEvent(event); } } else if (findMode) { if (KeyboardUtils.isEscape(event)) { handleEscapeForFindMode(); suppressEvent(event); } else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { handleDeleteForFindMode(); suppressEvent(event); } else if (event.keyCode == keyCodes.enter) { handleEnterForFindMode(); suppressEvent(event); } else if (!modifiers) { event.stopPropagation(); } } else if (isShowingHelpDialog && KeyboardUtils.isEscape(event)) { hideHelpDialog(); } else if (!isInsertMode() && !findMode) { if (keyChar) { if (currentCompletionKeys.indexOf(keyChar) != -1) suppressEvent(event); keyPort.postMessage({keyChar:keyChar, frameId:frameId}); } else if (KeyboardUtils.isEscape(event)) { keyPort.postMessage({keyChar:"", frameId:frameId}); } } // Added to prevent propagating this event to other listeners if it's one that'll trigger a Vimium command. // The goal is to avoid the scenario where Google Instant Search uses every keydown event to dump us // back into the search box. As a side effect, this should also prevent overriding by other sites. // // Subject to internationalization issues since we're using keyIdentifier instead of charCode (in keypress). // // TOOD(ilya): Revisit this. Not sure it's the absolute best approach. if (keyChar == "" && !isInsertMode() && (currentCompletionKeys.indexOf(KeyboardUtils.getKeyChar(event)) != -1 || isValidFirstKey(KeyboardUtils.getKeyChar(event)))) event.stopPropagation(); } function onKeyup() { if (!bubbleEvent('keyup', event)) return; } function checkIfEnabledForUrl() { var url = window.location.toString(); chrome.extension.sendRequest({ handler: "isEnabledForUrl", url: url }, function (response) { isEnabledForUrl = response.isEnabledForUrl; if (isEnabledForUrl) initializeWhenEnabled(); else if (HUD.isReady()) // Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load. HUD.hide(); }); } function refreshCompletionKeys(response) { if (response) { currentCompletionKeys = response.completionKeys; if (response.validFirstKeys) validFirstKeys = response.validFirstKeys; } else { chrome.extension.sendRequest({ handler: "getCompletionKeys" }, refreshCompletionKeys); } } function isValidFirstKey(keyChar) { return validFirstKeys[keyChar] || /[1-9]/.test(keyChar); } function onFocusCapturePhase(event) { if (isFocusable(event.target) && !findMode) enterInsertModeWithoutShowingIndicator(event.target); } function onBlurCapturePhase(event) { if (isFocusable(event.target)) exitInsertMode(event.target); } /* * Returns true if the element is focusable. This includes embeds like Flash, which steal the keybaord focus. */ 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.nodeName.toLowerCase()) > 0; } /* * Input or text elements are considered focusable and able to receieve their own keyboard events, * and will enter enter mode if focused. Also note that the "contentEditable" attribute can be set on * any element which makes it a rich text editor, like the notes on jjot.com. */ function isEditable(target) { if (target.isContentEditable) return true; var nodeName = target.nodeName.toLowerCase(); // use a blacklist instead of a whitelist because new form controls are still being implemented for html5 var noFocus = ["radio", "checkbox"]; if (nodeName == "input" && noFocus.indexOf(target.type) == -1) return true; var focusableElements = ["textarea", "select"]; return focusableElements.indexOf(nodeName) >= 0; } /* * Enters insert mode and show an "Insert mode" message. Showing the UI is only useful when entering insert * mode manually by pressing "i". In most cases we do not show any UI (enterInsertModeWithoutShowingIndicator) */ function enterInsertMode(target) { enterInsertModeWithoutShowingIndicator(target); HUD.show("Insert mode"); } /* * We cannot count on 'focus' and 'blur' events to happen sequentially. For example, if blurring element A * causes element B to come into focus, we may get "B focus" before "A blur". Thus we only leave insert mode * when the last editable element that came into focus -- which insertModeLock points to -- has been blurred. * If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only * leave insert mode when the user presses . */ function enterInsertModeWithoutShowingIndicator(target) { insertModeLock = target; } function exitInsertMode(target) { if (target === undefined || insertModeLock === target) { insertModeLock = null; HUD.hide(); } } 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.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.rawQuery.length == 0) { exitFindMode(); performFindInPlace(); } else { findModeQuery.rawQuery = findModeQuery.rawQuery.substring(0, findModeQuery.rawQuery.length - 1); updateFindModeQuery(); performFindInPlace(); showFindModeHUDForQuery(); } } // sends us into insert mode if possible, but does not. // corresponds approximately to 'nevermind, I have found it already' while means 'I want to save // this query and do more searches with it' function handleEnterForFindMode() { exitFindMode(); focusFoundLink(); document.body.classList.add("vimiumFindMode"); settings.set("findModeRawQuery", findModeQuery.rawQuery); } function performFindInPlace() { var cachedScrollX = window.scrollX; var cachedScrollY = window.scrollY; var query = findModeQuery.isRegex ? getNextQueryFromRegexMatches(0) : 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. 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); 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"); // prevent find from matching its own search query in the HUD HUD.hide(true); // 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 seems to nullify it, regardless of whether we do // preventDefault() findModeAnchorNode = document.getSelection().anchorNode; return rv; } function restoreDefaultSelectionHighlight() { document.body.classList.remove("vimiumFindMode"); } function focusFoundLink() { if (findModeQueryHasResults) { var link = getLinkFromSelection(); if (link) link.focus(); } } 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 getNextQueryFromRegexMatches(stepSize) { if (!findModeQuery.regexMatches) return ""; // find()ing an empty query always returns false var totalMatches = findModeQuery.regexMatches.length; findModeQuery.activeRegexIndex += stepSize + totalMatches; findModeQuery.activeRegexIndex %= totalMatches; return findModeQuery.regexMatches[findModeQuery.activeRegexIndex]; } function findAndFocus(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(); } var query = findModeQuery.isRegex ? getNextQueryFromRegexMatches(backwards ? -1 : 1) : findModeQuery.parsedQuery; findModeQueryHasResults = executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase }); if (!findModeQueryHasResults) { HUD.showForDuration("No matches for '" + findModeQuery.rawQuery + "'", 1000); return; } // if we have found an input element via 'n', pressing immediately afterwards sends us into insert // mode var elementCanTakeInput = DomUtils.isSelectable(document.activeElement) && isDOMDescendant(findModeAnchorNode, document.activeElement); if (elementCanTakeInput) { handlerStack.push({ keydown: function(event) { handlerStack.pop(); if (KeyboardUtils.isEscape(event)) { DomUtils.simulateSelect(document.activeElement); enterInsertModeWithoutShowingIndicator(document.activeElement); return false; // we have 'consumed' this event, so do not propagate } return true; } }); } focusFoundLink(); } function performFind() { findAndFocus(); } function performBackwardsFind() { findAndFocus(true); } function getLinkFromSelection() { var node = window.getSelection().anchorNode; while (node && node !== document.body) { if (node.nodeName.toLowerCase() === 'a') return node; node = node.parentNode; } return null; } // used by the findAndFollow* functions. function followLink(linkElement) { if (linkElement.nodeName.toLowerCase() === 'link') window.location.href = linkElement.href; else { // if we can click on it, don't simply set location.href: some next/prev links are meant to trigger AJAX // calls, like the 'more' button on GitHub's newsfeed. linkElement.scrollIntoView(); linkElement.focus(); DomUtils.simulateClick(linkElement); } } /** * Find and follow a link which matches any one of a list of strings. If there are multiple such links, they * are prioritized for shortness, by their position in :linkStrings, how far down the page they are located, * and finally by whether the match is exact. 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) { var linksXPath = DomUtils.makeXPath(["a", "*[@onclick or @role='link' or contains(@class, 'button')]"]); var links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE); var candidateLinks = []; // at the end of this loop, candidateLinks will contain all visible links that match our patterns // 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; candidateLinks.push(link); } if (candidateLinks.length === 0) return; function wordCount(link) { return link.innerText.trim().split(/\s+/).length; } // We can use this trick to ensure that Array.sort is stable. We need this property to retain the reverse // in-page order of the links. candidateLinks.forEach(function(a,i){ a.originalIndex = i; }); // favor shorter links, and ignore those that are more than one word longer than the shortest link candidateLinks = candidateLinks .sort(function(a,b) { var wcA = wordCount(a), wcB = wordCount(b); return wcA === wcB ? a.originalIndex - b.originalIndex : wcA - wcB; }) .filter(function(a){return wordCount(a) <= wordCount(candidateLinks[0]) + 1}); // try to get exact word matches first for (var i = 0; i < linkStrings.length; i++) for (var j = 0; j < candidateLinks.length; j++) { var exactWordRegex = new RegExp("\\b" + linkStrings[i] + "\\b", "i"); if (exactWordRegex.test(candidateLinks[j].innerText)) { followLink(candidateLinks[j]); return true; } } for (var i = 0; i < linkStrings.length; i++) for (var j = 0; j < candidateLinks.length; j++) { if (candidateLinks[j].innerText.toLowerCase().indexOf(linkStrings[i]) !== -1) { followLink(candidateLinks[j]); return true; } } return false; } function findAndFollowRel(value) { var relTags = ['link', 'a', 'area']; for (i = 0; i < relTags.length; i++) { var elements = document.getElementsByTagName(relTags[i]); for (j = 0; j < elements.length; j++) { if (elements[j].hasAttribute('rel') && elements[j].rel == value) { followLink(elements[j]); return true; } } } } function goPrevious() { var previousPatterns = settings.get("previousPatterns") || ""; var previousStrings = previousPatterns.split(","); findAndFollowRel('prev') || findAndFollowLink(previousStrings); } function goNext() { var nextPatterns = settings.get("nextPatterns") || ""; var nextStrings = nextPatterns.split(","); findAndFollowRel('next') || findAndFollowLink(nextStrings); } function showFindModeHUDForQuery() { if (findModeQueryHasResults || findModeQuery.parsedQuery.length == 0) HUD.show("/" + findModeQuery.rawQuery); else HUD.show("/" + findModeQuery.rawQuery + " (No Matches)"); } function enterFindMode() { findModeQuery = { rawQuery: "" }; findMode = true; HUD.show("/"); } function exitFindMode() { findMode = false; HUD.hide(); } function showHelpDialog(html, fid) { if (isShowingHelpDialog || !document.body || fid != frameId) return; isShowingHelpDialog = true; var container = document.createElement("div"); container.id = "vimiumHelpDialogContainer"; container.className = "vimiumReset"; document.body.appendChild(container); container.innerHTML = html; container.getElementsByClassName("closeButton")[0].addEventListener("click", hideHelpDialog, false); container.getElementsByClassName("optionsPage")[0].addEventListener("click", function() { chrome.extension.sendRequest({ handler: "openOptionsPageInNewTab" }); }, false); // This is necessary because innerHTML does not evaluate javascript embedded in