]> git.gir.st - VimFx.git/blob - extension/packages/mode-hints/hints.coffee
Better handling of pseudo-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 FrameElement = Ci.nsIDOMHTMLFrameElement
11 IFrameElement = Ci.nsIDOMHTMLIFrameElement
12
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
21 # values.
22
23
24 removeHints = (document) ->
25 document.getElementById(CONTAINER_ID)?.remove()
26
27
28 injectHints = (window) ->
29 { document } = window
30
31 { clientWidth, clientHeight } = document.documentElement
32 viewport =
33 left: 0
34 top: 0
35 right: clientWidth
36 bottom: clientHeight
37 width: clientWidth
38 height: clientHeight
39 scrollX: window.scrollX
40 scrollY: window.scrollY
41 markers = createMarkers(window, viewport)
42
43 return if markers.length == 0
44
45 for marker in markers
46 marker.weight = marker.elementShape.area
47
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')
54
55 hintChars = utils.getHintChars()
56 addHuffmanCodeWordsTo(markers, {alphabet: hintChars}, (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 marker.coffee)
65 marker.setPosition(viewport)
66
67 return markers
68
69
70 createMarkers = (window, viewport, parents = []) ->
71 { document } = window
72 markers = []
73
74 # For now we aren't able to handle hint markers in XUL Documents :(
75 return [] unless document instanceof HTMLDocument # or document instanceof XULDocument
76
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.
81 continue unless shape
82
83 markers.push(new Marker(element, shape))
84
85 if element instanceof FrameElement or element instanceof IFrameElement
86 frame = element.contentWindow
87 [ rect ] = shape.rects # Frames only have one rect.
88
89 # Calculate the visible part of the frame, according to the parent.
90 { clientWidth, clientHeight } = frame.document.documentElement
91 frameViewport =
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)
96
97 computedStyle = window.getComputedStyle(frame.frameElement)
98 offset =
99 left: rect.left +
100 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
101 parseFloat(computedStyle.getPropertyValue('padding-left'))
102 top: rect.top +
103 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
104 parseFloat(computedStyle.getPropertyValue('padding-top'))
105
106 frameMarkers = createMarkers(frame, frameViewport, parents.concat({ window, offset }))
107 markers.push(frameMarkers...)
108
109 return markers
110
111 # Returns the “shape” of `element`:
112 #
113 # - `rects`: Its `.getClientRects()` rectangles.
114 # - `visibleRects`: The parts of rectangles out of the above that are inside
115 # `viewport`.
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`.
119 #
120 # Returns `null` if `element` is outside `viewport` or entirely covered by
121 # other elements.
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()
130 totalArea = 0
131 visibleRects = []
132 for rect in rects when isInsideViewport(rect, viewport)
133 visibleRect = adjustRectToViewport(rect, viewport)
134 totalArea += visibleRect.area
135 visibleRects.push(visibleRect)
136
137 return null if visibleRects.length == 0
138
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
146 # the best bet.
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
153 return null
154
155 return null if totalArea == 0
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, element, visibleRect, parents)
160 break if nonCoveredPoint
161
162 return null unless nonCoveredPoint
163
164 return {
165 rects, visibleRects, nonCoveredPoint, area: totalArea
166 }
167
168
169 MINIMUM_EDGE_DISTANCE = 4
170 isInsideViewport = (rect, viewport) ->
171 return \
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
176
177
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)
183
184 width = right - left
185 height = bottom - top
186 area = width * height
187
188 return {
189 left, right, top, bottom
190 height, width, area
191 }
192
193
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).
199 #
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)
203 #
204 # If `element` still isn’t determined to be covered, we return the point. (#C)
205 #
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)
210 #
211 # If that doesn’t find some exposed space of `element` we do the same
212 # procedure again, but downwards instead. (#E)
213 #
214 # Otherwise `element` seems to be covered to the right of `x` and below `y`.
215 # (#F)
216 #
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')
222
223 triedElements = new Set()
224
225 nonCoveredPoint = do recurse = (x = elementRect.left, y = elementRect.top) ->
226 elementAtPoint = window.document.elementFromPoint(x, y)
227
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
231
232 # (#A)
233 covered = true
234 if element.contains(elementAtPoint) # Note that `a.contains(a) == true`!
235 covered = false
236 else
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)
247 covered = false
248
249 if not covered
250 point = {x, y}
251
252 # (#B)
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
259 covered = true
260 adjustment =
261 x: point.x - x
262 y: point.y - y
263 break
264 currentWindow = parentWindow
265
266 # (#C)
267 return point unless covered
268
269 # If we have already looked around the found element, it is a waste of time
270 # to do it again.
271 return false if triedElements.has(elementAtPoint)
272 triedElements.add(elementAtPoint)
273
274 { right, bottom } = elementAtPoint.getBoundingClientRect()
275 if adjustment
276 right -= adjustment.x
277 bottom -= adjustment.y
278
279 # (#B)
280 if right < elementRect.right
281 return point if point = recurse(right + 1, y)
282
283 # (#C)
284 if bottom < elementRect.bottom
285 return point if point = recurse(x, bottom + 1)
286
287 # (#D)
288 return false
289
290 element.classList.remove('VimFxNoBorderRadius')
291
292 return nonCoveredPoint
293
294
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[..]
300
301 # (#1)
302 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
303
304 # (#2)
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)
309
310 # Array of z-indices
311 indexStack = (marker.markerElement.style.zIndex for marker in stack)
312 # Shift the array of indices one item forward or back
313 if forward
314 indexStack.unshift(indexStack.pop())
315 else
316 indexStack.push(indexStack.shift())
317
318 for marker, index in stack
319 marker.markerElement.style.setProperty('z-index', indexStack[index], 'important')
320
321 return
322
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) ->
326 stack = [marker]
327
328 { top, bottom, left, right } = marker.position
329
330 index = 0
331 while index < markers.length
332 nextMarker = markers[index]
333
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)
337
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))
342 else
343 # Continue the search
344 index++
345
346 return stack
347
348
349 exports.injectHints = injectHints
350 exports.removeHints = removeHints
351 exports.rotateOverlappingMarkers = rotateOverlappingMarkers
Imprint / Impressum