1 # This file provides utility functions for working with the viewport.
3 utils = require('./utils')
7 getPosition = (element) ->
8 computedStyle = element.ownerGlobal.getComputedStyle(element)
9 return computedStyle?.getPropertyValue('position')
11 isFixed = (element) -> getPosition(element) == 'fixed'
13 isFixedOrAbsolute = (element) -> getPosition(element) in ['fixed', 'absolute']
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
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)
24 # Make sure that `right >= left and bottom >= top`, since we subtracted by 1
26 right = Math.max(right, left)
27 bottom = Math.max(bottom, top)
31 area = Math.round(width * height)
34 left, right, top, bottom
38 getAllRangesInsideViewport = (window, viewport, offset = {left: 0, top: 0}) ->
39 selection = window.getSelection()
40 {rangeCount} = selection
44 {header, headerBottom, footer, footerTop} =
45 getFixedHeaderAndFooter(window, viewport)
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
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)
63 left: offset.left + rect.left
64 top: offset.top + rect.top
65 right: offset.left + rect.right
66 bottom: offset.top + rect.bottom
70 ranges.push({range, rect: adjustedRect})
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
78 left: offset.left + frameOffset.left
79 top: offset.top + frameOffset.top
81 frameRanges = getAllRangesInsideViewport(frame, frameViewport, newOffset)
82 ranges.push(frameRanges...)
86 getFirstNonWhitespace = (element) ->
87 window = element.ownerGlobal
88 viewport = getWindowViewport(window)
90 utils.walkTextNodes(element, (textNode) ->
91 return false unless /\S/.test(textNode.data)
92 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
94 result = [textNode, offset]
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
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
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
124 getFirstVisibleRange = (window, viewport) ->
125 ranges = getAllRangesInsideViewport(window, viewport)
128 if not first or item.rect.top < first.rect.top
130 return if first then first.range else null
132 getFirstVisibleText = (window, viewport) ->
133 for element in window.document.getElementsByTagName('*')
134 rect = element.getBoundingClientRect()
135 continue unless isInsideViewport(rect, viewport)
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
146 Array.prototype.filter.call(element.childNodes, utils.isNonEmptyTextNode)
147 continue if nonEmptyTextNodes.length == 0
149 continue if utils.checkElementOrAncestor(element, isFixed)
151 for textNode in nonEmptyTextNodes
152 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
153 return [textNode, offset] if offset >= 0
157 # Adapted from Firefox’s source code for `<space>` scrolling (which is where the
158 # arbitrary constants below come from).
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)
166 headerBottom = viewport.top
168 footerTop = viewport.bottom
169 maxHeight = viewport.height / 3
170 minWidth = Math.min(viewport.width / 2, 800)
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'
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.
184 when rect.top <= headerBottom and rect.bottom > headerBottom and
185 isFixedOrAbsolute(candidate)
187 headerBottom = rect.bottom
188 when rect.bottom >= footerTop and rect.top < footerTop and
189 isFixedOrAbsolute(candidate)
193 return {header, headerBottom, footer, footerTop}
195 getFrameViewport = (frame, parentViewport) ->
196 rect = frame.getBoundingClientRect()
197 return null unless isInsideViewport(rect, parentViewport)
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)
204 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
205 parseFloat(computedStyle.getPropertyValue('padding-left'))
207 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
208 parseFloat(computedStyle.getPropertyValue('padding-top'))
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'))
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)
226 left, top, right, bottom
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)
241 getWindowViewport = (window) ->
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
260 isInsideViewport = (rect, viewport) ->
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
267 windowScrollProperties = {
268 clientHeight: 'innerHeight'
269 scrollTopMax: 'scrollMaxY'
270 scrollLeftMax: 'scrollMaxX'
274 element, {method, type, directions, amounts, properties, adjustment, smooth}
276 if element.ownerDocument.documentElement.localName == 'svg'
277 element = element.ownerGlobal
278 properties = properties?.map(
279 (property) -> windowScrollProperties[property] ? property
283 behavior: if smooth then 'smooth' else 'instant'
286 for direction, index in directions
287 amount = amounts[index]
288 options[direction] = -Math.sign(amount) * adjustment + switch type
293 if properties[index] == 'clientHeight'
294 getViewportCappedClientHeight(element)
296 element[properties[index]]
298 Math.min(amount, element[properties[index]])
300 element[method](options)
305 getAllRangesInsideViewport
306 getFirstNonWhitespace
307 getFirstVisibleNonWhitespaceOffset
308 getFirstVisibleOffset
311 getFixedHeaderAndFooter
313 getViewportCappedClientHeight