]> git.gir.st - VimFx.git/blob - extension/packages/mode-hints/hints.coffee
Merge pull request #330 from lydell/better-hintmarkers
[VimFx.git] / extension / packages / mode-hints / hints.coffee
1 utils = require 'utils'
2 { getPref } = require 'prefs'
3 { Marker } = require 'mode-hints/marker'
4 { addHuffmanCodeWordsTo } = require 'mode-hints/huffman'
5
6 { interfaces: Ci } = Components
7
8 HTMLDocument = Ci.nsIDOMHTMLDocument
9 XULDocument = Ci.nsIDOMXULDocument
10
11 CONTAINER_ID = 'VimFxHintMarkerContainer'
12 Z_INDEX_START = 2147480001 # The highest `z-index` used in style.css plus one.
13 # In theory, `z-index` can be infinitely large. In practice, Firefox uses a
14 # 32-bit signed integer to store it, so the maximum value is 2147483647
15 # (http://www.puidokas.com/max-z-index/). Youtube (insanely) uses 1999999999 for
16 # its top bar. So by using 2147480001 as a base, we trump that value with lots
17 # of margin, still leaving a few thousand values for markers, which should be
18 # more than enough. Hopefully no sites are crazy enough to use even higher
19 # values.
20
21
22 removeHints = (document) ->
23 document.getElementById(CONTAINER_ID)?.remove()
24
25
26 injectHints = (window) ->
27 { document } = window
28
29 { clientWidth, clientHeight } = document.documentElement
30 viewport =
31 left: 0
32 top: 0
33 right: clientWidth
34 bottom: clientHeight
35 width: clientWidth
36 height: clientHeight
37 scrollX: window.scrollX
38 scrollY: window.scrollY
39 markers = createMarkers(window, viewport)
40
41 return if markers.length == 0
42
43 for marker in markers
44 marker.weight = marker.elementShape.area
45
46 # Each marker gets a unique `z-index`, so that it can be determined if a
47 # marker overlaps another. Put more important markers (higher weight) at the
48 # end, so that they get higher `z-index`, in order not to be overlapped.
49 markers.sort((a, b) -> a.weight - b.weight)
50 for marker, index in markers
51 marker.markerElement.style.setProperty('z-index', Z_INDEX_START + index,
52 'important')
53
54 hintChars = utils.getHintChars()
55 addHuffmanCodeWordsTo(markers, {alphabet: hintChars},
56 (marker, hint) -> marker.setHint(hint))
57
58 removeHints(document)
59 container = utils.createElement(document, 'div', {id: CONTAINER_ID})
60 document.documentElement.appendChild(container)
61
62 for marker in markers
63 container.appendChild(marker.markerElement)
64 # Must be done after the hints have been inserted into the DOM (see
65 # marker.coffee).
66 marker.setPosition(viewport)
67
68 return markers
69
70
71 createMarkers = (window, viewport, parents = []) ->
72 { document } = window
73 markers = []
74
75 # For now we aren't able to handle hint markers in XUL Documents :(
76 return [] unless document instanceof HTMLDocument # or document instanceof XULDocument
77
78 candidates = utils.getMarkableElements(document, {type: 'all'})
79 for element in candidates
80 shape = getElementShape(window, element, viewport, parents)
81 # If `element` has no visible shape then it shouldn’t get any marker.
82 continue unless shape
83
84 markers.push(new Marker(element, shape))
85
86 for frame in window.frames
87 rect = frame.frameElement.getBoundingClientRect() # Frames only have one.
88 continue unless isInsideViewport(rect, viewport)
89
90 # Calculate the visible part of the frame, according to the parent.
91 { clientWidth, clientHeight } = frame.document.documentElement
92 frameViewport =
93 left: Math.max(viewport.left - rect.left, 0)
94 top: Math.max(viewport.top - rect.top, 0)
95 right: clientWidth + Math.min(viewport.right - rect.right, 0)
96 bottom: clientHeight + Math.min(viewport.bottom - rect.bottom, 0)
97
98 computedStyle = window.getComputedStyle(frame.frameElement)
99 offset =
100 left: rect.left +
101 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
102 parseFloat(computedStyle.getPropertyValue('padding-left'))
103 top: rect.top +
104 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
105 parseFloat(computedStyle.getPropertyValue('padding-top'))
106
107 frameMarkers = createMarkers(frame, frameViewport,
108 parents.concat({ window, offset }))
109 markers.push(frameMarkers...)
110
111 return markers
112
113 # Returns the “shape” of `element`:
114 #
115 # - `rects`: Its `.getClientRects()` rectangles.
116 # - `visibleRects`: The parts of rectangles out of the above that are inside
117 # `viewport`.
118 # - `nonCoveredPoint`: The coordinates of the first point of `element` that
119 # isn’t covered by another element (except children of `element`). It also
120 # contains the offset needed to make those coordinates relative to the top
121 # frame, as well as the rectangle that the coordinates occur in.
122 # - `area`: The area of the part of `element` that is inside `viewport`.
123 #
124 # Returns `null` if `element` is outside `viewport` or entirely covered by other
125 # elements.
126 getElementShape = (window, element, viewport, parents) ->
127 # `element.getClientRects()` returns a list of rectangles, usually just one,
128 # which is identical to the one returned by `element.getBoundingClientRect()`.
129 # However, if `element` is inline and line-wrapped, then it returns one
130 # rectangle for each line, since each line may be of different length, for
131 # example. That allows us to properly add hints to line-wrapped links.
132 rects = element.getClientRects()
133 totalArea = 0
134 visibleRects = []
135 for rect in rects when isInsideViewport(rect, viewport)
136 visibleRect = adjustRectToViewport(rect, viewport)
137 continue if visibleRect.area == 0
138 totalArea += visibleRect.area
139 visibleRects.push(visibleRect)
140
141 if visibleRects.length == 0
142 if rects.length == 1 and totalArea == 0
143 [ rect ] = rects
144 if rect.width > 0 or rect.height > 0
145 # If we get here, it means that everything inside `element` is floated
146 # and/or absolutely positioned (and that `element` hasn’t been made to
147 # “contain” the floats). For example, a link in a menu could contain a
148 # span of text floated to the left and an icon floated to the right.
149 # Those are still clickable. Therefore we return the shape of the first
150 # visible child instead. At least in that example, that’s the best bet.
151 for child in element.children
152 shape = getElementShape(window, child, viewport, parents)
153 return shape if shape
154 return null
155
156
157 # Even if `element` has a visible rect, it might be covered by other elements.
158 for visibleRect in visibleRects
159 nonCoveredPoint = getFirstNonCoveredPoint(window, viewport, element,
160 visibleRect, parents)
161 if nonCoveredPoint
162 nonCoveredPoint.rect = visibleRect
163 break
164
165 return null unless nonCoveredPoint
166
167 return {
168 rects, visibleRects, nonCoveredPoint, area: totalArea
169 }
170
171
172 MINIMUM_EDGE_DISTANCE = 4
173 isInsideViewport = (rect, viewport) ->
174 return \
175 rect.left <= viewport.right - MINIMUM_EDGE_DISTANCE and
176 rect.top <= viewport.bottom + MINIMUM_EDGE_DISTANCE and
177 rect.right >= viewport.left + MINIMUM_EDGE_DISTANCE and
178 rect.bottom >= viewport.top - MINIMUM_EDGE_DISTANCE
179
180
181 adjustRectToViewport = (rect, viewport) ->
182 # The right and bottom values are subtracted by 1 because
183 # `document.elementFromPoint(right, bottom)` does not return the element
184 # otherwise.
185 left = Math.max(rect.left, viewport.left)
186 right = Math.min(rect.right - 1, viewport.right)
187 top = Math.max(rect.top, viewport.top)
188 bottom = Math.min(rect.bottom - 1, viewport.bottom)
189
190 # Make sure that `right >= left and bottom >= top`, since we subtracted by 1
191 # above.
192 right = Math.max(right, left)
193 bottom = Math.max(bottom, top)
194
195 width = right - left
196 height = bottom - top
197 area = Math.floor(width * height)
198
199 return {
200 left, right, top, bottom
201 height, width, area
202 }
203
204
205 getFirstNonCoveredPoint = (window, viewport, element, elementRect, parents) ->
206 # Before we start we need to hack around a little problem. If `element` has
207 # `border-radius`, the corners won’t really belong to `element`, so
208 # `document.elementFromPoint()` will return whatever is behind. This will
209 # result in missing or out-of-place markers. The solution is to temporarily
210 # add a CSS class that removes `border-radius`.
211 element.classList.add('VimFxNoBorderRadius')
212
213 # Tries a point `(x + dx, y + dy)`. Returns `(x, y)` (and the frame offset)
214 # if it passes the tests. Otherwise it tries to the right of whatever is at
215 # `(x, y)`, `tryRight` times . If nothing succeeds, `false` is returned. `dx`
216 # and `dy` are used to offset the wanted point `(x, y)` while trying (see the
217 # invocations of `tryPoint` below).
218 tryPoint = (x, dx, y, dy, tryRight = 0) ->
219 elementAtPoint = window.document.elementFromPoint(x + dx, y + dy)
220 offset = {left: 0, top: 0}
221 found = false
222
223 # Ensure that `element`, or a child of `element` (anything inside an `<a>`
224 # is clickable too), really is present at (x,y). Note that this is not 100%
225 # bullet proof: Combinations of CSS can cause this check to fail, even
226 # though `element` isn’t covered. We don’t try to temporarily reset such CSS
227 # (as with `border-radius`) because of performance. Instead we rely on that
228 # some of the attempts below will work.
229 if element.contains(elementAtPoint) # Note that `a.contains(a) == true`!
230 found = true
231 # If we’re currently in a frame, there might be something on top of the
232 # frame that covers `element`. Therefore we ensure that the frame really
233 # is present at the point for each parent in `parents`.
234 currentWindow = window
235 for parent in parents by -1
236 offset.left += parent.offset.left
237 offset.top += parent.offset.top
238 elementAtPoint = parent.window.document.elementFromPoint(
239 offset.left + x + dx, offset.top + y + dy
240 )
241 unless elementAtPoint == currentWindow.frameElement
242 found = false
243 break
244 currentWindow = parent.window
245
246 if found
247 return {x, y, offset}
248 else
249 return false if elementAtPoint == null or tryRight == 0
250 rect = elementAtPoint.getBoundingClientRect()
251 x = rect.right - offset.left + 1
252 return false if x > viewport.right
253 return tryPoint(x, 0, y, 0, tryRight - 1)
254
255
256 # Try the following 3 positions, or immediately to the right of a covering
257 # element at one of those positions, in order. If all of those are covered the
258 # whole element is considered to be covered. The reasoning is:
259 #
260 # - A marker should show up as near the left edge of its visible area as
261 # possible. Having it appear to the far right (for example) is confusing.
262 # - We can’t try too many times because of performance.
263 #
264 # +-------------------------------+
265 # |1 left-top |
266 # | |
267 # |2 left-middle |
268 # | |
269 # |3 left-bottom |
270 # +-------------------------------+
271 #
272 # It is safer to try points at least one pixel into the element from the
273 # edges, hence the `+1`s and `-1`s.
274 { left, top, bottom, height } = elementRect
275 nonCoveredPoint =
276 tryPoint(left, +1, top, +1, 1) or
277 tryPoint(left, +1, top + height / 2, 0, 1) or
278 tryPoint(left, +1, bottom, -1, 1)
279
280 element.classList.remove('VimFxNoBorderRadius')
281
282 return nonCoveredPoint
283
284
285 # Finds all stacks of markers that overlap each other (by using `getStackFor`)
286 # (#1), and rotates their `z-index`:es (#2), thus alternating which markers are
287 # visible.
288 rotateOverlappingMarkers = (originalMarkers, forward) ->
289 # Shallow working copy. This is necessary since `markers` will be mutated and
290 # eventually empty.
291 markers = originalMarkers[..]
292
293 # (#1)
294 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
295
296 # (#2)
297 # Stacks of length 1 don't participate in any overlapping, and can therefore
298 # be skipped.
299 for stack in stacks when stack.length > 1
300 # This sort is not required, but makes the rotation more predictable.
301 stack.sort((a, b) -> a.markerElement.style.zIndex -
302 b.markerElement.style.zIndex)
303
304 # Array of z-indices.
305 indexStack = (marker.markerElement.style.zIndex for marker in stack)
306 # Shift the array of indices one item forward or back.
307 if forward
308 indexStack.unshift(indexStack.pop())
309 else
310 indexStack.push(indexStack.shift())
311
312 for marker, index in stack
313 marker.markerElement.style.setProperty('z-index', indexStack[index],
314 'important')
315
316 return
317
318 # Get an array containing `marker` and all markers that overlap `marker`, if
319 # any, which is called a "stack". All markers in the returned stack are spliced
320 # out from `markers`, thus mutating it.
321 getStackFor = (marker, markers) ->
322 stack = [marker]
323
324 { top, bottom, left, right } = marker.position
325
326 index = 0
327 while index < markers.length
328 nextMarker = markers[index]
329
330 next = nextMarker.position
331 overlapsVertically = (next.bottom >= top and next.top <= bottom)
332 overlapsHorizontally = (next.right >= left and next.left <= right)
333
334 if overlapsVertically and overlapsHorizontally
335 # Also get all markers overlapping this one.
336 markers.splice(index, 1)
337 stack = stack.concat(getStackFor(nextMarker, markers))
338 else
339 # Continue the search.
340 index++
341
342 return stack
343
344
345 exports.injectHints = injectHints
346 exports.removeHints = removeHints
347 exports.rotateOverlappingMarkers = rotateOverlappingMarkers
Imprint / Impressum