diff options
Diffstat (limited to 'content_scripts/link_hints.coffee')
| -rw-r--r-- | content_scripts/link_hints.coffee | 496 |
1 files changed, 496 insertions, 0 deletions
diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee new file mode 100644 index 00000000..e454a1b6 --- /dev/null +++ b/content_scripts/link_hints.coffee @@ -0,0 +1,496 @@ +# +# This implements link hinting. Typing "F" will enter link-hinting mode, where all clickable items on the +# page have a hint marker displayed containing a sequence of letters. Typing those letters will select a link. +# +# In our 'default' mode, the characters we use to show link hints are a user-configurable option. By default +# they're the home row. The CSS which is used on the link hints is also a configurable option. +# +# In 'filter' mode, our link hints are numbers, and the user can narrow down the range of possibilities by +# typing the text of the link itself. +# +LinkHints = + hintMarkers: [] + hintMarkerContainingDiv: null + shouldOpenInNewTab: false + shouldOpenWithQueue: false + # function that does the appropriate action on the selected link + linkActivator: undefined + # While in delayMode, all keypresses have no effect. + delayMode: false + # Handle the link hinting marker generation and matching. Must be initialized after settings have been + # loaded, so that we can retrieve the option setting. + markerMatcher: undefined + + # + # To be called after linkHints has been generated from linkHintsBase. + # + init: -> + @onKeyDownInMode = @onKeyDownInMode.bind(this) + @markerMatcher = if settings.get("filterLinkHints") then filterHints else alphabetHints + + # + # Generate an XPath describing what a clickable element is. + # The final expression will be something like "//button | //xhtml:button | ..." + # We use translate() instead of lower-case() because Chrome only supports XPath 1.0. + # + clickableElementsXPath: DomUtils.makeXPath( + ["a", "area[@href]", "textarea", "button", "select", + "input[not(@type='hidden' or @disabled or @readonly)]", + "*[@onclick or @tabindex or @role='link' or @role='button' or contains(@class, 'button') or " + + "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]) + + # We need this as a top-level function because our command system doesn't yet support arguments. + activateModeToOpenInNewTab: -> @activateMode(true, false, false) + activateModeToCopyLinkUrl: -> @activateMode(null, false, true) + activateModeWithQueue: -> @activateMode(true, true, false) + + activateMode: (openInNewTab, withQueue, copyLinkUrl) -> + if (!document.getElementById("vimiumLinkHintCss")) + # linkHintCss is declared by vimiumFrontend.js and contains the user supplied css overrides. + addCssToPage(linkHintCss, "vimiumLinkHintCss") + @setOpenLinkMode(openInNewTab, withQueue, copyLinkUrl) + @buildLinkHints() + # handlerStack is declared by vimiumFrontend.js + handlerStack.push({ + keydown: @onKeyDownInMode, + # trap all key events + keypress: -> false + keyup: -> false + }) + + setOpenLinkMode: (openInNewTab, withQueue, copyLinkUrl) -> + @shouldOpenInNewTab = openInNewTab + @shouldOpenWithQueue = withQueue + + if (openInNewTab || withQueue) + if (openInNewTab) + HUD.show("Open link in new tab") + else if (withQueue) + HUD.show("Open multiple links in a new tab") + @linkActivator = (link) -> + # When "clicking" on a link, dispatch the event with the appropriate meta key (CMD on Mac, CTRL on windows) + # to open it in a new tab if necessary. + DomUtils.simulateClick(link, { + metaKey: KeyboardUtils.platform == "Mac", + ctrlKey: KeyboardUtils.platform != "Mac" }) + else if (copyLinkUrl) + HUD.show("Copy link URL to Clipboard") + @linkActivator = (link) -> + chrome.extension.sendRequest({handler: "copyToClipboard", data: link.href}) + else + HUD.show("Open link in current tab") + # When we're opening the link in the current tab, don't navigate to the selected link immediately + # we want to give the user some time to notice which link has received focus. + @linkActivator = (link) -> setTimeout(DomUtils.simulateClick.bind(DomUtils, link), 400) + + # + # Builds and displays link hints for every visible clickable item on the page. + # + buildLinkHints: -> + visibleElements = @getVisibleClickableElements() + @hintMarkers = @markerMatcher.getHintMarkers(visibleElements) + + # Note(philc): Append these markers as top level children instead of as child nodes to the link itself, + # because some clickable elements cannot contain children, e.g. submit buttons. This has the caveat + # that if you scroll the page and the link has position=fixed, the marker will not stay fixed. + # Also note that adding these nodes to document.body all at once is significantly faster than one-by-one. + @hintMarkerContainingDiv = document.createElement("div") + @hintMarkerContainingDiv.id = "vimiumHintMarkerContainer" + @hintMarkerContainingDiv.className = "vimiumReset" + @hintMarkerContainingDiv.appendChild(marker) for marker in @hintMarkers + + # sometimes this is triggered before documentElement is created + # TODO(int3): fail more gracefully? + if (document.documentElement) + document.documentElement.appendChild(@hintMarkerContainingDiv) + else + @deactivateMode() + + # + # Returns all clickable elements that are not hidden and are in the current viewport. + # We prune invisible elements partly for performance reasons, but moreso it's to decrease the number + # of digits needed to enumerate all of the links on screen. + # + getVisibleClickableElements: -> + resultSet = DomUtils.evaluateXPath(@clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) + + visibleElements = [] + + # Find all visible clickable elements. + for i in [0...resultSet.snapshotLength] + # for (i = 0, count = resultSet.snapshotLength; i < count; i++) { + element = resultSet.snapshotItem(i) + clientRect = DomUtils.getVisibleClientRect(element, clientRect) + if (clientRect != null) + visibleElements.push({element: element, rect: clientRect}) + + if (element.localName == "area") + map = element.parentElement + continue unless map + img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']") + continue unless img + imgClientRects = img.getClientRects() + continue if (imgClientRects.length == 0) + c = element.coords.split(/,/) + coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)] + rect = { + top: imgClientRects[0].top + coords[1], + left: imgClientRects[0].left + coords[0], + right: imgClientRects[0].left + coords[2], + bottom: imgClientRects[0].top + coords[3], + width: coords[2] - coords[0], + height: coords[3] - coords[1] + } + + visibleElements.push({element: element, rect: rect}) + + visibleElements + + # + # Handles shift and esc keys. The other keys are passed to markerMatcher.matchHintsByKey. + # + onKeyDownInMode: (event) -> + return if @delayMode + + if (event.keyCode == keyCodes.shiftKey && @shouldOpenInNewTab != null) + # Toggle whether to open link in a new or current tab. + @setOpenLinkMode(!@shouldOpenInNewTab, @shouldOpenWithQueue, false) + handlerStack.push({ + keyup: (event) -> + return if (event.keyCode != keyCodes.shiftKey) + LinkHints.setOpenLinkMode(!LinkHints.shouldOpenInNewTab, LinkHints.shouldOpenWithQueue, false) + handlerStack.pop() + }) + + # TODO(philc): Ignore keys that have modifiers. + if (KeyboardUtils.isEscape(event)) + @deactivateMode() + else + keyResult = @markerMatcher.matchHintsByKey(event, @hintMarkers) + linksMatched = keyResult.linksMatched + delay = (if keyResult.delay? then keyResult.delay else 0) + if (linksMatched.length == 0) + @deactivateMode() + else if (linksMatched.length == 1) + @activateLink(linksMatched[0], delay) + else + for i of @hintMarkers + @hideMarker(@hintMarkers[i]) + for i of linksMatched + @showMarker(linksMatched[i], @markerMatcher.hintKeystrokeQueue.length) + + # + # When only one link hint remains, this function activates it in the appropriate way. + # + activateLink: (matchedLink, delay) -> + @delayMode = true + clickEl = matchedLink.clickableItem + if (DomUtils.isSelectable(clickEl)) + DomUtils.simulateSelect(clickEl) + @deactivateMode(delay, -> LinkHints.delayMode = false) + else + # TODO figure out which other input elements should not receive focus + if (clickEl.nodeName.toLowerCase() == "input" && clickEl.type != "button") + clickEl.focus() + DomUtils.flashRect(matchedLink.rect) + @linkActivator(clickEl) + if (@shouldOpenWithQueue) + @deactivateMode delay, -> + LinkHints.delayMode = false + LinkHints.activateModeWithQueue() + else + @deactivateMode(delay, -> LinkHints.delayMode = false) + + # + # Shows the marker, highlighting matchingCharCount characters. + # + showMarker: (linkMarker, matchingCharCount) -> + linkMarker.style.display = "" + # TODO(philc): + for j in [0...linkMarker.childNodes.length] + if (j < matchingCharCount) + linkMarker.childNodes[j].classList.add("matchingCharacter") + else + linkMarker.childNodes[j].classList.remove("matchingCharacter") + + hideMarker: (linkMarker) -> linkMarker.style.display = "none" + + # + # If called without arguments, it executes immediately. Othewise, it + # executes after 'delay' and invokes 'callback' when it is finished. + # + deactivateMode: (delay, callback) -> + deactivate = -> + if (LinkHints.markerMatcher.deactivate) + LinkHints.markerMatcher.deactivate() + if (LinkHints.hintMarkerContainingDiv) + LinkHints.hintMarkerContainingDiv.parentNode.removeChild(LinkHints.hintMarkerContainingDiv) + LinkHints.hintMarkerContainingDiv = null + LinkHints.hintMarkers = [] + handlerStack.pop() + HUD.hide() + + # we invoke the deactivate() function directly instead of using setTimeout(callback, 0) so that + # deactivateMode can be tested synchronously + if (!delay) + deactivate() + callback() if (callback) + else + setTimeout(-> + deactivate() + callback() if callback + delay) + +alphabetHints = + hintKeystrokeQueue: [] + logXOfBase: (x, base) -> Math.log(x) / Math.log(base) + + getHintMarkers: (visibleElements) -> + hintStrings = @hintStrings(visibleElements.length) + hintMarkers = [] + for i in [0...visibleElements.length] + marker = hintUtils.createMarkerFor(visibleElements[i]) + marker.hintString = hintStrings[i] + marker.innerHTML = hintUtils.spanWrap(marker.hintString.toUpperCase()) + hintMarkers.push(marker) + + hintMarkers + + # + # Returns a list of hint strings which will uniquely identify the given number of links. The hint strings + # may be of different lengths. + # + hintStrings: (linkCount) -> + linkHintCharacters = settings.get("linkHintCharacters") + # Determine how many digits the link hints will require in the worst case. Usually we do not need + # all of these digits for every link single hint, so we can show shorter hints for a few of the links. + digitsNeeded = Math.ceil(@logXOfBase(linkCount, linkHintCharacters.length)) + # Short hints are the number of hints we can possibly show which are (digitsNeeded - 1) digits in length. + shortHintCount = Math.floor( + (Math.pow(linkHintCharacters.length, digitsNeeded) - linkCount) / + linkHintCharacters.length) + longHintCount = linkCount - shortHintCount + + hintStrings = [] + + if (digitsNeeded > 1) + for i in [0...shortHintCount] + hintStrings.push(@numberToHintString(i, digitsNeeded - 1, linkHintCharacters)) + + start = shortHintCount * linkHintCharacters.length + for i in [start...(start + longHintCount)] + hintStrings.push(@numberToHintString(i, digitsNeeded, linkHintCharacters)) + + @shuffleHints(hintStrings, linkHintCharacters.length) + + # + # This shuffles the given set of hints so that they're scattered -- hints starting with the same character + # will be spread evenly throughout the array. + # + shuffleHints: (hints, characterSetLength) -> + buckets = [] + buckets[i] = [] for i in [0...characterSetLength] + for i in [0...hints.length] + buckets[i % buckets.length].push(hints[i]) + result = [] + for i in [0...buckets.length] + result = result.concat(buckets[i]) + result + + # + # Converts a number like "8" into a hint string like "JK". This is used to sequentially generate all of + # the hint text. The hint string will be "padded with zeroes" to ensure its length is equal to numHintDigits. + # + numberToHintString: (number, numHintDigits, characterSet) -> + base = characterSet.length + hintString = [] + remainder = 0 + loop + remainder = number % base + hintString.unshift(characterSet[remainder]) + number -= remainder + number /= Math.floor(base) + break unless number > 0 + + # Pad the hint string we're returning so that it matches numHintDigits. + # Note: the loop body changes hintString.length, so the original length must be cached! + hintStringLength = hintString.length + for i in [0...(numHintDigits - hintStringLength)] + hintString.unshift(characterSet[0]) + + hintString.join("") + + matchHintsByKey: (event, hintMarkers) -> + # If a shifted-character is typed, treat it as lowerase for the purposes of matching hints. + keyChar = KeyboardUtils.getKeyChar(event).toLowerCase() + + if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) + if (!@hintKeystrokeQueue.pop()) + return { linksMatched: [] } + else if (keyChar && settings.get("linkHintCharacters").indexOf(keyChar) >= 0) + @hintKeystrokeQueue.push(keyChar) + + matchString = @hintKeystrokeQueue.join("") + linksMatched = hintMarkers.filter((linkMarker) -> linkMarker.hintString.indexOf(matchString) == 0) + { linksMatched: linksMatched } + + deactivate: -> @hintKeystrokeQueue = [] + +filterHints = + hintKeystrokeQueue: [] + linkTextKeystrokeQueue: [] + labelMap: {} + + # + # Generate a map of input element => label + # + generateLabelMap: -> + labels = document.querySelectorAll("label") + for i in [0...labels.length] + forElement = labels[i].getAttribute("for") + if (forElement) + labelText = labels[i].textContent.trim() + # remove trailing : commonly found in labels + if (labelText[labelText.length-1] == ":") + labelText = labelText.substr(0, labelText.length-1) + @labelMap[forElement] = labelText + + generateHintString: (linkHintNumber) -> (linkHintNumber + 1).toString() + + generateLinkText: (element) -> + linkText = "" + showLinkText = false + # toLowerCase is necessary as html documents return "IMG" and xhtml documents return "img" + nodeName = element.nodeName.toLowerCase() + + if (nodeName == "input") + if (@labelMap[element.id]) + linkText = @labelMap[element.id] + showLinkText = true + else if (element.type != "password") + linkText = element.value + # check if there is an image embedded in the <a> tag + else if (nodeName == "a" && !element.textContent.trim() && + element.firstElementChild && + element.firstElementChild.nodeName.toLowerCase() == "img") + linkText = element.firstElementChild.alt || element.firstElementChild.title + showLinkText = true if (linkText) + else + linkText = element.textContent || element.innerHTML + + { text: linkText, show: showLinkText } + + renderMarker: (marker) -> + marker.innerHTML = hintUtils.spanWrap(marker.hintString + + (if marker.showLinkText then ": " + marker.linkText else "")) + + getHintMarkers: (visibleElements) -> + @generateLabelMap() + hintMarkers = [] + for i in [0...visibleElements.length] + marker = hintUtils.createMarkerFor(visibleElements[i]) + marker.hintString = @generateHintString(i) + linkTextObject = @generateLinkText(marker.clickableItem) + marker.linkText = linkTextObject.text + marker.showLinkText = linkTextObject.show + @renderMarker(marker) + hintMarkers.push(marker) + + hintMarkers + + matchHintsByKey: (event, hintMarkers) -> + keyChar = KeyboardUtils.getKeyChar(event) + delay = 0 + userIsTypingLinkText = false + + if (event.keyCode == keyCodes.enter) + # activate the lowest-numbered link hint that is visible + for i in [0...hintMarkers.length] + if (hintMarkers[i].style.display != "none") + return { linksMatched: [ hintMarkers[i] ] } + else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) + # backspace clears hint key queue first, then acts on link text key queue. + # if both queues are empty. exit hinting mode + if (!@hintKeystrokeQueue.pop() && !@linkTextKeystrokeQueue.pop()) + return { linksMatched: [] } + else if (keyChar) + if (/[0-9]/.test(keyChar)) + @hintKeystrokeQueue.push(keyChar) + else + # since we might renumber the hints, the current hintKeyStrokeQueue + # should be rendered invalid (i.e. reset). + @hintKeystrokeQueue = [] + @linkTextKeystrokeQueue.push(keyChar) + userIsTypingLinkText = true + + # at this point, linkTextKeystrokeQueue and hintKeystrokeQueue have been updated to reflect the latest + # input. use them to filter the link hints accordingly. + linksMatched = @filterLinkHints(hintMarkers) + matchString = @hintKeystrokeQueue.join("") + linksMatched = linksMatched.filter((linkMarker) -> + !linkMarker.filtered && linkMarker.hintString.indexOf(matchString) == 0) + + if (linksMatched.length == 1 && userIsTypingLinkText) + # In filter mode, people tend to type out words past the point + # needed for a unique match. Hence we should avoid passing + # control back to command mode immediately after a match is found. + delay = 200 + + { linksMatched: linksMatched, delay: delay } + + # + # Marks the links that do not match the linkText search string with the 'filtered' DOM property. Renumbers + # the remainder if necessary. + # + filterLinkHints: (hintMarkers) -> + linksMatched = [] + linkSearchString = @linkTextKeystrokeQueue.join("") + + for i in [0...hintMarkers.length] + linkMarker = hintMarkers[i] + matchedLink = linkMarker.linkText.toLowerCase().indexOf(linkSearchString.toLowerCase()) >= 0 + + if (!matchedLink) + linkMarker.filtered = true + else + linkMarker.filtered = false + oldHintString = linkMarker.hintString + linkMarker.hintString = @generateHintString(linksMatched.length) + @renderMarker(linkMarker) if (linkMarker.hintString != oldHintString) + linksMatched.push(linkMarker) + + linksMatched + + deactivate: (delay, callback) -> + @hintKeystrokeQueue = [] + @linkTextKeystrokeQueue = [] + @labelMap = {} + +hintUtils = + # + # Make each hint character a span, so that we can highlight the typed characters as you type them. + # + spanWrap: (hintString) -> + innerHTML = [] + for i in [0...hintString.length] + innerHTML.push("<span class='vimiumReset'>" + hintString[i] + "</span>") + innerHTML.join("") + + # + # Creates a link marker for the given link. + # + createMarkerFor: (link) -> + marker = document.createElement("div") + marker.className = "vimiumReset internalVimiumHintMarker vimiumHintMarker" + marker.clickableItem = link.element + + clientRect = link.rect + marker.style.left = clientRect.left + window.scrollX + "px" + marker.style.top = clientRect.top + window.scrollY + "px" + + marker.rect = link.rect + + marker + +root = exports ? window +root.LinkHints = LinkHints |
