]> git.gir.st - VimFx.git/blob - extension/packages/mode-hints/hints.coffee
Enhance marker frame handling
[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
16 # for its top bar. So by using 2147480001 as a base, we trump that value with
17 # lots of margin, still leaving a few thousand values for markers, which should
18 # be 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 marker overlaps another.
47 # Put more important markers (higher weight) at the end, so that they get higher `z-index`, in
48 # 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, 'important')
52
53 hintChars = utils.getHintChars()
54 addHuffmanCodeWordsTo(markers, {alphabet: hintChars}, (marker, hint) -> marker.setHint(hint))
55
56 removeHints(document)
57 container = utils.createElement(document, 'div', {id: CONTAINER_ID})
58 document.documentElement.appendChild(container)
59
60 for marker in markers
61 container.appendChild(marker.markerElement)
62 # Must be done after the hints have been inserted into the DOM (see marker.coffee)
63 marker.setPosition(viewport)
64
65 return markers
66
67
68 createMarkers = (window, viewport, parents = []) ->
69 { document } = window
70 markers = []
71
72 # For now we aren't able to handle hint markers in XUL Documents :(
73 return [] unless document instanceof HTMLDocument # or document instanceof XULDocument
74
75 candidates = utils.getMarkableElements(document, {type: 'all'})
76 for element in candidates
77 shape = getElementShape(window, element, viewport, parents)
78 # If `element` has no visible shape then it shouldn’t get any marker.
79 continue unless shape
80
81 markers.push(new Marker(element, shape))
82
83 for frame in window.frames
84 rect = frame.frameElement.getBoundingClientRect() # Frames only have one rect.
85 continue unless isInsideViewport(rect, viewport)
86
87 # Calculate the visible part of the frame, according to the parent.
88 { clientWidth, clientHeight } = frame.document.documentElement
89 frameViewport =
90 left: Math.max(viewport.left - rect.left, 0)
91 top: Math.max(viewport.top - rect.top, 0)
92 right: clientWidth + Math.min(viewport.right - rect.right, 0)
93 bottom: clientHeight + Math.min(viewport.bottom - rect.bottom, 0)
94
95 computedStyle = window.getComputedStyle(frame.frameElement)
96 offset =
97 left: rect.left +
98 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
99 parseFloat(computedStyle.getPropertyValue('padding-left'))
100 top: rect.top +
101 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
102 parseFloat(computedStyle.getPropertyValue('padding-top'))
103
104 frameMarkers = createMarkers(frame, frameViewport, parents.concat({ window, offset }))
105 markers.push(frameMarkers...)
106
107 return markers
108
109 # Returns the “shape” of `element`:
110 #
111 # - `rects`: Its `.getClientRects()` rectangles.
112 # - `visibleRects`: The parts of rectangles out of the above that are inside
113 # `viewport`.
114 # - `nonCoveredPoint`: The coordinates of the first point of `element` that
115 # isn’t covered by another element (except children of `element`). It also
116 # contains the offset needed to make those coordinates relative to the top
117 # frame, as well as the rectangle that the coordinates occur in.
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 if nonCoveredPoint
161 nonCoveredPoint.rect = visibleRect
162 break
163
164 return null unless nonCoveredPoint
165
166 return {
167 rects, visibleRects, nonCoveredPoint, area: totalArea
168 }
169
170
171 MINIMUM_EDGE_DISTANCE = 4
172 isInsideViewport = (rect, viewport) ->
173 return \
174 rect.left <= viewport.right - MINIMUM_EDGE_DISTANCE and
175 rect.top <= viewport.bottom + MINIMUM_EDGE_DISTANCE and
176 rect.right >= viewport.left + MINIMUM_EDGE_DISTANCE and
177 rect.bottom >= viewport.top - MINIMUM_EDGE_DISTANCE
178
179
180 adjustRectToViewport = (rect, viewport) ->
181 # The right and bottom values are subtracted by 1 because
182 # `document.elementFromPoint(right, bottom)` does not return the element
183 # otherwise.
184 left = Math.max(rect.left, viewport.left)
185 right = Math.min(rect.right - 1, viewport.right)
186 top = Math.max(rect.top, viewport.top)
187 bottom = Math.min(rect.bottom - 1, viewport.bottom)
188
189 width = right - left
190 height = bottom - top
191 area = width * height
192
193 return {
194 left, right, top, bottom
195 height, width, area
196 }
197
198
199 getFirstNonCoveredPoint = (window, element, elementRect, parents) ->
200 # Before we start we need to hack around a little problem. If `element` has
201 # `border-radius`, the corners won’t really belong to `element`, so
202 # `document.elementFromPoint()` will return whatever is behind. This will
203 # result in missing or out-of-place markers. The solution is to temporarily
204 # add a CSS class that removes `border-radius`.
205 element.classList.add('VimFxNoBorderRadius')
206
207 tryPoint = (x, y) ->
208 # Ensure that `element`, or a child of `element` (anything inside an `<a>`
209 # is clickable too), really is present at (x,y). Note that this is not 100%
210 # bullet proof: Combinations of CSS can cause this check to fail, even
211 # though `element` isn’t covered. We don’t try to temporarily reset such
212 # CSS (as with `border-radius`) because of performance. Instead we rely on
213 # that some of the 6 attempts below will work.
214 elementAtPoint = window.document.elementFromPoint(x, y)
215 return false unless element.contains(elementAtPoint) # Note that `a.contains(a) == true`!
216
217 # If we’re currently in a frame, there might be something on top of the
218 # frame that covers `element`. Therefore we ensure that the frame really is
219 # present at the point for each parent in `parents`.
220 currentWindow = window
221 offset = left: 0, top: 0
222 for parent in parents by -1
223 offset.left += parent.offset.left
224 offset.top += parent.offset.top
225 elementAtPoint = parent.window.document.elementFromPoint(offset.left + x, offset.top + y)
226 if elementAtPoint != currentWindow.frameElement
227 return false
228 currentWindow = parent.window
229
230 return {x, y, offset}
231
232 # Try the following 6 positions in order. If all of those are covered the
233 # whole element is considered to be covered.
234 # +-------------------------------+
235 # |1 left-top right-top 4|
236 # | |
237 # |2 left-middle right-middle 5|
238 # | |
239 # |3 left-bottom right-bottom 6|
240 # +-------------------------------+
241 nonCoveredPoint =
242 tryPoint(elementRect.left, elementRect.top ) or
243 tryPoint(elementRect.left, elementRect.top + elementRect.height / 2) or
244 tryPoint(elementRect.left, elementRect.bottom ) or
245 tryPoint(elementRect.right, elementRect.top ) or
246 tryPoint(elementRect.right, elementRect.top + elementRect.height / 2) or
247 tryPoint(elementRect.right, elementRect.bottom )
248
249 element.classList.remove('VimFxNoBorderRadius')
250
251 return nonCoveredPoint
252
253
254 # Finds all stacks of markers that overlap each other (by using `getStackFor`) (#1), and rotates
255 # their `z-index`:es (#2), thus alternating which markers are visible.
256 rotateOverlappingMarkers = (originalMarkers, forward) ->
257 # Shallow working copy. This is necessary since `markers` will be mutated and eventually empty.
258 markers = originalMarkers[..]
259
260 # (#1)
261 stacks = (getStackFor(markers.pop(), markers) while markers.length > 0)
262
263 # (#2)
264 # Stacks of length 1 don't participate in any overlapping, and can therefore be skipped.
265 for stack in stacks when stack.length > 1
266 # This sort is not required, but makes the rotation more predictable.
267 stack.sort((a, b) -> a.markerElement.style.zIndex - b.markerElement.style.zIndex)
268
269 # Array of z-indices
270 indexStack = (marker.markerElement.style.zIndex for marker in stack)
271 # Shift the array of indices one item forward or back
272 if forward
273 indexStack.unshift(indexStack.pop())
274 else
275 indexStack.push(indexStack.shift())
276
277 for marker, index in stack
278 marker.markerElement.style.setProperty('z-index', indexStack[index], 'important')
279
280 return
281
282 # Get an array containing `marker` and all markers that overlap `marker`, if any, which is called
283 # a "stack". All markers in the returned stack are spliced out from `markers`, thus mutating it.
284 getStackFor = (marker, markers) ->
285 stack = [marker]
286
287 { top, bottom, left, right } = marker.position
288
289 index = 0
290 while index < markers.length
291 nextMarker = markers[index]
292
293 { top: nextTop, bottom: nextBottom, left: nextLeft, right: nextRight } = nextMarker.position
294 overlapsVertically = (nextBottom >= top and nextTop <= bottom)
295 overlapsHorizontally = (nextRight >= left and nextLeft <= right)
296
297 if overlapsVertically and overlapsHorizontally
298 # Also get all markers overlapping this one
299 markers.splice(index, 1)
300 stack = stack.concat(getStackFor(nextMarker, markers))
301 else
302 # Continue the search
303 index++
304
305 return stack
306
307
308 exports.injectHints = injectHints
309 exports.removeHints = removeHints
310 exports.rotateOverlappingMarkers = rotateOverlappingMarkers
Imprint / Impressum