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 top-level window.
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 # If `element` has no area there is nothing to click. It is likely hidden
138 # using `display: none;`. However, if all the children of `element` are
139 # floated and/or absolutely positioned, then the area is 0, too. In that case
140 # it is actually possible to click `element` by clicking one of its children.
141 # For performance, let’s ignore this case until it’s needed. I haven’t found
142 # a need for this on real sites.
143 return null if totalArea == 0
145 for visibleRect in visibleRects
146 nonCoveredPoint = getFirstNonCoveredPoint(window, element, visibleRect, parents)
147 break if nonCoveredPoint
149 return null unless nonCoveredPoint
152 rects, visibleRects, nonCoveredPoint, area: totalArea
156 MINIMUM_EDGE_DISTANCE = 4
157 isInsideViewport = (rect, viewport) ->
159 rect.left <= viewport.right - MINIMUM_EDGE_DISTANCE and
160 rect.top <= viewport.bottom + MINIMUM_EDGE_DISTANCE and
161 rect.right >= viewport.left + MINIMUM_EDGE_DISTANCE and
162 rect.bottom >= viewport.top - MINIMUM_EDGE_DISTANCE
165 adjustRectToViewport = (rect, viewport) ->
166 left = Math.max(rect.left, viewport.left)
167 right = Math.min(rect.right, viewport.right)
168 top = Math.max(rect.top, viewport.top)
169 bottom = Math.min(rect.bottom, viewport.bottom)
172 height = bottom - top
173 area = width * height
176 left, right, top, bottom
181 getFirstNonCoveredPoint = (window, element, elementRect, parents) ->
182 # Determining if `element` is covered by other elements is a bit tricky. We
183 # use `document.elementFromPoint()` to check if `element`, or a child of
184 # `element` (anything inside an `<a>` is clickable too), really is present in
185 # `elementRect`. If so, we prepare that point for being returned (#A).
187 # However, if we’re currently in a frame, there might be something on top of
188 # the frame that covers `element`. Therefore we check that the frame really
189 # is present at the point for each parent in `parents`. (#B)
191 # If `element` still isn’t determined to be covered, we return the point. (#C)
193 # We start by checking the top-left corner, since that’s where we want to
194 # place the marker, if possible. If there’s something else there, we check if
195 # that element is not as wide as `element`. If so we recurse, checking the
196 # point directly to the right of the found element. (#D)
198 # If that doesn’t find some exposed space of `element` we do the same
199 # procedure again, but downwards instead. (#E)
201 # Otherwise `element` seems to be covered to the right of `x` and below `y`.
204 # But before we start we need to hack around a little problem. If `element`
205 # has `border-radius`, the top-left corner won’t really belong to `element`,
206 # so `document.elementFromPoint()` will return whatever is behind. The
207 # solution is to temporarily add a CSS class that removes `border-radius`.
208 element.classList.add('VimFxNoBorderRadius')
210 triedElements = new Set()
212 nonCoveredPoint = do recurse = (x = elementRect.left, y = elementRect.top) ->
214 elementAtPoint = window.document.elementFromPoint(x, y)
215 if element.contains(elementAtPoint) # Note that `a.contains(a) == true`!
220 currentWindow = window
221 for {window: parentWindow, offset} in parents by -1
222 point.x += offset.left
223 point.y += offset.top
224 elementAtPoint = parentWindow.document.elementFromPoint(point.x, point.y)
225 if elementAtPoint != currentWindow.frameElement
231 currentWindow = parentWindow
234 return point unless covered
236 # `document.elementFromPoint()` returns `null` if the point is outside the
237 # viewport. That should never happen, but in case it does we return.
238 return false if elementAtPoint == null
240 # If we have already looked around the found element, it is a waste of time
242 return false if triedElements.has(elementAtPoint)
243 triedElements.add(elementAtPoint)
245 { right, bottom } = elementAtPoint.getBoundingClientRect()
247 right -= adjustment.x
248 bottom -= adjustment.y
251 if right < elementRect.right
252 return point if point = recurse(right + 1, y)
255 if bottom < elementRect.bottom
256 return point if point = recurse(x, bottom + 1)
261 element.classList.remove('VimFxNoBorderRadius')
263 return nonCoveredPoint
266 # Finds all stacks of markers that overlap each other (by using `getStackFor`) (#1), and rotates
267 # their `z-index`:es (#2), thus alternating which markers are visible.
268 rotateOverlappingMarkers = (originalMarkers, forward) ->
269 # Shallow working copy. This is necessary since `markers` will be mutated and eventually empty.
270 markers = originalMarkers[..]
273 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
276 # Stacks of length 1 don't participate in any overlapping, and can therefore be skipped.
277 for stack in stacks when stack.length > 1
278 # This sort is not required, but makes the rotation more predictable.
279 stack.sort((a, b) -> a.markerElement.style.zIndex - b.markerElement.style.zIndex)
282 indexStack = (marker.markerElement.style.zIndex for marker in stack)
283 # Shift the array of indices one item forward or back
285 indexStack.unshift(indexStack.pop())
287 indexStack.push(indexStack.shift())
289 for marker, index in stack
290 marker.markerElement.style.setProperty('z-index', indexStack[index], 'important')
294 # Get an array containing `marker` and all markers that overlap `marker`, if any, which is called
295 # a "stack". All markers in the returned stack are spliced out from `markers`, thus mutating it.
296 getStackFor = (marker, markers) ->
299 { top, bottom, left, right } = marker.position
302 while index < markers.length
303 nextMarker = markers[index]
305 { top: nextTop, bottom: nextBottom, left: nextLeft, right: nextRight } = nextMarker.position
306 overlapsVertically = (nextBottom >= top and nextTop <= bottom)
307 overlapsHorizontally = (nextRight >= left and nextLeft <= right)
309 if overlapsVertically and overlapsHorizontally
310 # Also get all markers overlapping this one
311 markers.splice(index, 1)
312 stack = stack.concat(getStackFor(nextMarker, markers))
314 # Continue the search
320 exports.injectHints = injectHints
321 exports.removeHints = removeHints
322 exports.rotateOverlappingMarkers = rotateOverlappingMarkers