]> git.gir.st - VimFx.git/blob - extension/packages/mode-hints/hints.coffee
Improve markers for inline line-wrapped elements
[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 tryPoint = (x, y, tryRight = 0) ->
214 elementAtPoint = window.document.elementFromPoint(x, y)
215 offset = {left: 0, top: 0}
216 found = false
217
218 # Ensure that `element`, or a child of `element` (anything inside an `<a>`
219 # is clickable too), really is present at (x,y). Note that this is not 100%
220 # bullet proof: Combinations of CSS can cause this check to fail, even
221 # though `element` isn’t covered. We don’t try to temporarily reset such CSS
222 # (as with `border-radius`) because of performance. Instead we rely on that
223 # some of the attempts below will work.
224 if element.contains(elementAtPoint) # Note that `a.contains(a) == true`!
225 found = true
226 # If we’re currently in a frame, there might be something on top of the
227 # frame that covers `element`. Therefore we ensure that the frame really
228 # is present at the point for each parent in `parents`.
229 currentWindow = window
230 for parent in parents by -1
231 offset.left += parent.offset.left
232 offset.top += parent.offset.top
233 elementAtPoint = parent.window.document.elementFromPoint(
234 offset.left + x, offset.top + y
235 )
236 unless elementAtPoint == currentWindow.frameElement
237 found = false
238 break
239 currentWindow = parent.window
240
241 if found
242 return {x, y, offset}
243 else
244 return false if elementAtPoint == null or tryRight == 0
245 rect = elementAtPoint.getBoundingClientRect()
246 x = rect.right - offset.left + 1
247 return false if x > viewport.right
248 return tryPoint(x, y, tryRight - 1)
249
250
251 # Try the following 3 positions, or immediately to the right of a covering
252 # element at one of those positions, in order. If all of those are covered the
253 # whole element is considered to be covered. The reasoning is:
254 #
255 # - A marker should show up as near the left edge of its visible area as
256 # possible. Having it appear to the far right (for example) is confusing.
257 # - We can’t try too many times because of performance.
258 #
259 # +-------------------------------+
260 # |1 left-top |
261 # | |
262 # |2 left-middle |
263 # | |
264 # |3 left-bottom |
265 # +-------------------------------+
266 nonCoveredPoint =
267 tryPoint(elementRect.left, elementRect.top , 1) or
268 tryPoint(elementRect.left, elementRect.top + elementRect.height / 2, 1) or
269 tryPoint(elementRect.left, elementRect.bottom , 1)
270
271 element.classList.remove('VimFxNoBorderRadius')
272
273 return nonCoveredPoint
274
275
276 # Finds all stacks of markers that overlap each other (by using `getStackFor`)
277 # (#1), and rotates their `z-index`:es (#2), thus alternating which markers are
278 # visible.
279 rotateOverlappingMarkers = (originalMarkers, forward) ->
280 # Shallow working copy. This is necessary since `markers` will be mutated and
281 # eventually empty.
282 markers = originalMarkers[..]
283
284 # (#1)
285 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
286
287 # (#2)
288 # Stacks of length 1 don't participate in any overlapping, and can therefore
289 # be skipped.
290 for stack in stacks when stack.length > 1
291 # This sort is not required, but makes the rotation more predictable.
292 stack.sort((a, b) -> a.markerElement.style.zIndex -
293 b.markerElement.style.zIndex)
294
295 # Array of z-indices.
296 indexStack = (marker.markerElement.style.zIndex for marker in stack)
297 # Shift the array of indices one item forward or back.
298 if forward
299 indexStack.unshift(indexStack.pop())
300 else
301 indexStack.push(indexStack.shift())
302
303 for marker, index in stack
304 marker.markerElement.style.setProperty('z-index', indexStack[index],
305 'important')
306
307 return
308
309 # Get an array containing `marker` and all markers that overlap `marker`, if
310 # any, which is called a "stack". All markers in the returned stack are spliced
311 # out from `markers`, thus mutating it.
312 getStackFor = (marker, markers) ->
313 stack = [marker]
314
315 { top, bottom, left, right } = marker.position
316
317 index = 0
318 while index < markers.length
319 nextMarker = markers[index]
320
321 next = nextMarker.position
322 overlapsVertically = (next.bottom >= top and next.top <= bottom)
323 overlapsHorizontally = (next.right >= left and next.left <= right)
324
325 if overlapsVertically and overlapsHorizontally
326 # Also get all markers overlapping this one.
327 markers.splice(index, 1)
328 stack = stack.concat(getStackFor(nextMarker, markers))
329 else
330 # Continue the search.
331 index++
332
333 return stack
334
335
336 exports.injectHints = injectHints
337 exports.removeHints = removeHints
338 exports.rotateOverlappingMarkers = rotateOverlappingMarkers
Imprint / Impressum