diff options
| author | Stephen Blott | 2016-03-28 06:59:18 +0100 |
|---|---|---|
| committer | Stephen Blott | 2016-03-28 06:59:20 +0100 |
| commit | 9c1ea0ce9f4b31e288c0808dd28796bb36df1aaf (patch) | |
| tree | cb729d9d8c443c735afc88d468b29814e5de8e88 | |
| parent | 9700c9f2c315abfc1c11634a36df76eb093a4f85 (diff) | |
| download | vimium-9c1ea0ce9f4b31e288c0808dd28796bb36df1aaf.tar.bz2 | |
Move LocalHints out of link-hints mode.
This code (LocalHints) has been embedded into the middle of the
link-hints mode class. And it shouldn't be.
This moves it out, and allows us to unwind some of the gymnastics #2048
(global link hints) introduced to avoid having an incomprehensible diff.
| -rw-r--r-- | content_scripts/link_hints.coffee | 460 |
1 files changed, 227 insertions, 233 deletions
diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 1795f0f7..efe6f045 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -101,7 +101,7 @@ LinkHints = activateModeToOpenIncognito: (count) -> @activateMode count, OPEN_INCOGNITO activateModeToDownloadLink: (count) -> @activateMode count, DOWNLOAD_LINK_URL -class LinkHintsModeBase # This is temporary, because the "visible hints" code is embedded in the hints class. +class LinkHintsMode hintMarkerContainingDiv: null # One of the enums listed at the top of this file. mode: undefined @@ -177,238 +177,6 @@ class LinkHintsModeBase # This is temporary, because the "visible hints" code is marker -# TODO(smblott) This is temporary. Unfortunately, this code is embedded in the "old" link-hints mode class. -# It should be moved, but it's left here for the moment to help keep the diff clearer. -LocalHints = - # - # Determine whether the element is visible and clickable. If it is, find the rect bounding the element in - # the viewport. There may be more than one part of element which is clickable (for example, if it's an - # image), therefore we always return a array of element/rect pairs (which may also be a singleton or empty). - # - getVisibleClickable: (element) -> - tagName = element.tagName.toLowerCase() - isClickable = false - onlyHasTabIndex = false - possibleFalsePositive = false - visibleElements = [] - reason = null - - # Insert area elements that provide click functionality to an img. - if tagName == "img" - mapName = element.getAttribute "usemap" - if mapName - imgClientRects = element.getClientRects() - mapName = mapName.replace(/^#/, "").replace("\"", "\\\"") - map = document.querySelector "map[name=\"#{mapName}\"]" - if map and imgClientRects.length > 0 - areas = map.getElementsByTagName "area" - areasAndRects = DomUtils.getClientRectsForAreas imgClientRects[0], areas - visibleElements.push areasAndRects... - - # Check aria properties to see if the element should be ignored. - if (element.getAttribute("aria-hidden")?.toLowerCase() in ["", "true"] or - element.getAttribute("aria-disabled")?.toLowerCase() in ["", "true"]) - return [] # This element should never have a link hint. - - # Check for AngularJS listeners on the element. - @checkForAngularJs ?= do -> - angularElements = document.getElementsByClassName "ng-scope" - if angularElements.length == 0 - -> false - else - ngAttributes = [] - for prefix in [ '', 'data-', 'x-' ] - for separator in [ '-', ':', '_' ] - ngAttributes.push "#{prefix}ng#{separator}click" - (element) -> - for attribute in ngAttributes - return true if element.hasAttribute attribute - false - - isClickable ||= @checkForAngularJs element - - # Check for attributes that make an element clickable regardless of its tagName. - if (element.hasAttribute("onclick") or - element.getAttribute("role")?.toLowerCase() in ["button", "link"] or - element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) - isClickable = true - - # Check for jsaction event listeners on the element. - if element.hasAttribute "jsaction" - jsactionRules = element.getAttribute("jsaction").split(";") - for jsactionRule in jsactionRules - ruleSplit = jsactionRule.split ":" - isClickable ||= ruleSplit[0] == "click" or (ruleSplit.length == 1 and ruleSplit[0] != "none") - - # Check for tagNames which are natively clickable. - switch tagName - when "a" - isClickable = true - when "textarea" - isClickable ||= not element.disabled and not element.readOnly - when "input" - isClickable ||= not (element.getAttribute("type")?.toLowerCase() == "hidden" or - element.disabled or - (element.readOnly and DomUtils.isSelectable element)) - when "button", "select" - isClickable ||= not element.disabled - when "label" - isClickable ||= element.control? and (@getVisibleClickable element.control).length == 0 - when "body" - isClickable ||= - if element == document.body and not document.hasFocus() and - window.innerWidth > 3 and window.innerHeight > 3 and - document.body?.tagName.toLowerCase() != "frameset" - reason = "Frame." - when "div", "ol", "ul" - isClickable ||= - if element.clientHeight < element.scrollHeight and Scroller.isScrollableElement element - reason = "Scroll." - - # An element with a class name containing the text "button" might be clickable. However, real clickables - # are often wrapped in elements with such class names. So, when we find clickables based only on their - # class name, we mark them as unreliable. - if not isClickable and 0 <= element.getAttribute("class")?.toLowerCase().indexOf "button" - possibleFalsePositive = isClickable = true - - # Elements with tabindex are sometimes useful, but usually not. We can treat them as second class - # citizens when it improves UX, so take special note of them. - tabIndexValue = element.getAttribute("tabindex") - tabIndex = if tabIndexValue == "" then 0 else parseInt tabIndexValue - unless isClickable or isNaN(tabIndex) or tabIndex < 0 - isClickable = onlyHasTabIndex = true - - if isClickable - clientRect = DomUtils.getVisibleClientRect element, true - if clientRect != null - visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex, - possibleFalsePositive, reason} - - visibleElements - - # - # Returns all clickable elements that are not hidden and are in the current viewport, along with rectangles - # at which (parts of) the elements are displayed. - # In the process, we try to find rects where elements do not overlap so that link hints are unambiguous. - # Because of this, the rects returned will frequently *NOT* be equivalent to the rects for the whole - # element. - # - getLocalHints: -> - elements = document.documentElement.getElementsByTagName "*" - visibleElements = [] - - # The order of elements here is important; they should appear in the order they are in the DOM, so that - # we can work out which element is on top when multiple elements overlap. Detecting elements in this loop - # is the sensible, efficient way to ensure this happens. - # NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't provide - # this, so it's necessary to check whether elements are clickable in order, as we do below. - for element in elements - visibleElement = @getVisibleClickable element - visibleElements.push visibleElement... - - # Traverse the DOM from descendants to ancestors, so later elements show above earlier elements. - visibleElements = visibleElements.reverse() - - # Filter out suspected false positives. A false positive is taken to be an element marked as a possible - # false positive for which a close descendant is already clickable. False positives tend to be close - # together in the DOM, so - to keep the cost down - we only search nearby elements. NOTE(smblott): The - # visible elements have already been reversed, so we're visiting descendants before their ancestors. - descendantsToCheck = [1..3] # This determines how many descendants we're willing to consider. - visibleElements = - for element, position in visibleElements - continue if element.possibleFalsePositive and do -> - index = Math.max 0, position - 6 # This determines how far back we're willing to look. - while index < position - candidateDescendant = visibleElements[index].element - for _ in descendantsToCheck - candidateDescendant = candidateDescendant?.parentElement - return true if candidateDescendant == element.element - index += 1 - false # This is not a false positive. - element - - # TODO(mrmr1993): Consider z-index. z-index affects behviour as follows: - # * The document has a local stacking context. - # * An element with z-index specified - # - sets its z-order position in the containing stacking context, and - # - creates a local stacking context containing its children. - # * An element (1) is shown above another element (2) if either - # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), the - # ancestor of (1) has a higher z-index than the ancestor of (2); or - # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), - # + the ancestors of (1) and (2) have equal z-index, and - # + the ancestor of (1) appears later in the DOM than the ancestor of (2). - # - # Remove rects from elements where another clickable element lies above it. - localHints = nonOverlappingElements = [] - while visibleElement = visibleElements.pop() - rects = [visibleElement.rect] - for {rect: negativeRect} in visibleElements - # Subtract negativeRect from every rect in rects, and concatenate the arrays of rects that result. - rects = [].concat (rects.map (rect) -> Rect.subtract rect, negativeRect)... - if rects.length > 0 - nonOverlappingElements.push extend visibleElement, rect: rects[0] - else - # Every part of the element is covered by some other element, so just insert the whole element's - # rect. Except for elements with tabIndex set (second class citizens); these are often more trouble - # than they're worth. - # TODO(mrmr1993): This is probably the wrong thing to do, but we don't want to stop being able to - # click some elements that we could click before. - nonOverlappingElements.push visibleElement unless visibleElement.secondClassCitizen - - hint.hasHref = hint.element.href? for hint in localHints - if Settings.get "filterLinkHints" - @withLabelMap (labelMap) => - extend hint, @generateLinkText labelMap, hint for hint in localHints - localHints - - # Generate a map of input element => label text, call a callback with it. - withLabelMap: (callback) -> - labelMap = {} - labels = document.querySelectorAll "label" - for label in labels - forElement = label.getAttribute "for" - if forElement - labelText = label.textContent.trim() - # Remove trailing ":" commonly found in labels. - if labelText[labelText.length-1] == ":" - labelText = labelText.substr 0, labelText.length-1 - labelMap[forElement] = labelText - callback labelMap - - generateLinkText: (labelMap, hint) -> - element = hint.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 - if not linkText and 'placeholder' of element - linkText = element.placeholder - # Check if there is an image embedded in the <a> tag. - else if nodeName == "a" and not element.textContent.trim() and - element.firstElementChild and - element.firstElementChild.nodeName.toLowerCase() == "img" - linkText = element.firstElementChild.alt || element.firstElementChild.title - showLinkText = true if linkText - else if hint.reason? - linkText = hint.reason - showLinkText = true - else - linkText = (element.textContent.trim() || element.innerHTML.trim())[...512] - - {linkText, showLinkText} - -# TODO(smblott) Again, this is temporary. We need to move the code above out of the "old" link-hints class. -class LinkHintsMode extends LinkHintsModeBase - constructor: (args...) -> super args... - # Handles <Shift> and <Ctrl>. onKeyDownInMode: (hintMarkers, event) -> return if event.repeat @@ -713,6 +481,232 @@ spanWrap = (hintString) -> innerHTML.push("<span class='vimiumReset'>" + char + "</span>") innerHTML.join("") +LocalHints = + # + # Determine whether the element is visible and clickable. If it is, find the rect bounding the element in + # the viewport. There may be more than one part of element which is clickable (for example, if it's an + # image), therefore we always return a array of element/rect pairs (which may also be a singleton or empty). + # + getVisibleClickable: (element) -> + tagName = element.tagName.toLowerCase() + isClickable = false + onlyHasTabIndex = false + possibleFalsePositive = false + visibleElements = [] + reason = null + + # Insert area elements that provide click functionality to an img. + if tagName == "img" + mapName = element.getAttribute "usemap" + if mapName + imgClientRects = element.getClientRects() + mapName = mapName.replace(/^#/, "").replace("\"", "\\\"") + map = document.querySelector "map[name=\"#{mapName}\"]" + if map and imgClientRects.length > 0 + areas = map.getElementsByTagName "area" + areasAndRects = DomUtils.getClientRectsForAreas imgClientRects[0], areas + visibleElements.push areasAndRects... + + # Check aria properties to see if the element should be ignored. + if (element.getAttribute("aria-hidden")?.toLowerCase() in ["", "true"] or + element.getAttribute("aria-disabled")?.toLowerCase() in ["", "true"]) + return [] # This element should never have a link hint. + + # Check for AngularJS listeners on the element. + @checkForAngularJs ?= do -> + angularElements = document.getElementsByClassName "ng-scope" + if angularElements.length == 0 + -> false + else + ngAttributes = [] + for prefix in [ '', 'data-', 'x-' ] + for separator in [ '-', ':', '_' ] + ngAttributes.push "#{prefix}ng#{separator}click" + (element) -> + for attribute in ngAttributes + return true if element.hasAttribute attribute + false + + isClickable ||= @checkForAngularJs element + + # Check for attributes that make an element clickable regardless of its tagName. + if (element.hasAttribute("onclick") or + element.getAttribute("role")?.toLowerCase() in ["button", "link"] or + element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) + isClickable = true + + # Check for jsaction event listeners on the element. + if element.hasAttribute "jsaction" + jsactionRules = element.getAttribute("jsaction").split(";") + for jsactionRule in jsactionRules + ruleSplit = jsactionRule.split ":" + isClickable ||= ruleSplit[0] == "click" or (ruleSplit.length == 1 and ruleSplit[0] != "none") + + # Check for tagNames which are natively clickable. + switch tagName + when "a" + isClickable = true + when "textarea" + isClickable ||= not element.disabled and not element.readOnly + when "input" + isClickable ||= not (element.getAttribute("type")?.toLowerCase() == "hidden" or + element.disabled or + (element.readOnly and DomUtils.isSelectable element)) + when "button", "select" + isClickable ||= not element.disabled + when "label" + isClickable ||= element.control? and (@getVisibleClickable element.control).length == 0 + when "body" + isClickable ||= + if element == document.body and not document.hasFocus() and + window.innerWidth > 3 and window.innerHeight > 3 and + document.body?.tagName.toLowerCase() != "frameset" + reason = "Frame." + when "div", "ol", "ul" + isClickable ||= + if element.clientHeight < element.scrollHeight and Scroller.isScrollableElement element + reason = "Scroll." + + # An element with a class name containing the text "button" might be clickable. However, real clickables + # are often wrapped in elements with such class names. So, when we find clickables based only on their + # class name, we mark them as unreliable. + if not isClickable and 0 <= element.getAttribute("class")?.toLowerCase().indexOf "button" + possibleFalsePositive = isClickable = true + + # Elements with tabindex are sometimes useful, but usually not. We can treat them as second class + # citizens when it improves UX, so take special note of them. + tabIndexValue = element.getAttribute("tabindex") + tabIndex = if tabIndexValue == "" then 0 else parseInt tabIndexValue + unless isClickable or isNaN(tabIndex) or tabIndex < 0 + isClickable = onlyHasTabIndex = true + + if isClickable + clientRect = DomUtils.getVisibleClientRect element, true + if clientRect != null + visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex, + possibleFalsePositive, reason} + + visibleElements + + # + # Returns all clickable elements that are not hidden and are in the current viewport, along with rectangles + # at which (parts of) the elements are displayed. + # In the process, we try to find rects where elements do not overlap so that link hints are unambiguous. + # Because of this, the rects returned will frequently *NOT* be equivalent to the rects for the whole + # element. + # + getLocalHints: -> + elements = document.documentElement.getElementsByTagName "*" + visibleElements = [] + + # The order of elements here is important; they should appear in the order they are in the DOM, so that + # we can work out which element is on top when multiple elements overlap. Detecting elements in this loop + # is the sensible, efficient way to ensure this happens. + # NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't provide + # this, so it's necessary to check whether elements are clickable in order, as we do below. + for element in elements + visibleElement = @getVisibleClickable element + visibleElements.push visibleElement... + + # Traverse the DOM from descendants to ancestors, so later elements show above earlier elements. + visibleElements = visibleElements.reverse() + + # Filter out suspected false positives. A false positive is taken to be an element marked as a possible + # false positive for which a close descendant is already clickable. False positives tend to be close + # together in the DOM, so - to keep the cost down - we only search nearby elements. NOTE(smblott): The + # visible elements have already been reversed, so we're visiting descendants before their ancestors. + descendantsToCheck = [1..3] # This determines how many descendants we're willing to consider. + visibleElements = + for element, position in visibleElements + continue if element.possibleFalsePositive and do -> + index = Math.max 0, position - 6 # This determines how far back we're willing to look. + while index < position + candidateDescendant = visibleElements[index].element + for _ in descendantsToCheck + candidateDescendant = candidateDescendant?.parentElement + return true if candidateDescendant == element.element + index += 1 + false # This is not a false positive. + element + + # TODO(mrmr1993): Consider z-index. z-index affects behviour as follows: + # * The document has a local stacking context. + # * An element with z-index specified + # - sets its z-order position in the containing stacking context, and + # - creates a local stacking context containing its children. + # * An element (1) is shown above another element (2) if either + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), the + # ancestor of (1) has a higher z-index than the ancestor of (2); or + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), + # + the ancestors of (1) and (2) have equal z-index, and + # + the ancestor of (1) appears later in the DOM than the ancestor of (2). + # + # Remove rects from elements where another clickable element lies above it. + localHints = nonOverlappingElements = [] + while visibleElement = visibleElements.pop() + rects = [visibleElement.rect] + for {rect: negativeRect} in visibleElements + # Subtract negativeRect from every rect in rects, and concatenate the arrays of rects that result. + rects = [].concat (rects.map (rect) -> Rect.subtract rect, negativeRect)... + if rects.length > 0 + nonOverlappingElements.push extend visibleElement, rect: rects[0] + else + # Every part of the element is covered by some other element, so just insert the whole element's + # rect. Except for elements with tabIndex set (second class citizens); these are often more trouble + # than they're worth. + # TODO(mrmr1993): This is probably the wrong thing to do, but we don't want to stop being able to + # click some elements that we could click before. + nonOverlappingElements.push visibleElement unless visibleElement.secondClassCitizen + + hint.hasHref = hint.element.href? for hint in localHints + if Settings.get "filterLinkHints" + @withLabelMap (labelMap) => + extend hint, @generateLinkText labelMap, hint for hint in localHints + localHints + + # Generate a map of input element => label text, call a callback with it. + withLabelMap: (callback) -> + labelMap = {} + labels = document.querySelectorAll "label" + for label in labels + forElement = label.getAttribute "for" + if forElement + labelText = label.textContent.trim() + # Remove trailing ":" commonly found in labels. + if labelText[labelText.length-1] == ":" + labelText = labelText.substr 0, labelText.length-1 + labelMap[forElement] = labelText + callback labelMap + + generateLinkText: (labelMap, hint) -> + element = hint.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 + if not linkText and 'placeholder' of element + linkText = element.placeholder + # Check if there is an image embedded in the <a> tag. + else if nodeName == "a" and not element.textContent.trim() and + element.firstElementChild and + element.firstElementChild.nodeName.toLowerCase() == "img" + linkText = element.firstElementChild.alt || element.firstElementChild.title + showLinkText = true if linkText + else if hint.reason? + linkText = hint.reason + showLinkText = true + else + linkText = (element.textContent.trim() || element.innerHTML.trim())[...512] + + {linkText, showLinkText} + # Suppress all keyboard events until the user stops typing for sufficiently long. class TypingProtector extends Mode constructor: (delay, callback) -> |
