diff options
Diffstat (limited to 'linkHints.js')
| -rw-r--r-- | linkHints.js | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/linkHints.js b/linkHints.js new file mode 100644 index 00000000..2af1c720 --- /dev/null +++ b/linkHints.js @@ -0,0 +1,254 @@ +/* + * 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. + */ +var linkHintsCss = + '.vimiumHintMarker {' + + 'background-color:yellow;' + + 'color:black;' + + 'font-weight:bold;' + + 'font-size:13px;' + + 'padding:0 1px;' + + 'line-height:100%;' + + 'border:1px solid #E3BE23;' + + 'z-index:99999999;' + + 'font-family:"Helvetica Neue", "Helvetica", "Arial", "Sans";' + + '}' + + '.vimiumHintMarker > span.matchingCharacter {' + + 'color:#C79F0B;' + + '}'; + +var hintMarkers = []; +var hintCharacters = "asdfjkl"; +// The characters that were typed in while in "link hints" mode. +var hintKeystrokeQueue = []; +var linkHintsModeActivated = false; +// Whether we have added to the page the CSS needed to display link hints. +var linkHintsCssAdded = false; + +// An XPath describing what a clickable element is. We could also look for images with an onclick +// attribute, but let's wait to see if that really is necessary. +var clickableElementsXPath = "//a | //textarea | //button | //select | //input[not(@type='hidden')]"; + +function activateLinkHintsMode() { + if (!linkHintsCssAdded) + addCssToPage(linkHintsCss); + linkHintsModeActivated = true; + buildLinkHints(); + document.addEventListener("keydown", onKeyDownInLinkHintsMode, true); +} + +/* + * Builds and displays link hints for every visible clickable item on the page. + */ +function buildLinkHints() { + var visibleElements = getVisibleClickableElements(); + + // Initialize the number used to generate the character hints to be as many digits as we need to + // highlight all the links on the page; we don't want some link hints to have more chars than others. + var digitsNeeded = digitsNeededToRepresentLinks(visibleElements.length); + var linkHintNumber = Math.pow(hintCharacters.length, digitsNeeded - 1); + for (var i = 0; i < visibleElements.length; i++) { + hintMarkers.push(addMarkerFor(visibleElements[i], linkHintNumber)); + linkHintNumber++; + } +} + +/* + * 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. + */ +function getVisibleClickableElements() { + var resultSet = document.evaluate(clickableElementsXPath, document.body, null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + var visibleElements = []; + + // Prune all invisible clickable elements. + for (var i = 0; i < resultSet.snapshotLength; i++) { + var element = resultSet.snapshotItem(i); + + // Note that getBoundingClientRect() is relative to the viewport + var boundingRect = element.getBoundingClientRect(); + if (boundingRect.bottom < 0 || boundingRect.top > window.innerHeight) + continue; + + // Using getElementFromPoint will omit elements which have visibility=hidden or display=none, and + // elements inside of containers that are also hidden. + if (element != getElementFromPoint(boundingRect.left, boundingRect.top)) + continue; + + visibleElements.push(element); + } + return visibleElements; +} + +/* + * Returns the element at the given point and factors in the page's CSS zoom level, which Webkit neglects + * to do. This should become unnecessary when webkit fixes their bug. + */ +function getElementFromPoint(x, y) { + var zoomLevel = parseInt(document.documentElement.style.zoom || 100) / 100.0; + return document.elementFromPoint(Math.ceil(x * zoomLevel), Math.ceil(y * zoomLevel)); +} + +/* + * Returns the number of digits that will be needed by the link hints to represent all of the elements + * on screen. This assumes that we want all of the elements to have the same number of characters in + * their link hints. + */ +function digitsNeededToRepresentLinks(numElements) { + for (var i = 1; i < 5; i++) { + var maxCharactersRepresented = Math.pow(hintCharacters.length, i); + for (var j = 1; j < i; j++) + maxCharactersRepresented -= Math.pow(hintCharacters.length, j); + if (maxCharactersRepresented >= numElements) + return i; + } + return 6; +} + +function onKeyDownInLinkHintsMode(event) { + var keyChar = String.fromCharCode(event.keyCode).toLowerCase(); + if (!keyChar) + return; + + // TODO(philc): Ignore keys that have modifiers. + if (event.keyCode == keyCodes.ESC) { + deactivateLinkHintsMode(); + } else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { + if (hintKeystrokeQueue.length == 0) { + deactivateLinkHintsMode(); + } else { + hintKeystrokeQueue.pop(); + updateLinkHints(); + } + } else if (hintCharacters.indexOf(keyChar) >= 0) { + hintKeystrokeQueue.push(keyChar); + updateLinkHints(); + } else { + return; + } + + event.stopPropagation(); + event.preventDefault(); +} + +/* + * Updates the visibility of link hints on screen based on the keystrokes typed thus far. If only one + * link hint remains, click on that link and exit link hints mode. + */ +function updateLinkHints() { + var matchString = hintKeystrokeQueue.join(""); + var linksMatched = highlightLinkMatches(matchString); + if (linksMatched.length == 0) + deactivateLinkHintsMode(); + else if (linksMatched.length == 1) { + var matchedLink = linksMatched[0]; + // Don't navigate to the selected link immediately; we want to give the user some feedback depicting + // which link they've selected by focusing it. Note that for textareas and inputs, the click + // event is ignored, but focus causes the desired behavior. + setTimeout(function() { simulateClick(matchedLink); }, 600); + matchedLink.focus(); + deactivateLinkHintsMode(); + } +} + +/* + * Hides link hints which do not match the given search string. To allow the backspace key to work, this + * will also show link hints which do match but were previously hidden. + */ +function highlightLinkMatches(searchString) { + var linksMatched = []; + for (var i = 0; i < hintMarkers.length; i++) { + var linkMarker = hintMarkers[i]; + if (linkMarker.getAttribute("hintString").indexOf(searchString) == 0) { + if (linkMarker.style.display == "none") + linkMarker.style.display = ""; + for (var j = 0; j < linkMarker.childNodes.length; j++) + linkMarker.childNodes[j].className = (j >= searchString.length) ? "" : "matchingCharacter"; + linksMatched.push(linkMarker.clickableItem); + } else { + linkMarker.style.display = "none"; + } + } + return linksMatched; +} + +/* + * Converts a number like "8" into a hint string like "JK". This is used to sequentially generate all of + * the hint text. + */ +function numberToHintString(number) { + var base = hintCharacters.length; + var hintString = []; + var remainder = 0; + while (number > 0) { + remainder = number % base; + hintString.unshift(hintCharacters[remainder]); + number -= remainder; + number /= Math.floor(base); + } + return hintString.join(""); +} + +function simulateClick(link) { + var event = document.createEvent("MouseEvents"); + event.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); + // Debugging note: Firefox will not execute the link's default action if we dispatch this click event, + // but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately + link.dispatchEvent(event); +} + +function deactivateLinkHintsMode() { + for (var i = 0; i < hintMarkers.length; i++) + hintMarkers[i].parentNode.removeChild(hintMarkers[i]); + hintMarkers = []; + hintKeystrokeQueue = []; + document.removeEventListener("keydown", onKeyDownInLinkHintsMode, true); + linkHintsModeActivated = false; +} + +/* + * Adds a link marker for the given link by adding a new element to <body> and positioning it on top of + * the link. + */ +function addMarkerFor(link, linkHintNumber) { + var hintString = numberToHintString(linkHintNumber); + var marker = document.createElement("div"); + marker.className = "vimiumHintMarker"; + var innerHTML = []; + // Make each hint character a span, so that we can highlight the typed characters as you type them. + for (var i = 0; i < hintString.length; i++) + innerHTML.push("<span>" + hintString[i].toUpperCase() + "</span>"); + marker.innerHTML = innerHTML.join(""); + marker.setAttribute("hintString", hintString); + marker.style.position = "absolute"; + + var boundingRect = link.getBoundingClientRect(); + marker.style.left = boundingRect.left + window.scrollX + "px"; + marker.style.top = boundingRect.top + window.scrollY + "px"; + + marker.clickableItem = link; + // Note(philc): Append these markers to document.body 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. + document.body.appendChild(marker); + return marker; +} + +/* + * Adds the given CSS to the page. TODO: This may belong in the core vimium frontend. + */ +function addCssToPage(css) { + var head = document.getElementsByTagName("head")[0]; + if (!head) { + console.log("Warning: unable to add CSS to the page."); + return; + } + var style = document.createElement("style"); + style.type = "text/css"; + style.appendChild(document.createTextNode(css)); + head.appendChild(style); +}
\ No newline at end of file |
