# # 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". # window.handlerStack = new HandlerStack insertModeLock = null findMode = false findModeQuery = { rawQuery: "", matchCount: 0 } findModeQueryHasResults = false findModeAnchorNode = null isShowingHelpDialog = false keyPort = null # Users can disable Vimium on URL patterns via the settings page. The following two variables # (isEnabledForUrl and passKeys) control Vimium's enabled/disabled behaviour. # "passKeys" are keys which would normally be handled by Vimium, but are disabled on this tab, and therefore # are passed through to the underlying page. isEnabledForUrl = true passKeys = null keyQueue = null # The user's operating system. currentCompletionKeys = null validFirstKeys = null # 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. textInputXPath = (-> textInputTypes = ["text", "search", "email", "url", "number", "password"] inputElements = ["input[" + "(" + textInputTypes.map((type) -> '@type="' + type + '"').join(" or ") + "or not(@type))" + " and not(@disabled or @readonly)]", "textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"] 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. # settings = port: null values: {} loadedValues: 0 valuesToLoad: ["scrollStepSize", "linkHintCharacters", "linkHintNumbers", "filterLinkHints", "hideHud", "previousPatterns", "nextPatterns", "findModeRawQuery", "regexFindMode", "userDefinedLinkHintCss", "helpDialog_showAdvancedCommands"] isLoaded: false eventListeners: {} init: -> @port = chrome.runtime.connect({ name: "settings" }) @port.onMessage.addListener(@receiveMessage) get: (key) -> @values[key] set: (key, value) -> @init() unless @port @values[key] = value @port.postMessage({ operation: "set", key: key, value: value }) load: -> @init() unless @port for i of @valuesToLoad @port.postMessage({ operation: "get", key: @valuesToLoad[i] }) receiveMessage: (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 listener = null while (listener = settings.eventListeners["load"].pop()) listener() addEventListener: (eventName, callback) -> if (!(eventName of @eventListeners)) @eventListeners[eventName] = [] @eventListeners[eventName].push(callback) # # Give this frame a unique id. # frameId = Math.floor(Math.random()*999999999) hasModifiersRegex = /^<([amc]-)+.>/ # # Complete initialization work that sould be done prior to DOMReady. # initializePreDomReady = -> settings.addEventListener("load", LinkHints.init.bind(LinkHints)) settings.load() Scroller.init() checkIfEnabledForUrl() refreshCompletionKeys() # Send the key to the key handler in the background page. keyPort = chrome.runtime.connect({ name: "keyDown" }) requestHandlers = hideUpgradeNotification: -> HUD.hideUpgradeNotification() showUpgradeNotification: (request) -> HUD.showUpgradeNotification(request.version) showHUDforDuration: (request) -> HUD.showForDuration request.text, request.duration toggleHelpDialog: (request) -> toggleHelpDialog(request.dialogHtml, request.frameId) focusFrame: (request) -> if (frameId == request.frameId) then focusThisFrame(request.highlight) refreshCompletionKeys: refreshCompletionKeys getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY executePageCommand: executePageCommand getActiveState: -> { enabled: isEnabledForUrl, passKeys: passKeys } setState: setState currentKeyQueue: (request) -> keyQueue = request.keyQueue chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> # In the options page, we will receive requests from both content and background scripts. ignore those # from the former. return if sender.tab and not sender.tab.url.startsWith 'chrome-extension://' return unless isEnabledForUrl or request.name == 'getActiveState' or request.name == 'setState' # These requests are delivered to the options page, but there are no handlers there. return if request.handler == "registerFrame" or request.handler == "frameFocused" sendResponse requestHandlers[request.name](request, sender) # Ensure the sendResponse callback is freed. false # Wrapper to install event listeners. Syntactic sugar. installListener = (element, event, callback) -> element.addEventListener(event, -> if isEnabledForUrl then callback.apply(this, arguments) else true , true) # # Installing or uninstalling listeners is error prone. Instead we elect to check isEnabledForUrl each time so # we know whether the listener should run or not. # Run this as early as possible, so the page can't register any event handlers before us. # installedListeners = false initializeWhenEnabled = (newPassKeys) -> isEnabledForUrl = true passKeys = newPassKeys if (!installedListeners) # Key event handlers fire on window before they do on document. Prefer window for key events so the page # can't set handlers to grab the keys before us. installListener window, "keydown", onKeydown installListener window, "keypress", onKeypress installListener window, "keyup", onKeyup installListener document, "focus", onFocusCapturePhase installListener document, "blur", onBlurCapturePhase installListener document, "DOMActivate", onDOMActivate enterInsertModeIfElementIsFocused() installedListeners = true setState = (request) -> initializeWhenEnabled(request.passKeys) if request.enabled isEnabledForUrl = request.enabled passKeys = request.passKeys # # The backend needs to know which frame has focus. # window.addEventListener "focus", -> # settings may have changed since the frame last had focus settings.load() chrome.runtime.sendMessage({ handler: "frameFocused", frameId: frameId }) # # Initialization tasks that must wait for the document to be ready. # initializeOnDomReady = -> enterInsertModeIfElementIsFocused() if isEnabledForUrl # Tell the background page we're in the dom ready state. chrome.runtime.connect({ name: "domReady" }) registerFrame = -> # Don't register frameset containers; focusing them is no use. if document.body.tagName != "FRAMESET" chrome.runtime.sendMessage handler: "registerFrame" frameId: frameId # Unregister the frame if we're going to exit. unregisterFrame = -> chrome.runtime.sendMessage handler: "unregisterFrame" frameId: frameId tab_is_closing: window.top == window.self # # Enters insert mode if the currently focused element in the DOM is focusable. # enterInsertModeIfElementIsFocused = -> if (document.activeElement && isEditable(document.activeElement) && !findMode) enterInsertModeWithoutShowingIndicator(document.activeElement) onDOMActivate = (event) -> handlerStack.bubbleEvent 'DOMActivate', event executePageCommand = (request) -> return unless frameId == request.frameId if (request.passCountToFunction) Utils.invokeCommandString(request.command, [request.count]) else Utils.invokeCommandString(request.command) for i in [0...request.count] refreshCompletionKeys(request) setScrollPosition = (scrollX, scrollY) -> if (scrollX > 0 || scrollY > 0) DomUtils.documentReady(-> window.scrollTo(scrollX, scrollY)) # # Called from the backend in order to change frame focus. # window.focusThisFrame = (shouldHighlight) -> window.focus() if (document.body && shouldHighlight) borderWas = document.body.style.border document.body.style.border = '5px solid yellow' setTimeout((-> document.body.style.border = borderWas), 200) extend window, scrollToBottom: -> Scroller.scrollTo "y", "max" scrollToTop: -> Scroller.scrollTo "y", 0 scrollToLeft: -> Scroller.scrollTo "x", 0 scrollToRight: -> Scroller.scrollTo "x", "max" scrollUp: -> Scroller.scrollBy "y", -1 * settings.get("scrollStepSize") scrollDown: -> Scroller.scrollBy "y", settings.get("scrollStepSize") scrollPageUp: -> Scroller.scrollBy "y", "viewSize", -1/2 scrollPageDown: -> Scroller.scrollBy "y", "viewSize", 1/2 scrollFullPageUp: -> Scroller.scrollBy "y", "viewSize", -1 scrollFullPageDown: -> Scroller.scrollBy "y", "viewSize" scrollLeft: -> Scroller.scrollBy "x", -1 * settings.get("scrollStepSize") scrollRight: -> Scroller.scrollBy "x", settings.get("scrollStepSize") extend window, reload: -> window.location.reload() goBack: (count) -> history.go(-count) goForward: (count) -> history.go(count) goUp: (count) -> url = window.location.href if (url[url.length - 1] == "/") url = url.substring(0, url.length - 1) 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('/') goToRoot: () -> window.location.href = window.location.origin toggleViewSource: -> chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> if (url.substr(0, 12) == "view-source:") url = url.substr(12, url.length - 12) else url = "view-source:" + url chrome.runtime.sendMessage({ handler: "openUrlInNewTab", url: url, selected: true }) 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 chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> chrome.runtime.sendMessage { handler: "copyToClipboard", data: url } HUD.showForDuration("Yanked URL", 1000) focusInput: (count) -> # Focus the first input element on the page, and create overlays to highlight all the input elements, with # the currently-focused element highlighted specially. Tabbing will shift focus to the next input element. # Pressing any other key will remove the overlays and the special tab behavior. resultSet = DomUtils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) visibleInputs = for i in [0...resultSet.snapshotLength] by 1 element = resultSet.snapshotItem(i) rect = DomUtils.getVisibleClientRect(element) continue if rect == null { element: element, rect: rect } return if visibleInputs.length == 0 selectedInputIndex = Math.min(count - 1, visibleInputs.length - 1) visibleInputs[selectedInputIndex].element.focus() return if visibleInputs.length == 1 hints = for tuple in visibleInputs hint = document.createElement("div") hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint" # minus 1 for the border hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px" hint.style.top = (tuple.rect.top - 1) + window.scrollY + "px" hint.style.width = tuple.rect.width + "px" hint.style.height = tuple.rect.height + "px" hint hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' hintContainingDiv = DomUtils.addElementList(hints, { id: "vimiumInputMarkerContainer", className: "vimiumReset" }) handlerStack.push keydown: (event) -> if event.keyCode == KeyboardUtils.keyCodes.tab hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint' if event.shiftKey if --selectedInputIndex == -1 selectedInputIndex = hints.length - 1 else if ++selectedInputIndex == hints.length selectedInputIndex = 0 hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' visibleInputs[selectedInputIndex].element.focus() else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey DomUtils.removeElement hintContainingDiv @remove() return true false # Decide whether this keyChar should be passed to the underlying page. # Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a # passKey, then 'gt' and '99t' will neverthless be handled by vimium. isPassKey = ( keyChar ) -> return !keyQueue and passKeys and 0 <= passKeys.indexOf(keyChar) handledKeydownEvents = [] # # 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. # onKeypress = (event) -> return unless handlerStack.bubbleEvent('keypress', event) 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) DomUtils.suppressEvent(event) else if (!isInsertMode() && !findMode) if (isPassKey keyChar) return undefined if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) DomUtils.suppressEvent(event) keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) onKeydown = (event) -> return unless handlerStack.bubbleEvent('keydown', event) 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) || ( # TODO(philc): some events don't have a keyidentifier. How is that possible? event.keyIdentifier && event.keyIdentifier.slice(0, 2) != "U+")) keyChar = KeyboardUtils.getKeyChar(event) # Again, ignore just modifiers. Maybe this should replace the keyCode>31 condition. if (keyChar != "") 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 i of 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() DomUtils.suppressEvent event handledKeydownEvents.push event else if (findMode) if (KeyboardUtils.isEscape(event)) handleEscapeForFindMode() DomUtils.suppressEvent event handledKeydownEvents.push event else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) handleDeleteForFindMode() DomUtils.suppressEvent event handledKeydownEvents.push event else if (event.keyCode == keyCodes.enter) handleEnterForFindMode() DomUtils.suppressEvent event handledKeydownEvents.push event else if (!modifiers) DomUtils.suppressPropagation(event) handledKeydownEvents.push event else if (isShowingHelpDialog && KeyboardUtils.isEscape(event)) hideHelpDialog() DomUtils.suppressEvent event handledKeydownEvents.push event else if (!isInsertMode() && !findMode) if (keyChar) if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) DomUtils.suppressEvent event handledKeydownEvents.push event keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) else if (KeyboardUtils.isEscape(event)) keyPort.postMessage({ keyChar:"", frameId:frameId }) else if isPassKey KeyboardUtils.getKeyChar(event) return undefined # 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)))) DomUtils.suppressPropagation(event) handledKeydownEvents.push event onKeyup = (event) -> return unless handlerStack.bubbleEvent("keyup", event) return if isInsertMode() # Don't propagate the keyup to the underlying page if Vimium has handled it. See #733. for keydown, i in handledKeydownEvents if event.metaKey == keydown.metaKey and event.altKey == keydown.altKey and event.ctrlKey == keydown.ctrlKey and event.keyIdentifier == keydown.keyIdentifier and event.keyCode == keydown.keyCode handledKeydownEvents.splice i, 1 DomUtils.suppressPropagation(event) break checkIfEnabledForUrl = -> url = window.location.toString() chrome.runtime.sendMessage { handler: "isEnabledForUrl", url: url }, (response) -> isEnabledForUrl = response.isEnabledForUrl if (isEnabledForUrl) initializeWhenEnabled(response.passKeys) 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() refreshCompletionKeys = (response) -> if (response) currentCompletionKeys = response.completionKeys if (response.validFirstKeys) validFirstKeys = response.validFirstKeys else chrome.runtime.sendMessage({ handler: "getCompletionKeys" }, refreshCompletionKeys) isValidFirstKey = (keyChar) -> validFirstKeys[keyChar] || /^[1-9]/.test(keyChar) onFocusCapturePhase = (event) -> if (isFocusable(event.target) && !findMode) enterInsertModeWithoutShowingIndicator(event.target) 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. # isFocusable = (element) -> isEditable(element) || isEmbed(element) # # Embedded elements like Flash and quicktime players can obtain focus but cannot be programmatically # unfocused. # isEmbed = (element) -> ["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. # isEditable = (target) -> # Note: document.activeElement.isContentEditable is also rechecked in isInsertMode() dynamically. return true if target.isContentEditable nodeName = target.nodeName.toLowerCase() # use a blacklist instead of a whitelist because new form controls are still being implemented for html5 noFocus = ["radio", "checkbox"] if (nodeName == "input" && noFocus.indexOf(target.type) == -1) return true focusableElements = ["textarea", "select"] 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) # window.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 . # Note. This returns the truthiness of target, which is required by isInsertMode. # enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target exitInsertMode = (target) -> if (target == undefined || insertModeLock == target) insertModeLock = null HUD.hide() isInsertMode = -> return true if insertModeLock != null # Some sites (e.g. inbox.google.com) change the contentEditable attribute on the fly (see #1245); and # unfortunately, isEditable() is called *before* the change is made. Therefore, we need to re-check whether # the active element is contentEditable. document.activeElement and document.activeElement.isContentEditable and enterInsertModeWithoutShowingIndicator document.activeElement # should be called whenever rawQuery is modified. 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 = settings.get 'regexFindMode' hasNoIgnoreCaseFlag = false findModeQuery.parsedQuery = findModeQuery.rawQuery.replace /\\./g, (match) -> switch (match) when "\\r" findModeQuery.isRegex = true return "" when "\\R" findModeQuery.isRegex = false return "" when "\\I" hasNoIgnoreCaseFlag = true return "" when "\\\\" return "\\" else return match # default to 'smartcase' mode, unless noIgnoreCase is explicitly specified findModeQuery.ignoreCase = !hasNoIgnoreCaseFlag && !Utils.hasUpperCase(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 pattern = new RegExp(findModeQuery.parsedQuery, "g" + (if findModeQuery.ignoreCase then "i" else "")) catch error # 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 text = document.body.innerText findModeQuery.regexMatches = text.match(pattern) findModeQuery.activeRegexIndex = 0 findModeQuery.matchCount = findModeQuery.regexMatches?.length # if we are doing a basic plain string match, we still want to grep for matches of the string, so we can # show a the number of results. We can grep on document.body.innerText, as it should be indistinguishable # from the internal representation used by window.find. else # escape all special characters, so RegExp just parses the string 'as is'. # Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex escapeRegExp = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g parsedNonRegexQuery = findModeQuery.parsedQuery.replace(escapeRegExp, (char) -> "\\" + char) pattern = new RegExp(parsedNonRegexQuery, "g" + (if findModeQuery.ignoreCase then "i" else "")) text = document.body.innerText findModeQuery.matchCount = text.match(pattern)?.length handleKeyCharForFindMode = (keyChar) -> findModeQuery.rawQuery += keyChar updateFindModeQuery() performFindInPlace() showFindModeHUDForQuery() 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. selection = window.getSelection() unless selection.isCollapsed range = window.getSelection().getRangeAt(0) window.getSelection().removeAllRanges() window.getSelection().addRange(range) focusFoundLink() || selectFoundInputElement() 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' handleEnterForFindMode = -> exitFindMode() focusFoundLink() document.body.classList.add("vimiumFindMode") settings.set("findModeRawQuery", findModeQuery.rawQuery) performFindInPlace = -> cachedScrollX = window.scrollX cachedScrollY = window.scrollY query = if findModeQuery.isRegex then getNextQueryFromRegexMatches(0) else 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'. 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. 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) result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false) setTimeout( -> 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 result restoreDefaultSelectionHighlight = -> document.body.classList.remove("vimiumFindMode") focusFoundLink = -> if (findModeQueryHasResults) link = getLinkFromSelection() link.focus() if link isDOMDescendant = (parent, child) -> node = child while (node != null) return true if (node == parent) node = node.parentNode false 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 && document.activeElement && 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) getNextQueryFromRegexMatches = (stepSize) -> # find()ing an empty query always returns false return "" unless findModeQuery.regexMatches totalMatches = findModeQuery.regexMatches.length findModeQuery.activeRegexIndex += stepSize + totalMatches findModeQuery.activeRegexIndex %= totalMatches findModeQuery.regexMatches[findModeQuery.activeRegexIndex] findAndFocus = (backwards) -> # check if the query has been changed by a script in another frame mostRecentQuery = settings.get("findModeRawQuery") || "" if (mostRecentQuery != findModeQuery.rawQuery) findModeQuery.rawQuery = mostRecentQuery updateFindModeQuery() query = if findModeQuery.isRegex getNextQueryFromRegexMatches(if backwards then -1 else 1) else 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 elementCanTakeInput = document.activeElement && DomUtils.isSelectable(document.activeElement) && isDOMDescendant(findModeAnchorNode, document.activeElement) if (elementCanTakeInput) handlerStack.push({ keydown: (event) -> @remove() 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() window.performFind = -> findAndFocus() window.performBackwardsFind = -> findAndFocus(true) getLinkFromSelection = -> node = window.getSelection().anchorNode while (node && node != document.body) return node if (node.nodeName.toLowerCase() == "a") node = node.parentNode null # used by the findAndFollow* functions. 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. # findAndFollowLink = (linkStrings) -> linksXPath = DomUtils.makeXPath(["a", "*[@onclick or @role='link' or contains(@class, 'button')]"]) links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) 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 i in [(links.snapshotLength - 1)..0] by -1 link = links.snapshotItem(i) # ensure link is visible (we don't mind if it is scrolled offscreen) boundingClientRect = link.getBoundingClientRect() if (boundingClientRect.width == 0 || boundingClientRect.height == 0) continue computedStyle = window.getComputedStyle(link, null) if (computedStyle.getPropertyValue("visibility") != "visible" || computedStyle.getPropertyValue("display") == "none") continue linkMatches = false for linkString in linkStrings if (link.innerText.toLowerCase().indexOf(linkString) != -1) linkMatches = true break continue unless linkMatches candidateLinks.push(link) return if (candidateLinks.length == 0) for link in candidateLinks link.wordCount = 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((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((a, b) -> if (a.wordCount == b.wordCount) then a.originalIndex - b.originalIndex else a.wordCount - b.wordCount ) .filter((a) -> a.wordCount <= candidateLinks[0].wordCount + 1) for linkString in linkStrings exactWordRegex = if /\b/.test(linkString[0]) or /\b/.test(linkString[linkString.length - 1]) new RegExp "\\b" + linkString + "\\b", "i" else new RegExp linkString, "i" for candidateLink in candidateLinks if (exactWordRegex.test(candidateLink.innerText)) followLink(candidateLink) return true false findAndFollowRel = (value) -> relTags = ["link", "a", "area"] for tag in relTags elements = document.getElementsByTagName(tag) for element in elements if (element.hasAttribute("rel") && element.rel.toLowerCase() == value) followLink(element) return true window.goPrevious = -> previousPatterns = settings.get("previousPatterns") || "" previousStrings = previousPatterns.split(",").filter( (s) -> s.trim().length ) findAndFollowRel("prev") || findAndFollowLink(previousStrings) window.goNext = -> nextPatterns = settings.get("nextPatterns") || "" nextStrings = nextPatterns.split(",").filter( (s) -> s.trim().length ) findAndFollowRel("next") || findAndFollowLink(nextStrings) showFindModeHUDForQuery = -> if (findModeQueryHasResults || findModeQuery.parsedQuery.length == 0) HUD.show("/" + findModeQuery.rawQuery + " (" + findModeQuery.matchCount + " Matches)") else HUD.show("/" + findModeQuery.rawQuery + " (No Matches)") window.enterFindMode = -> findModeQuery = { rawQuery: "" } findMode = true HUD.show("/") exitFindMode = -> findMode = false HUD.hide() window.showHelpDialog = (html, fid) -> return if (isShowingHelpDialog || !document.body || fid != frameId) isShowingHelpDialog = true 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) VimiumHelpDialog = # This setting is pulled out of local storage. It's false by default. getShowAdvancedCommands: -> settings.get("helpDialog_showAdvancedCommands") init: () -> this.dialogElement = document.getElementById("vimiumHelpDialog") this.dialogElement.getElementsByClassName("toggleAdvancedCommands")[0].addEventListener("click", VimiumHelpDialog.toggleAdvancedCommands, false) this.dialogElement.style.maxHeight = window.innerHeight - 80 this.showAdvancedCommands(this.getShowAdvancedCommands()) # # Advanced commands are hidden by default so they don't overwhelm new and casual users. # toggleAdvancedCommands: (event) -> event.preventDefault() showAdvanced = VimiumHelpDialog.getShowAdvancedCommands() VimiumHelpDialog.showAdvancedCommands(!showAdvanced) settings.set("helpDialog_showAdvancedCommands", !showAdvanced) showAdvancedCommands: (visible) -> VimiumHelpDialog.dialogElement.getElementsByClassName("toggleAdvancedCommands")[0].innerHTML = if visible then "Hide advanced commands" else "Show advanced commands" advancedEls = VimiumHelpDialog.dialogElement.getElementsByClassName("advanced") for el in advancedEls el.style.display = if visible then "table-row" else "none" VimiumHelpDialog.init() container.getElementsByClassName("optionsPage")[0].addEventListener("click", (clickEvent) -> clickEvent.preventDefault() chrome.runtime.sendMessage({handler: "openOptionsPageInNewTab"}) false) hideHelpDialog = (clickEvent) -> isShowingHelpDialog = false helpDialog = document.getElementById("vimiumHelpDialogContainer") if (helpDialog) helpDialog.parentNode.removeChild(helpDialog) if (clickEvent) clickEvent.preventDefault() toggleHelpDialog = (html, fid) -> if (isShowingHelpDialog) hideHelpDialog() else showHelpDialog(html, fid) # # A heads-up-display (HUD) for showing Vimium page operations. # Note: you cannot interact with the HUD until document.body is available. # HUD = _tweenId: -1 _displayElement: null _upgradeNotificationElement: null # This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html" # test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that # it doesn't sit on top of horizontal scrollbars like Chrome's HUD does. showForDuration: (text, duration) -> HUD.show(text) HUD._showForDurationTimerId = setTimeout((-> HUD.hide()), duration) show: (text) -> return unless HUD.enabled() clearTimeout(HUD._showForDurationTimerId) HUD.displayElement().innerText = text clearInterval(HUD._tweenId) HUD._tweenId = Tween.fade(HUD.displayElement(), 1.0, 150) HUD.displayElement().style.display = "" showUpgradeNotification: (version) -> HUD.upgradeNotificationElement().innerHTML = "Vimium has been updated to #{version}.×" links = HUD.upgradeNotificationElement().getElementsByTagName("a") links[0].addEventListener("click", HUD.onUpdateLinkClicked, false) links[1].addEventListener "click", (event) -> event.preventDefault() HUD.onUpdateLinkClicked() Tween.fade(HUD.upgradeNotificationElement(), 1.0, 150) onUpdateLinkClicked: (event) -> HUD.hideUpgradeNotification() chrome.runtime.sendMessage({ handler: "upgradeNotificationClosed" }) hideUpgradeNotification: (clickEvent) -> Tween.fade(HUD.upgradeNotificationElement(), 0, 150, -> HUD.upgradeNotificationElement().style.display = "none") # # Retrieves the HUD HTML element. # displayElement: -> if (!HUD._displayElement) HUD._displayElement = HUD.createHudElement() # Keep this far enough to the right so that it doesn't collide with the "popups blocked" chrome HUD. HUD._displayElement.style.right = "150px" HUD._displayElement upgradeNotificationElement: -> if (!HUD._upgradeNotificationElement) HUD._upgradeNotificationElement = HUD.createHudElement() # Position this just to the left of our normal HUD. HUD._upgradeNotificationElement.style.right = "315px" HUD._upgradeNotificationElement createHudElement: -> element = document.createElement("div") element.className = "vimiumReset vimiumHUD" document.body.appendChild(element) element hide: (immediate) -> clearInterval(HUD._tweenId) if (immediate) HUD.displayElement().style.display = "none" else HUD._tweenId = Tween.fade(HUD.displayElement(), 0, 150, -> HUD.displayElement().style.display = "none") isReady: -> document.body != null # A preference which can be toggled in the Options page. */ enabled: -> !settings.get("hideHud") Tween = # # Fades an element's alpha. Returns a timer ID which can be used to stop the tween via clearInterval. # fade: (element, toAlpha, duration, onComplete) -> state = {} state.duration = duration state.startTime = (new Date()).getTime() state.from = parseInt(element.style.opacity) || 0 state.to = toAlpha state.onUpdate = (value) -> element.style.opacity = value if (value == state.to && onComplete) onComplete() state.timerId = setInterval((-> Tween.performTweenStep(state)), 50) state.timerId performTweenStep: (state) -> elapsed = (new Date()).getTime() - state.startTime if (elapsed >= state.duration) clearInterval(state.timerId) state.onUpdate(state.to) else value = (elapsed / state.duration) * (state.to - state.from) + state.from state.onUpdate(value) initializePreDomReady() window.addEventListener("DOMContentLoaded", registerFrame) window.addEventListener("unload", unregisterFrame) window.addEventListener("DOMContentLoaded", initializeOnDomReady) window.onbeforeunload = -> chrome.runtime.sendMessage( handler: "updateScrollPosition" scrollX: window.scrollX scrollY: window.scrollY) root = exports ? window root.settings = settings root.HUD = HUD root.handlerStack = handlerStack root.frameId = frameId