/* * 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, like setting * the page's zoom level. We tell the background page that we're in domReady and ready to accept normal * commands by connectiong to a port named "domReady". */ var settings = {}; var settingsToLoad = ["scrollStepSize", "linkHintCharacters"]; var getCurrentUrlHandlers = []; // function(url) var insertMode = false; var findMode = false; var findModeQuery = ""; var findModeQueryHasResults = false; var isShowingHelpDialog = false; var keyPort; var settingPort; var saveZoomLevelPort; // Users can disable Vimium on URL patterns via the settings page. var isEnabledForUrl = true; // The user's operating system. var currentCompletionKeys; var linkHintCss; // TODO(philc): This should be pulled from the extension's storage when the page loads. var currentZoomLevel = 100; // 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? var textInputTypes = ["text", "search", "email", "url", "number"]; // The corresponding XPath for such elements. var textInputXPath = '//input[' + textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + ' or not(@type)]'; /* * Give this frame a unique id. */ frameId = Math.floor(Math.random()*999999999) var hasModifiersRegex = /^<([amc]-)+.>/; function getSetting(key) { if (!settingPort) settingPort = chrome.extension.connect({ name: "getSetting" }); settingPort.postMessage({ key: key }); } function setSetting(args) { settings[args.key] = args.value; } /* * Complete initialization work that sould be done prior to DOMReady, like setting the page's zoom level. */ function initializePreDomReady() { for (var i in settingsToLoad) { getSetting(settingsToLoad[i]); } checkIfEnabledForUrl(); var getZoomLevelPort = chrome.extension.connect({ name: "getZoomLevel" }); if (window.self == window.parent) getZoomLevelPort.postMessage({ domain: window.location.host }); 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.completionKeys); 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 (this[args.command] && frameId == args.frameId) { if (args.passCountToFunction) { this[args.command].call(null, args.count); } else { for (var i = 0; i < args.count; i++) { this[args.command].call(); } } } refreshCompletionKeys(args.completionKeys); }); } 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) { 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 == "returnZoomLevel") { port.onMessage.addListener(function(args) { currentZoomLevel = args.zoomLevel; if (isEnabledForUrl) setPageZoomLevel(currentZoomLevel); }); } else if (port.name == "returnSetting") { port.onMessage.addListener(setSetting); } else if (port.name == "refreshCompletionKeys") { port.onMessage.addListener(function (args) { refreshCompletionKeys(args.completionKeys); }); } }); } /* * 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("focus", onFocusCapturePhase, true); document.addEventListener("blur", onBlurCapturePhase, true); enterInsertModeIfElementIsFocused(); } /* * The backend needs to know which frame has focus. */ window.addEventListener("focus", function(e) { 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 (top) { if (innerWidth != undefined && innerWidth != 0 && innerHeight != undefined && innerHeight != 0) chrome.extension.sendRequest({ handler: "registerFrame", frameId: frameId, area: innerWidth * innerHeight, top: top, total: frames.length + 1 }); else setTimeout(function () { registerFrameIfSizeAvailable(top); }, 100); } /* * Checks the currently focused element of the document and will enter insert mode if that element is focusable. */ function enterInsertModeIfElementIsFocused() { // Enter insert mode automatically if there's already a text box focused. if (document.activeElement && isEditable(document.activeElement)) enterInsertMode(); } /* * Asks the background page to persist the zoom level for the given domain to localStorage. */ function saveZoomLevel(domain, zoomLevel) { if (!saveZoomLevelPort) saveZoomLevelPort = chrome.extension.connect({ name: "saveZoomLevel" }); saveZoomLevelPort.postMessage({ domain: domain, zoomLevel: zoomLevel }); } /* * Zoom in increments of 20%; this matches chrome's CMD+ and CMD- keystrokes. * Set the zoom style on documentElement because document.body does not exist pre-page load. */ function setPageZoomLevel(zoomLevel, showUINotification) { document.documentElement.style.zoom = zoomLevel + "%"; if (document.body) HUD.updatePageZoomLevel(zoomLevel); if (showUINotification) HUD.showForDuration("Zoom: " + currentZoomLevel + "%", 1000); } function zoomIn() { currentZoomLevel += 20; setAndSaveZoom(); } function zoomOut() { currentZoomLevel -= 20; setAndSaveZoom(); } function zoomReset() { currentZoomLevel = 100; setAndSaveZoom(); } function setAndSaveZoom() { setPageZoomLevel(currentZoomLevel, true); saveZoomLevel(window.location.host, currentZoomLevel); } 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() { window.scrollBy(0, -1 * settings["scrollStepSize"]); } function scrollDown() { window.scrollBy(0, settings["scrollStepSize"]); } function scrollPageUp() { window.scrollBy(0, -1 * window.innerHeight / 2); } function scrollPageDown() { window.scrollBy(0, window.innerHeight / 2); } function scrollFullPageUp() { window.scrollBy(0, -window.innerHeight); } function scrollFullPageDown() { window.scrollBy(0, window.innerHeight); } function scrollLeft() { window.scrollBy(-1 * settings["scrollStepSize"], 0); } function scrollRight() { window.scrollBy(settings["scrollStepSize"], 0); } function focusInput(count) { var results = document.evaluate(textInputXPath, document.documentElement, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); var lastInputBox; var i = 0; while (i < count) { i += 1; var currentInputBox = results.iterateNext(); if (!currentInputBox) { break; } lastInputBox = currentInputBox; } if (lastInputBox) { lastInputBox.focus(); } } function reload() { window.location.reload(); } function goBack() { history.back(); } function goForward() { history.forward(); } 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({}); } 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) { var keyChar = ""; if (linkHintsModeActivated) return; // 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" && isPrimaryModifierKey(event)) { enterInsertMode(); return; } if (keyChar) { if (findMode) { handleKeyCharForFindMode(keyChar); // Don't let the space scroll us if we're searching. if (event.keyCode == keyCodes.space) event.preventDefault(); } else if (!insertMode && !findMode) { if (currentCompletionKeys.indexOf(keyChar) != -1) { event.preventDefault(); event.stopPropagation(); } keyPort.postMessage({keyChar:keyChar, frameId:frameId}); } } } } function onKeydown(event) { var keyChar = ""; if (linkHintsModeActivated) return; // handle modifiers being pressed.don't handle shiftKey alone (to avoid / being interpreted as ? if (event.metaKey && event.keyCode > 31 || event.ctrlKey && event.keyCode > 31 || event.altKey && event.keyCode > 31) { keyChar = getKeyChar(event); 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 (insertMode && 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(); // Added to prevent Google Instant from reclaiming the keystroke and putting us back into the search box. // TOOD(ilya): Revisit this. Not sure it's the absolute best approach. event.stopPropagation(); } } else if (findMode) { if (isEscape(event)) exitFindMode(); // Don't let backspace take us back in history. else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { handleDeleteForFindMode(); event.preventDefault(); } else if (event.keyCode == keyCodes.enter) handleEnterForFindMode(); } else if (isShowingHelpDialog && isEscape(event)) { hideHelpDialog(); } else if (!insertMode && !findMode) { if (keyChar) { if (currentCompletionKeys.indexOf(keyChar) != -1) { event.preventDefault(); event.stopPropagation(); } keyPort.postMessage({keyChar:keyChar, frameId:frameId}); } else if (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 == "" && !insertMode && currentCompletionKeys.indexOf(getKeyChar(event)) != -1) event.stopPropagation(); } 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 insertMode on page load. HUD.hide(); }); } function refreshCompletionKeys(completionKeys) { if (completionKeys) currentCompletionKeys = completionKeys; else chrome.extension.sendRequest({handler: "getCompletionKeys"}, function (response) { currentCompletionKeys = response.completionKeys; }); } function onFocusCapturePhase(event) { if (isFocusable(event.target)) enterInsertMode(); } function onBlurCapturePhase(event) { if (isFocusable(event.target)) exitInsertMode(); } /* * 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.tagName) > 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. * Note: we used to discriminate for text-only inputs, but this is not accurate since all input fields * can be controlled via the keyboard, particuarlly SELECT combo boxes. */ function isEditable(target) { if (target.getAttribute("contentEditable") == "true") return true; var focusableInputs = ["input", "textarea", "select", "button"]; return focusableInputs.indexOf(target.tagName.toLowerCase()) >= 0; } function enterInsertMode() { insertMode = true; HUD.show("Insert mode"); } function exitInsertMode() { insertMode = false; HUD.hide(); } function handleKeyCharForFindMode(keyChar) { findModeQuery = findModeQuery + keyChar; performFindInPlace(); showFindModeHUDForQuery(); } function handleDeleteForFindMode() { if (findModeQuery.length == 0) { exitFindMode(); performFindInPlace(); } else { findModeQuery = findModeQuery.substring(0, findModeQuery.length - 1); performFindInPlace(); showFindModeHUDForQuery(); } } function handleEnterForFindMode() { exitFindMode(); performFindInPlace(); } function performFindInPlace() { var cachedScrollX = window.scrollX; var cachedScrollY = window.scrollY; // 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); // We need to restore the scroll position because we might've lost the right position by searching // backwards. window.scrollTo(cachedScrollX, cachedScrollY); performFind(); } function performFind() { findModeQueryHasResults = window.find(findModeQuery, false, false, true, false, true, false); } function performBackwardsFind() { findModeQueryHasResults = window.find(findModeQuery, false, true, true, false, true, false); } function findAndFollowLink(linkStrings) { for (i = 0; i < linkStrings.length; i++) { var findModeQueryHasResults = window.find(linkStrings[i], false, true, true, false, true, false); if (findModeQueryHasResults) { var node = window.getSelection().anchorNode; while (node.nodeName != 'BODY') { if (node.nodeName == 'A') { window.location = node.href; return true; } node = node.parentNode; } } } } 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) { window.location = elements[j].href; return true; } } } } function goPrevious() { var previousStrings = ["\bprev\b","\bprevious\b","\u00AB","<<","<"]; findAndFollowRel('prev') || findAndFollowLink(previousStrings); } function goNext() { var nextStrings = ["\bnext\b","\u00BB",">>","\bmore\b",">"]; findAndFollowRel('next') || findAndFollowLink(nextStrings); } function showFindModeHUDForQuery() { if (findModeQueryHasResults || findModeQuery.length == 0) HUD.show("/" + insertSpaces(findModeQuery)); else HUD.show("/" + insertSpaces(findModeQuery + " (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 newQuery = newQuery + query[i] + " "; } return newQuery; } function enterFindMode() { findModeQuery = ""; 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"; document.body.appendChild(container); container.innerHTML = html; // This is necessary because innerHTML does not evaluate javascript embedded in