]> git.gir.st - VimFx.git/blob - extension/lib/markable-elements.coffee
Change license to MIT
[VimFx.git] / extension / lib / markable-elements.coffee
1 # This file contains functions for getting markable elements and related data.
2
3 utils = require('./utils')
4 viewportUtils = require('./viewport')
5
6 {devtools} = Cu.import('resource://devtools/shared/Loader.jsm', {})
7
8 Element = Ci.nsIDOMElement
9 XULDocument = Ci.nsIDOMXULDocument
10
11 MIN_TEXTNODE_SIZE = 4
12
13 find = (window, filter, selector = '*') ->
14 viewport = viewportUtils.getWindowViewport(window)
15 wrappers = []
16 getMarkableElements(window, viewport, wrappers, filter, selector)
17 return wrappers
18
19 # `filter` is a function that is given every element in every frame of the page.
20 # It should return wrapper objects for markable elements and a falsy value for
21 # all other elements. All returned wrappers are added to `wrappers`. `wrappers`
22 # is modified instead of using return values to avoid array concatenation for
23 # each frame. It might sound expensive to go through _every_ element, but that’s
24 # actually what other methods like using XPath or CSS selectors would need to do
25 # anyway behind the scenes. However, it is possible to pass in a CSS selector,
26 # which allows getting markable elements in several passes with different sets
27 # of candidates.
28 getMarkableElements = (
29 window, viewport, wrappers, filter, selector, parents = []
30 ) ->
31 {document} = window
32
33 for element in getAllElements(document, selector)
34 continue unless element instanceof Element
35 # `getRects` is fast and filters out most elements, so run it first of all.
36 rects = getRects(element, viewport)
37 continue unless rects.insideViewport.length > 0
38 continue unless wrapper = filter(
39 element, (elementArg, tryRight = 1) ->
40 return getElementShape(
41 {window, viewport, parents, element: elementArg}, tryRight,
42 if elementArg == element then rects else null
43 )
44 )
45 wrappers.push(wrapper)
46
47 for frame in window.frames when frame.frameElement
48 continue unless result = viewportUtils.getFrameViewport(
49 frame.frameElement, viewport
50 )
51 {viewport: frameViewport, offset} = result
52 getMarkableElements(
53 frame, frameViewport, wrappers, filter, selector,
54 parents.concat({window, offset})
55 )
56
57 return
58
59 getAllElements = (document, selector) ->
60 unless document instanceof XULDocument
61 return document.querySelectorAll(selector)
62
63 # Use a `Set` since this algorithm may find the same element more than once.
64 # Ideally we should find a way to find all elements without duplicates.
65 elements = new Set()
66 getAllRegular = (element) ->
67 # The first time `eb` is run `.getElementsByTagName('*')` may oddly include
68 # `undefined` in its result! Filter those out. (Also, `selector` is ignored
69 # here since it doesn’t make sense in XUL documents because of all the
70 # trickery around anonymous elements.)
71 for child in element.getElementsByTagName('*') when child
72 elements.add(child)
73 getAllAnonymous(child)
74 return
75 getAllAnonymous = (element) ->
76 for child in document.getAnonymousNodes(element) or []
77 continue unless child instanceof Element
78 elements.add(child)
79 getAllRegular(child)
80 getAllAnonymous(child)
81 return
82 getAllRegular(document.documentElement)
83 return Array.from(elements)
84
85 getRects = (element, viewport) ->
86 # `element.getClientRects()` returns a list of rectangles, usually just one,
87 # which is identical to the one returned by `element.getBoundingClientRect()`.
88 # However, if `element` is inline and line-wrapped, then it returns one
89 # rectangle for each line, since each line may be of different length, for
90 # example. That allows us to properly add hints to line-wrapped links.
91 rects = element.getClientRects()
92 return {
93 all: rects,
94 insideViewport: Array.filter(
95 rects,
96 (rect) -> viewportUtils.isInsideViewport(rect, viewport)
97 )
98 }
99
100 # Returns the “shape” of an element:
101 #
102 # - `nonCoveredPoint`: The coordinates of the first point of the element that
103 # isn’t covered by another element (except children of the element). It also
104 # contains the offset needed to make those coordinates relative to the top
105 # frame, as well as the rectangle that the coordinates occur in. It is `null`
106 # if the element is outside `viewport` or entirely covered by other elements.
107 # - `area`: The area of the part of the element that is inside the viewport.
108 # - `width`: The width of the visible rect at `nonCoveredPoint`.
109 # - `textOffset`: The distance between the left edge of the element and the left
110 # edge of its text vertically near `nonCoveredPoint`. Might be `null`. The
111 # calculation might stop early if `isBlock`.
112 # - `isBlock`: `true` if the element is a block and has several lines of text
113 # (which is the case for “cards” with an image to the left and a title as well
114 # as some text to the right (where the entire “card” is a link)). This is used
115 # to place the the marker at the edge of the block.
116 getElementShape = (elementData, tryRight, rects = null) ->
117 {viewport, element} = elementData
118 result =
119 {nonCoveredPoint: null, area: 0, width: 0, textOffset: null, isBlock: false}
120
121 rects ?= getRects(element, viewport)
122 totalArea = 0
123 visibleRects = []
124 for rect, index in rects.insideViewport
125 visibleRect = viewportUtils.adjustRectToViewport(rect, viewport)
126 continue if visibleRect.area == 0
127 visibleRect.index = index
128 totalArea += visibleRect.area
129 visibleRects.push(visibleRect)
130
131 if visibleRects.length == 0
132 if rects.all.length == 1 and totalArea == 0
133 [rect] = rects.all
134 if rect.width > 0 or rect.height > 0
135 # If we get here, it means that everything inside `element` is floated
136 # and/or absolutely positioned (and that `element` hasn’t been made to
137 # “contain” the floats). For example, a link in a menu could contain a
138 # span of text floated to the left and an icon floated to the right.
139 # Those are still clickable. Therefore we return the shape of the first
140 # visible child instead. At least in that example, that’s the best bet.
141 for child in element.children
142 childData = Object.assign({}, elementData, {element: child})
143 shape = getElementShape(childData, tryRight)
144 return shape if shape
145 return result
146
147 result.area = totalArea
148
149 # Even if `element` has a visible rect, it might be covered by other elements.
150 nonCoveredPoint = null
151 nonCoveredPointRect = null
152 for visibleRect in visibleRects
153 nonCoveredPoint = getFirstNonCoveredPoint(
154 elementData, visibleRect, tryRight
155 )
156 if nonCoveredPoint
157 nonCoveredPointRect = visibleRect
158 break
159
160 return result unless nonCoveredPoint
161 result.nonCoveredPoint = nonCoveredPoint
162
163 result.width = nonCoveredPointRect.width
164
165 lefts = []
166 smallestBottom = Infinity
167 hasSingleRect = (rects.all.length == 1)
168
169 utils.walkTextNodes(element, (node) ->
170 unless node.data.trim() == ''
171 for {bounds} in node.getBoxQuads()
172 if bounds.width < MIN_TEXTNODE_SIZE or bounds.height < MIN_TEXTNODE_SIZE
173 continue
174
175 if utils.overlaps(bounds, nonCoveredPointRect)
176 lefts.push(bounds.left)
177
178 if hasSingleRect
179 # The element is likely a block and has several lines of text; ignore
180 # the `textOffset` (see the description of `textOffset` at the
181 # beginning of the function).
182 if bounds.top > smallestBottom
183 result.isBlock = true
184 return true
185
186 if bounds.bottom < smallestBottom
187 smallestBottom = bounds.bottom
188
189 return false
190 )
191
192 if lefts.length > 0
193 result.textOffset =
194 Math.round(Math.min(lefts...) - nonCoveredPointRect.left)
195
196 return result
197
198 getFirstNonCoveredPoint = (elementData, elementRect, tryRight) ->
199 # Try the left-middle point, or immediately to the right of a covering element
200 # at that point (when `tryRight == 1`). If both of those are covered the whole
201 # element is considered to be covered. The reasoning is:
202 #
203 # - A marker should show up as near the left edge of its visible area as
204 # possible. Having it appear to the far right (for example) is confusing.
205 # - We can’t try too many times because of performance.
206 # - We used to try left-top first, but if the element has `border-radius`, the
207 # corners won’t belong to the element, so `document.elementFromPoint()` will
208 # return whatever is behind. One _could_ temporarily add a CSS class that
209 # removes `border-radius`, but that turned out to be too slow. Trying
210 # left-middle instead avoids the problem, and looks quite nice, actually.
211 # - We used to try left-bottom as well, but that is so rare that it’s not
212 # worth it.
213 #
214 # It is safer to try points at least one pixel into the element from the
215 # edges, hence the `+1`.
216 {left, top, bottom, height} = elementRect
217 return tryPoint(
218 elementData, elementRect,
219 left, +1, Math.round(top + height / 2), 0, tryRight
220 )
221
222 # Tries a point `(x + dx, y + dy)`. Returns `(x, y)` (and the frame offset) if
223 # the element passes the tests. Otherwise it tries to the right of whatever is
224 # at `(x, y)`, `tryRight` times . If nothing succeeds, `false` is returned. `dx`
225 # and `dy` are used to offset the wanted point `(x, y)` while trying.
226 tryPoint = (elementData, elementRect, x, dx, y, dy, tryRight = 0) ->
227 {window, viewport, parents, element} = elementData
228 elementAtPoint = window.document.elementFromPoint(x + dx, y + dy)
229 offset = {left: 0, top: 0}
230 found = false
231 firstLevel = true
232
233 # Ensure that `element`, or a child of `element` (anything inside an `<a>` is
234 # clickable too), really is present at (x,y). Note that this is not 100%
235 # bullet proof: Combinations of CSS can cause this check to fail, even though
236 # `element` isn’t covered. We don’t try to temporarily reset such CSS because
237 # of performance. (See further down for the special value `-1` of `tryRight`.)
238 if contains(element, elementAtPoint) or tryRight == -1
239 found = true
240 # If we’re currently in a frame, there might be something on top of the
241 # frame that covers `element`. Therefore we ensure that the frame really is
242 # present at the point for each parent in `parents`.
243 currentWindow = window
244 for parent in parents by -1
245 # If leaving the devtools container take the devtools zoom into account.
246 if utils.isDevtoolsWindow(currentWindow)
247 docShell = currentWindow
248 .QueryInterface(Ci.nsIInterfaceRequestor)
249 .getInterface(Ci.nsIWebNavigation)
250 .QueryInterface(Ci.nsIDocShell)
251 if docShell
252 devtoolsZoom = docShell.contentViewer.fullZoom
253 offset.left *= devtoolsZoom
254 offset.top *= devtoolsZoom
255 x *= devtoolsZoom
256 y *= devtoolsZoom
257 dx *= devtoolsZoom
258 dy *= devtoolsZoom
259
260 offset.left += parent.offset.left
261 offset.top += parent.offset.top
262 elementAtPoint = parent.window.document.elementFromPoint(
263 offset.left + x + dx, offset.top + y + dy
264 )
265 firstLevel = false
266 unless contains(currentWindow.frameElement, elementAtPoint)
267 found = false
268 break
269 currentWindow = parent.window
270
271 return {x, y, offset} if found
272
273 return false if elementAtPoint == null or tryRight <= 0
274 rect = elementAtPoint.getBoundingClientRect()
275
276 # `.getBoundingClientRect()` does not include pseudo-elements that are
277 # absolutely positioned so that they go outside of the element (which is
278 # common for `/###\`-looking tabs), but calling `.elementAtPoint()` on the
279 # pseudo-element _does_ return the element. This means that the covering
280 # element’s _rect_ won’t cover the element we’re looking for. If so, it’s
281 # better to try again, forcing the element to be considered located at this
282 # point. That’s what `-1` for the `tryRight` argument means. This is also used
283 # in the 'complementary' pass, to include elements considered covered in
284 # earlier passes (which might have been false positives).
285 if firstLevel and rect.right <= x + offset.left
286 return tryPoint(elementData, elementRect, x, dx, y, dy, -1)
287
288 # If `elementAtPoint` is a parent to `element`, it most likely means that
289 # `element` is hidden some way. It can also mean that a pseudo-element of
290 # `elementAtPoint` covers `element` partly. Therefore, try once at the most
291 # likely point: The center of the part of the rect to the right of `x`.
292 if elementRect.right > x and contains(elementAtPoint, element)
293 return tryPoint(
294 elementData, elementRect,
295 (x + elementRect.right) / 2, 0, y, 0, 0
296 )
297
298 newX = rect.right - offset.left + 1
299 return false if newX > viewport.right or newX > elementRect.right
300 return tryPoint(elementData, elementRect, newX, 0, y, 0, tryRight - 1)
301
302 # In XUL documents there are “anonymous” elements. These are never returned by
303 # `document.elementFromPoint` but their closest non-anonymous parents are.
304 normalize = (element) ->
305 normalized = element.ownerDocument.getBindingParent(element) or element
306 normalized = normalized.parentNode while normalized.prefix?
307 return normalized
308
309 # Returns whether `element` corresponds to `elementAtPoint`. This is only
310 # complicated for browser elements in the web page content area.
311 # `.elementAtPoint()` always returns `<tabbrowser#content>` then. The element
312 # might be in another tab and thus invisible, but `<tabbrowser#content>` is the
313 # same and visible in _all_ tabs, so we have to check that the element really
314 # belongs to the current tab.
315 contains = (element, elementAtPoint) ->
316 return false unless elementAtPoint
317 container = normalize(element)
318 if elementAtPoint.localName == 'tabbrowser' and elementAtPoint.id == 'content'
319 {gBrowser} = element.ownerGlobal.top
320 tabpanel = gBrowser.getNotificationBox(gBrowser.selectedBrowser)
321 return tabpanel.contains(element)
322 else
323 # Note that `a.contains(a)` is supposed to be true, but strangely aren’t for
324 # `<menulist>`s in the Add-ons Manager, so do a direct comparison as well.
325 return container == elementAtPoint or container.contains(elementAtPoint)
326
327 module.exports = {
328 find
329 }
Imprint / Impressum