]> git.gir.st - VimFx.git/blob - extension/packages/mode-hints/hints.coffee
Improve code style
[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 totalArea += visibleRect.area
138 visibleRects.push(visibleRect)
139
140 return null if visibleRects.length == 0
141
142 # If `element` has no area there is nothing to click, unless `element` has
143 # only one visible rect and either a width or a height. That means that
144 # everything inside `element` is floated and/or absolutely positioned (and
145 # that `element` hasn’t been made to “contain” the floats). For example, a
146 # link in a menu could contain a span of text floated to the left and an icon
147 # floated to the right. Those are still clickable. Therefore we return the
148 # shape of the first visible child instead. At least in that example, that’s
149 # the best bet.
150 if totalArea == 0 and visibleRects.length == 1
151 [ rect ] = visibleRects
152 if rect.width > 0 or rect.height > 0
153 for child in element.children
154 shape = getElementShape(window, child, viewport, parents)
155 return shape if shape
156 return null
157
158 return null if totalArea == 0
159
160 # Even if `element` has a visible rect, it might be covered by other elements.
161 for visibleRect in visibleRects
162 nonCoveredPoint = getFirstNonCoveredPoint(window, element, visibleRect,
163 parents)
164 if nonCoveredPoint
165 nonCoveredPoint.rect = visibleRect
166 break
167
168 return null unless nonCoveredPoint
169
170 return {
171 rects, visibleRects, nonCoveredPoint, area: totalArea
172 }
173
174
175 MINIMUM_EDGE_DISTANCE = 4
176 isInsideViewport = (rect, viewport) ->
177 return \
178 rect.left <= viewport.right - MINIMUM_EDGE_DISTANCE and
179 rect.top <= viewport.bottom + MINIMUM_EDGE_DISTANCE and
180 rect.right >= viewport.left + MINIMUM_EDGE_DISTANCE and
181 rect.bottom >= viewport.top - MINIMUM_EDGE_DISTANCE
182
183
184 adjustRectToViewport = (rect, viewport) ->
185 # The right and bottom values are subtracted by 1 because
186 # `document.elementFromPoint(right, bottom)` does not return the element
187 # otherwise.
188 left = Math.max(rect.left, viewport.left)
189 right = Math.min(rect.right - 1, viewport.right)
190 top = Math.max(rect.top, viewport.top)
191 bottom = Math.min(rect.bottom - 1, viewport.bottom)
192
193 width = right - left
194 height = bottom - top
195 area = width * height
196
197 return {
198 left, right, top, bottom
199 height, width, area
200 }
201
202
203 getFirstNonCoveredPoint = (window, element, elementRect, parents) ->
204 # Before we start we need to hack around a little problem. If `element` has
205 # `border-radius`, the corners won’t really belong to `element`, so
206 # `document.elementFromPoint()` will return whatever is behind. This will
207 # result in missing or out-of-place markers. The solution is to temporarily
208 # add a CSS class that removes `border-radius`.
209 element.classList.add('VimFxNoBorderRadius')
210
211 tryPoint = (x, y) ->
212 # Ensure that `element`, or a child of `element` (anything inside an `<a>`
213 # is clickable too), really is present at (x,y). Note that this is not 100%
214 # bullet proof: Combinations of CSS can cause this check to fail, even
215 # though `element` isn’t covered. We don’t try to temporarily reset such CSS
216 # (as with `border-radius`) because of performance. Instead we rely on that
217 # some of the 6 attempts below will work.
218 elementAtPoint = window.document.elementFromPoint(x, y)
219 return false unless element.contains(elementAtPoint)
220 # Note that `a.contains(a) == true`!
221
222 # If we’re currently in a frame, there might be something on top of the
223 # frame that covers `element`. Therefore we ensure that the frame really is
224 # present at the point for each parent in `parents`.
225 currentWindow = window
226 offset = left: 0, top: 0
227 for parent in parents by -1
228 offset.left += parent.offset.left
229 offset.top += parent.offset.top
230 elementAtPoint = parent.window.document.elementFromPoint(offset.left + x, offset.top + y)
231 if elementAtPoint != currentWindow.frameElement
232 return false
233 currentWindow = parent.window
234
235 return {x, y, offset}
236
237 # Try the following 6 positions in order. If all of those are covered the
238 # whole element is considered to be covered.
239 # +-------------------------------+
240 # |1 left-top right-top 4|
241 # | |
242 # |2 left-middle right-middle 5|
243 # | |
244 # |3 left-bottom right-bottom 6|
245 # +-------------------------------+
246 nonCoveredPoint =
247 tryPoint(elementRect.left, elementRect.top ) or
248 tryPoint(elementRect.left, elementRect.top + elementRect.height / 2) or
249 tryPoint(elementRect.left, elementRect.bottom ) or
250 tryPoint(elementRect.right, elementRect.top ) or
251 tryPoint(elementRect.right, elementRect.top + elementRect.height / 2) or
252 tryPoint(elementRect.right, elementRect.bottom )
253
254 element.classList.remove('VimFxNoBorderRadius')
255
256 return nonCoveredPoint
257
258
259 # Finds all stacks of markers that overlap each other (by using `getStackFor`)
260 # (#1), and rotates their `z-index`:es (#2), thus alternating which markers are
261 # visible.
262 rotateOverlappingMarkers = (originalMarkers, forward) ->
263 # Shallow working copy. This is necessary since `markers` will be mutated and
264 # eventually empty.
265 markers = originalMarkers[..]
266
267 # (#1)
268 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
269
270 # (#2)
271 # Stacks of length 1 don't participate in any overlapping, and can therefore
272 # be skipped.
273 for stack in stacks when stack.length > 1
274 # This sort is not required, but makes the rotation more predictable.
275 stack.sort((a, b) -> a.markerElement.style.zIndex -
276 b.markerElement.style.zIndex)
277
278 # Array of z-indices.
279 indexStack = (marker.markerElement.style.zIndex for marker in stack)
280 # Shift the array of indices one item forward or back.
281 if forward
282 indexStack.unshift(indexStack.pop())
283 else
284 indexStack.push(indexStack.shift())
285
286 for marker, index in stack
287 marker.markerElement.style.setProperty('z-index', indexStack[index],
288 'important')
289
290 return
291
292 # Get an array containing `marker` and all markers that overlap `marker`, if
293 # any, which is called a "stack". All markers in the returned stack are spliced
294 # out from `markers`, thus mutating it.
295 getStackFor = (marker, markers) ->
296 stack = [marker]
297
298 { top, bottom, left, right } = marker.position
299
300 index = 0
301 while index < markers.length
302 nextMarker = markers[index]
303
304 next = nextMarker.position
305 overlapsVertically = (next.bottom >= top and next.top <= bottom)
306 overlapsHorizontally = (next.right >= left and next.left <= right)
307
308 if overlapsVertically and overlapsHorizontally
309 # Also get all markers overlapping this one.
310 markers.splice(index, 1)
311 stack = stack.concat(getStackFor(nextMarker, markers))
312 else
313 # Continue the search.
314 index++
315
316 return stack
317
318
319 exports.injectHints = injectHints
320 exports.removeHints = removeHints
321 exports.rotateOverlappingMarkers = rotateOverlappingMarkers
Imprint / Impressum