1 utils = require 'utils'
2 { getPref } = require 'prefs'
3 { Marker } = require 'mode-hints/marker'
4 { addHuffmanCodeWordsTo } = require 'mode-hints/huffman'
6 { interfaces: Ci } = Components
8 HTMLDocument = Ci.nsIDOMHTMLDocument
9 XULDocument = Ci.nsIDOMXULDocument
10 FrameElement = Ci.nsIDOMHTMLFrameElement
11 IFrameElement = Ci.nsIDOMHTMLIFrameElement
13 CONTAINER_ID = 'VimFxHintMarkerContainer'
14 Z_INDEX_START = 2147480001 # The highest `z-index` used in style.css plus one.
15 # In theory, `z-index` can be infinitely large. In practice, Firefox uses a
16 # 32-bit signed integer to store it, so the maximum value is 2147483647
17 # (http://www.puidokas.com/max-z-index/). Youtube (insanely) uses 1999999999
18 # for its top bar. So by using 2147480001 as a base, we trump that value with
19 # lots of margin, still leaving a few thousand values for markers, which should
20 # be more than enough. Hopefully no sites are crazy enough to use even higher
24 removeHints = (document) ->
25 document.getElementById(CONTAINER_ID)?.remove()
28 injectHints = (window) ->
31 { clientWidth, clientHeight } = document.documentElement
39 scrollX: window.scrollX
40 scrollY: window.scrollY
41 markers = createMarkers(window, viewport)
43 return if markers.length == 0
46 marker.weight = marker.elementShape.area
48 # Each marker gets a unique `z-index`, so that it can be determined if a marker overlaps another.
49 # Put more important markers (higher weight) at the end, so that they get higher `z-index`, in
50 # order not to be overlapped.
51 markers.sort((a, b) -> a.weight - b.weight)
52 for marker, index in markers
53 marker.markerElement.style.setProperty('z-index', Z_INDEX_START + index, 'important')
55 hintChars = utils.getHintChars()
56 addHuffmanCodeWordsTo(markers, {alphabet: hintChars}, (marker, hint) -> marker.setHint(hint))
59 container = utils.createElement(document, 'div', {id: CONTAINER_ID})
60 document.documentElement.appendChild(container)
63 container.appendChild(marker.markerElement)
64 # Must be done after the hints have been inserted into the DOM (see marker.coffee)
65 marker.setPosition(viewport)
70 createMarkers = (window, viewport, parents = []) ->
74 # For now we aren't able to handle hint markers in XUL Documents :(
75 return [] unless document instanceof HTMLDocument # or document instanceof XULDocument
77 candidates = utils.getMarkableElements(document, {type: 'all'})
78 for element in candidates
79 shape = getElementShape(window, element, viewport, parents)
80 # If `element` has no visible shape then it shouldn’t get any marker.
83 markers.push(new Marker(element, shape))
85 if element instanceof FrameElement or element instanceof IFrameElement
86 frame = element.contentWindow
87 [ rect ] = shape.rects # Frames only have one rect.
89 # Calculate the visible part of the frame, according to the parent.
90 { clientWidth, clientHeight } = frame.document.documentElement
92 left: Math.max(viewport.left - rect.left, 0)
93 top: Math.max(viewport.top - rect.top, 0)
94 right: clientWidth + Math.min(viewport.right - rect.right, 0)
95 bottom: clientHeight + Math.min(viewport.bottom - rect.bottom, 0)
97 computedStyle = window.getComputedStyle(frame.frameElement)
100 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
101 parseFloat(computedStyle.getPropertyValue('padding-left'))
103 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
104 parseFloat(computedStyle.getPropertyValue('padding-top'))
106 frameMarkers = createMarkers(frame, frameViewport, parents.concat({ window, offset }))
107 markers.push(frameMarkers...)
111 # Returns the “shape” of `element`:
113 # - `rects`: Its `.getClientRects()` rectangles.
114 # - `visibleRects`: The parts of rectangles out of the above that are inside
116 # - `nonCoveredPoint`: The coordinates of the first point of `element` that
117 # isn’t covered by another element (except children of `element`).
118 # - `area`: The area of the part of `element` that is inside `viewport`.
120 # Returns `null` if `element` is outside `viewport` or entirely covered by
122 getElementShape = (window, element, viewport, parents) ->
123 # `element.getClientRects()` returns a list of rectangles, usually just one,
124 # which is identical to the one returned by
125 # `element.getBoundingClientRect()`. However, if `element` is inline and
126 # line-wrapped, then it returns one rectangle for each line, since each line
127 # may be of different length, for example. That allows us to properly add
128 # hints to line-wrapped links.
129 rects = element.getClientRects()
132 for rect in rects when isInsideViewport(rect, viewport)
133 visibleRect = adjustRectToViewport(rect, viewport)
134 totalArea += visibleRect.area
135 visibleRects.push(visibleRect)
137 return null if visibleRects.length == 0
139 # If `element` has no area there is nothing to click, unless `element` has
140 # only one visible rect and either a width or a height. That means that
141 # everything inside `element` is floated and/or absolutely positioned (and
142 # that `element` hasn’t been made to “contain” the floats). For example, a
143 # link in a menu could contain a span of text floated to the left and an icon
144 # floated to the right. Those are still clickable. Therefore we return the
145 # shape of the first visible child instead. At least in that example, that’s
147 if totalArea == 0 and visibleRects.length == 1
148 [ rect ] = visibleRects
149 if rect.width > 0 or rect.height > 0
150 for child in element.children
151 shape = getElementShape(window, child, viewport, parents)
152 return shape if shape
155 return null if totalArea == 0
157 # Even if `element` has a visible rect, it might be covered by other elements.
158 for visibleRect in visibleRects
159 nonCoveredPoint = getFirstNonCoveredPoint(window, element, visibleRect, parents)
160 break if nonCoveredPoint
162 return null unless nonCoveredPoint
165 rects, visibleRects, nonCoveredPoint, area: totalArea
169 MINIMUM_EDGE_DISTANCE = 4
170 isInsideViewport = (rect, viewport) ->
172 rect.left <= viewport.right - MINIMUM_EDGE_DISTANCE and
173 rect.top <= viewport.bottom + MINIMUM_EDGE_DISTANCE and
174 rect.right >= viewport.left + MINIMUM_EDGE_DISTANCE and
175 rect.bottom >= viewport.top - MINIMUM_EDGE_DISTANCE
178 adjustRectToViewport = (rect, viewport) ->
179 left = Math.max(rect.left, viewport.left)
180 right = Math.min(rect.right, viewport.right)
181 top = Math.max(rect.top, viewport.top)
182 bottom = Math.min(rect.bottom, viewport.bottom)
185 height = bottom - top
186 area = width * height
189 left, right, top, bottom
194 getFirstNonCoveredPoint = (window, element, elementRect, parents) ->
195 # Determining if `element` is covered by other elements is a bit tricky. We
196 # use `document.elementFromPoint()` to check if `element`, or a child of
197 # `element` (anything inside an `<a>` is clickable too), really is present in
198 # `elementRect`. If so, we prepare that point for being returned (#A).
200 # However, if we’re currently in a frame, there might be something on top of
201 # the frame that covers `element`. Therefore we check that the frame really
202 # is present at the point for each parent in `parents`. (#B)
204 # If `element` still isn’t determined to be covered, we return the point. (#C)
206 # We start by checking the top-left corner, since that’s where we want to
207 # place the marker, if possible. If there’s something else there, we check if
208 # that element is not as wide as `element`. If so we recurse, checking the
209 # point directly to the right of the found element. (#D)
211 # If that doesn’t find some exposed space of `element` we do the same
212 # procedure again, but downwards instead. (#E)
214 # Otherwise `element` seems to be covered to the right of `x` and below `y`.
217 # But before we start we need to hack around a little problem. If `element`
218 # has `border-radius`, the top-left corner won’t really belong to `element`,
219 # so `document.elementFromPoint()` will return whatever is behind. The
220 # solution is to temporarily add a CSS class that removes `border-radius`.
221 element.classList.add('VimFxNoBorderRadius')
223 triedElements = new Set()
225 nonCoveredPoint = do recurse = (x = elementRect.left, y = elementRect.top) ->
226 elementAtPoint = window.document.elementFromPoint(x, y)
228 # `document.elementFromPoint()` returns `null` if the point is outside the
229 # viewport. That should never happen, but in case it does we return.
230 return false if elementAtPoint == null
234 if element.contains(elementAtPoint) # Note that `a.contains(a) == true`!
237 # Some sites use a pseudo-element to give fancy borders or shadows to
238 # <input>s, which might cover `element`. There is no way of getting the
239 # dimensions of pseudo-elements, so we add `pointer-events: none;` to
240 # them, so that we can get what’s below. In theory there might be yet a
241 # pseudo-element there, which could be solved through recursion, but it
242 # seems to be impossible to know when to end that recursion.
243 elementAtPoint.classList.add('VimFxBeforeAfterClickThrough')
244 elementAtPoint = window.document.elementFromPoint(x, y)
245 elementAtPoint.classList.remove('VimFxBeforeAfterClickThrough')
246 if element.contains(elementAtPoint)
253 currentWindow = window
254 for {window: parentWindow, offset} in parents by -1
255 point.x += offset.left
256 point.y += offset.top
257 elementAtPoint = parentWindow.document.elementFromPoint(point.x, point.y)
258 if elementAtPoint != currentWindow.frameElement
264 currentWindow = parentWindow
267 return point unless covered
269 # If we have already looked around the found element, it is a waste of time
271 return false if triedElements.has(elementAtPoint)
272 triedElements.add(elementAtPoint)
274 { right, bottom } = elementAtPoint.getBoundingClientRect()
276 right -= adjustment.x
277 bottom -= adjustment.y
280 if right < elementRect.right
281 return point if point = recurse(right + 1, y)
284 if bottom < elementRect.bottom
285 return point if point = recurse(x, bottom + 1)
290 element.classList.remove('VimFxNoBorderRadius')
292 return nonCoveredPoint
295 # Finds all stacks of markers that overlap each other (by using `getStackFor`) (#1), and rotates
296 # their `z-index`:es (#2), thus alternating which markers are visible.
297 rotateOverlappingMarkers = (originalMarkers, forward) ->
298 # Shallow working copy. This is necessary since `markers` will be mutated and eventually empty.
299 markers = originalMarkers[..]
302 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
305 # Stacks of length 1 don't participate in any overlapping, and can therefore be skipped.
306 for stack in stacks when stack.length > 1
307 # This sort is not required, but makes the rotation more predictable.
308 stack.sort((a, b) -> a.markerElement.style.zIndex - b.markerElement.style.zIndex)
311 indexStack = (marker.markerElement.style.zIndex for marker in stack)
312 # Shift the array of indices one item forward or back
314 indexStack.unshift(indexStack.pop())
316 indexStack.push(indexStack.shift())
318 for marker, index in stack
319 marker.markerElement.style.setProperty('z-index', indexStack[index], 'important')
323 # Get an array containing `marker` and all markers that overlap `marker`, if any, which is called
324 # a "stack". All markers in the returned stack are spliced out from `markers`, thus mutating it.
325 getStackFor = (marker, markers) ->
328 { top, bottom, left, right } = marker.position
331 while index < markers.length
332 nextMarker = markers[index]
334 { top: nextTop, bottom: nextBottom, left: nextLeft, right: nextRight } = nextMarker.position
335 overlapsVertically = (nextBottom >= top and nextTop <= bottom)
336 overlapsHorizontally = (nextRight >= left and nextLeft <= right)
338 if overlapsVertically and overlapsHorizontally
339 # Also get all markers overlapping this one
340 markers.splice(index, 1)
341 stack = stack.concat(getStackFor(nextMarker, markers))
343 # Continue the search
349 exports.injectHints = injectHints
350 exports.removeHints = removeHints
351 exports.rotateOverlappingMarkers = rotateOverlappingMarkers