]> git.gir.st - VimFx.git/blob - extension/packages/mode-hints/hints.coffee
Improve marker generation. Fix #325.
[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 top-level window.
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 # 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
144
145 for visibleRect in visibleRects
146 nonCoveredPoint = getFirstNonCoveredPoint(window, element, visibleRect, parents)
147 break if nonCoveredPoint
148
149 return null unless nonCoveredPoint
150
151 return {
152 rects, visibleRects, nonCoveredPoint, area: totalArea
153 }
154
155
156 MINIMUM_EDGE_DISTANCE = 4
157 isInsideViewport = (rect, viewport) ->
158 return \
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
163
164
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)
170
171 width = right - left
172 height = bottom - top
173 area = width * height
174
175 return {
176 left, right, top, bottom
177 height, width, area
178 }
179
180
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).
186 #
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)
190 #
191 # If `element` still isn’t determined to be covered, we return the point. (#C)
192 #
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)
197 #
198 # If that doesn’t find some exposed space of `element` we do the same
199 # procedure again, but downwards instead. (#E)
200 #
201 # Otherwise `element` seems to be covered to the right of `x` and below `y`.
202 # (#F)
203 #
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')
209
210 triedElements = new Set()
211
212 nonCoveredPoint = do recurse = (x = elementRect.left, y = elementRect.top) ->
213 # (#A)
214 elementAtPoint = window.document.elementFromPoint(x, y)
215 if element.contains(elementAtPoint) # Note that `a.contains(a) == true`!
216 covered = false
217 point = {x, y}
218
219 # (#B)
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
226 covered = true
227 adjustment =
228 x: point.x - x
229 y: point.y - y
230 break
231 currentWindow = parentWindow
232
233 # (#C)
234 return point unless covered
235
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
239
240 # If we have already looked around the found element, it is a waste of time
241 # to do it again.
242 return false if triedElements.has(elementAtPoint)
243 triedElements.add(elementAtPoint)
244
245 { right, bottom } = elementAtPoint.getBoundingClientRect()
246 if adjustment
247 right -= adjustment.x
248 bottom -= adjustment.y
249
250 # (#B)
251 if right < elementRect.right
252 return point if point = recurse(right + 1, y)
253
254 # (#C)
255 if bottom < elementRect.bottom
256 return point if point = recurse(x, bottom + 1)
257
258 # (#D)
259 return false
260
261 element.classList.remove('VimFxNoBorderRadius')
262
263 return nonCoveredPoint
264
265
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[..]
271
272 # (#1)
273 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
274
275 # (#2)
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)
280
281 # Array of z-indices
282 indexStack = (marker.markerElement.style.zIndex for marker in stack)
283 # Shift the array of indices one item forward or back
284 if forward
285 indexStack.unshift(indexStack.pop())
286 else
287 indexStack.push(indexStack.shift())
288
289 for marker, index in stack
290 marker.markerElement.style.setProperty('z-index', indexStack[index], 'important')
291
292 return
293
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) ->
297 stack = [marker]
298
299 { top, bottom, left, right } = marker.position
300
301 index = 0
302 while index < markers.length
303 nextMarker = markers[index]
304
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)
308
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))
313 else
314 # Continue the search
315 index++
316
317 return stack
318
319
320 exports.injectHints = injectHints
321 exports.removeHints = removeHints
322 exports.rotateOverlappingMarkers = rotateOverlappingMarkers
Imprint / Impressum