]> git.gir.st - VimFx.git/blob - extension/lib/viewport.coffee
Don't crash on pages with out-of-process iframes when fission is enabled
[VimFx.git] / extension / lib / viewport.coffee
1 # This file provides utility functions for working with the viewport.
2
3 utils = require('./utils')
4
5 MIN_EDGE_DISTANCE = 4
6
7 getPosition = (element) ->
8 computedStyle = element.ownerGlobal.getComputedStyle(element)
9 return computedStyle?.getPropertyValue('position')
10
11 isFixed = (element) -> getPosition(element) == 'fixed'
12
13 isFixedOrAbsolute = (element) -> getPosition(element) in ['fixed', 'absolute']
14
15 adjustRectToViewport = (rect, viewport) ->
16 # The right and bottom values are subtracted by 1 because
17 # `document.elementFromPoint(right, bottom)` does not return the element
18 # otherwise.
19 left = Math.max(rect.left, viewport.left)
20 right = Math.min(rect.right - 1, viewport.right)
21 top = Math.max(rect.top, viewport.top)
22 bottom = Math.min(rect.bottom - 1, viewport.bottom)
23
24 # Make sure that `right >= left and bottom >= top`, since we subtracted by 1
25 # above.
26 right = Math.max(right, left)
27 bottom = Math.max(bottom, top)
28
29 width = right - left
30 height = bottom - top
31 area = Math.round(width * height)
32
33 return {
34 left, right, top, bottom
35 height, width, area
36 }
37
38 getAllRangesInsideViewport = (window, viewport, offset = {left: 0, top: 0}) ->
39 selection = window.getSelection()
40 {rangeCount} = selection
41 ranges = []
42
43 if rangeCount > 0
44 {header, headerBottom, footer, footerTop} =
45 getFixedHeaderAndFooter(window, viewport)
46
47 # Many sites use `text-indent: -9999px;` or similar to hide text intended
48 # for screen readers only. That text can still be selected and found by
49 # searching, though. Therefore, we have to allow selection ranges that are
50 # within the viewport vertically but not horizontally, even though they
51 # actually are outside the viewport. Otherwise you won’t be able to press
52 # `n` to get past those elements (instead, `n` would start over from the top
53 # of the viewport).
54 for index in [0...rangeCount]
55 range = selection.getRangeAt(index)
56 continue if range.collapsed
57 rect = range.getBoundingClientRect()
58 if (rect.top >= headerBottom - MIN_EDGE_DISTANCE and
59 rect.bottom <= footerTop + MIN_EDGE_DISTANCE) or
60 header?.contains(range.commonAncestorContainer) or
61 footer?.contains(range.commonAncestorContainer)
62 adjustedRect = {
63 left: offset.left + rect.left
64 top: offset.top + rect.top
65 right: offset.left + rect.right
66 bottom: offset.top + rect.bottom
67 width: rect.width
68 height: rect.height
69 }
70 ranges.push({range, rect: adjustedRect})
71
72 # Note: accessing frameElement fails on oop iframes (fission), so we skip them
73 for frame in window.frames when (try frame.frameElement)
74 {viewport: frameViewport, offset: frameOffset} =
75 getFrameViewport(frame.frameElement, viewport) ? {}
76 continue unless frameViewport
77 newOffset = {
78 left: offset.left + frameOffset.left
79 top: offset.top + frameOffset.top
80 }
81 frameRanges = getAllRangesInsideViewport(frame, frameViewport, newOffset)
82 ranges.push(frameRanges...)
83
84 return ranges
85
86 getFirstNonWhitespace = (element) ->
87 window = element.ownerGlobal
88 viewport = getWindowViewport(window)
89 result = null
90 utils.walkTextNodes(element, (textNode) ->
91 return false unless /\S/.test(textNode.data)
92 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
93 if offset >= 0
94 result = [textNode, offset]
95 return true
96 )
97 return result
98
99 getFirstVisibleNonWhitespaceOffset = (textNode, viewport) ->
100 firstVisibleOffset = getFirstVisibleOffset(textNode, viewport)
101 if firstVisibleOffset?
102 offset = textNode.data.slice(firstVisibleOffset).search(/\S/)
103 return firstVisibleOffset + offset if offset >= 0
104 return -1
105
106 getFirstVisibleOffset = (textNode, viewport) ->
107 {length} = textNode.data
108 return null if length == 0
109 {headerBottom} = getFixedHeaderAndFooter(textNode.ownerGlobal, viewport)
110 [nonMatch, match] = utils.bisect(0, length - 1, (offset) ->
111 range = textNode.ownerDocument.createRange()
112 # Using a zero-width range sometimes gives a bad rect, so make it span one
113 # character instead.
114 range.setStart(textNode, offset)
115 range.setEnd(textNode, offset + 1)
116 rect = range.getBoundingClientRect()
117 # Ideally, we should also make sure that the text node is visible
118 # horizintally, but there seems to be no performant way of doing so.
119 # Luckily, horizontal scrolling is much less common than vertical.
120 return rect.top >= headerBottom - MIN_EDGE_DISTANCE
121 )
122 return match
123
124 getFirstVisibleRange = (window, viewport) ->
125 ranges = getAllRangesInsideViewport(window, viewport)
126 first = null
127 for item in ranges
128 if not first or item.rect.top < first.rect.top
129 first = item
130 return if first then first.range else null
131
132 getFirstVisibleText = (window, viewport) ->
133 for element in window.document.getElementsByTagName('*')
134 rect = element.getBoundingClientRect()
135 continue unless isInsideViewport(rect, viewport)
136
137 if element.contentWindow and
138 not utils.checkElementOrAncestor(element, isFixed)
139 {viewport: frameViewport} = getFrameViewport(element, viewport) ? {}
140 continue unless frameViewport
141 result = getFirstVisibleText(element.contentWindow, frameViewport)
142 return result if result
143 continue
144
145 nonEmptyTextNodes =
146 Array.prototype.filter.call(element.childNodes, utils.isNonEmptyTextNode)
147 continue if nonEmptyTextNodes.length == 0
148
149 continue if utils.checkElementOrAncestor(element, isFixed)
150
151 for textNode in nonEmptyTextNodes
152 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
153 return [textNode, offset] if offset >= 0
154
155 return null
156
157 # Adapted from Firefox’s source code for `<space>` scrolling (which is where the
158 # arbitrary constants below come from).
159 #
160 # coffeelint: disable=max_line_length
161 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
162 # coffeelint: enable=max_line_length
163 getFixedHeaderAndFooter = (window) ->
164 viewport = getWindowViewport(window)
165 header = null
166 headerBottom = viewport.top
167 footer = null
168 footerTop = viewport.bottom
169 maxHeight = viewport.height / 3
170 minWidth = Math.min(viewport.width / 2, 800)
171
172 # Restricting the candidates for headers and footers to the most likely set of
173 # elements results in a noticeable performance boost.
174 candidates = window.document.querySelectorAll(
175 'div, ul, nav, header, footer, section'
176 )
177
178 for candidate in candidates
179 rect = candidate.getBoundingClientRect()
180 continue unless rect.height <= maxHeight and rect.width >= minWidth
181 # Checking for `position: fixed;` or `position: absolute;` is the absolutely
182 # most expensive operation, so that is done last.
183 switch
184 when rect.top <= headerBottom and rect.bottom > headerBottom and
185 isFixedOrAbsolute(candidate)
186 header = candidate
187 headerBottom = rect.bottom
188 when rect.bottom >= footerTop and rect.top < footerTop and
189 isFixedOrAbsolute(candidate)
190 footer = candidate
191 footerTop = rect.top
192
193 return {header, headerBottom, footer, footerTop}
194
195 getFrameViewport = (frame, parentViewport) ->
196 rect = frame.getBoundingClientRect()
197 return null unless isInsideViewport(rect, parentViewport)
198
199 # `.getComputedStyle()` may return `null` if the computed style isn’t availble
200 # yet. If so, consider the element not visible.
201 return null unless computedStyle = frame.ownerGlobal.getComputedStyle(frame)
202 offset = {
203 left: rect.left +
204 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
205 parseFloat(computedStyle.getPropertyValue('padding-left'))
206 top: rect.top +
207 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
208 parseFloat(computedStyle.getPropertyValue('padding-top'))
209 right: rect.right -
210 parseFloat(computedStyle.getPropertyValue('border-right-width')) -
211 parseFloat(computedStyle.getPropertyValue('padding-right'))
212 bottom: rect.bottom -
213 parseFloat(computedStyle.getPropertyValue('border-bottom-width')) -
214 parseFloat(computedStyle.getPropertyValue('padding-bottom'))
215 }
216
217 # Calculate the visible part of the frame, according to the parent.
218 viewport = getWindowViewport(frame.contentWindow)
219 left = viewport.left + Math.max(parentViewport.left - offset.left, 0)
220 top = viewport.top + Math.max(parentViewport.top - offset.top, 0)
221 right = viewport.right + Math.min(parentViewport.right - offset.right, 0)
222 bottom = viewport.bottom + Math.min(parentViewport.bottom - offset.bottom, 0)
223
224 return {
225 viewport: {
226 left, top, right, bottom
227 width: right - left
228 height: bottom - top
229 }
230 offset
231 }
232
233 # Returns the minimum of `element.clientHeight` and the height of the viewport,
234 # taking fixed headers and footers into account.
235 getViewportCappedClientHeight = (element) ->
236 window = element.ownerGlobal
237 viewport = getWindowViewport(window)
238 {headerBottom, footerTop} = getFixedHeaderAndFooter(window)
239 return Math.min(element.clientHeight, footerTop - headerBottom)
240
241 getWindowViewport = (window) ->
242 {
243 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
244 scrollWidth, scrollHeight
245 } = utils.getRootElement(window.document)
246 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
247 # When there are no scrollbars `clientWidth` and `clientHeight` might be too
248 # small. Then we use `innerWidth` and `innerHeight` instead.
249 width = if scrollWidth > innerWidth then clientWidth else innerWidth
250 height = if scrollHeight > innerHeight then clientHeight else innerHeight
251 return {
252 left: 0
253 top: 0
254 right: width
255 bottom: height
256 width
257 height
258 }
259
260 isInsideViewport = (rect, viewport) ->
261 return \
262 rect.left <= viewport.right - MIN_EDGE_DISTANCE and
263 rect.top <= viewport.bottom - MIN_EDGE_DISTANCE and
264 rect.right >= viewport.left + MIN_EDGE_DISTANCE and
265 rect.bottom >= viewport.top + MIN_EDGE_DISTANCE
266
267 windowScrollProperties = {
268 clientHeight: 'innerHeight'
269 scrollTopMax: 'scrollMaxY'
270 scrollLeftMax: 'scrollMaxX'
271 }
272
273 scroll = (
274 element, {method, type, directions, amounts, properties, adjustment, smooth}
275 ) ->
276 if element.ownerDocument.documentElement.localName == 'svg'
277 element = element.ownerGlobal
278 properties = properties?.map(
279 (property) -> windowScrollProperties[property] ? property
280 )
281
282 options = {
283 behavior: if smooth then 'smooth' else 'instant'
284 }
285
286 for direction, index in directions
287 amount = amounts[index]
288 options[direction] = -Math.sign(amount) * adjustment + switch type
289 when 'lines'
290 amount
291 when 'pages'
292 amount *
293 if properties[index] == 'clientHeight'
294 getViewportCappedClientHeight(element)
295 else
296 element[properties[index]]
297 when 'other'
298 Math.min(amount, element[properties[index]])
299
300 element[method](options)
301
302 module.exports = {
303 MIN_EDGE_DISTANCE
304 adjustRectToViewport
305 getAllRangesInsideViewport
306 getFirstNonWhitespace
307 getFirstVisibleNonWhitespaceOffset
308 getFirstVisibleOffset
309 getFirstVisibleRange
310 getFirstVisibleText
311 getFixedHeaderAndFooter
312 getFrameViewport
313 getViewportCappedClientHeight
314 getWindowViewport
315 isInsideViewport
316 scroll
317 }
Imprint / Impressum