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 adjustRectToViewport = (rect, viewport) ->
14 # The right and bottom values are subtracted by 1 because
15 # `document.elementFromPoint(right, bottom)` does not return the element
17 left = Math.max(rect.left, viewport.left)
18 right = Math.min(rect.right - 1, viewport.right)
19 top = Math.max(rect.top, viewport.top)
20 bottom = Math.min(rect.bottom - 1, viewport.bottom)
22 # Make sure that `right >= left and bottom >= top`, since we subtracted by 1
24 right = Math.max(right, left)
25 bottom = Math.max(bottom, top)
29 area = Math.round(width * height)
32 left, right, top, bottom
36 getAllRangesInsideViewport = (window, viewport, offset = {left: 0, top: 0}) ->
37 selection = window.getSelection()
38 {rangeCount} = selection
42 {header, headerBottom, footer, footerTop} =
43 getFixedHeaderAndFooter(window, viewport)
45 # Many sites use `text-indent: -9999px;` or similar to hide text intended
46 # for screen readers only. That text can still be selected and found by
47 # searching, though. Therefore, we have to allow selection ranges that are
48 # within the viewport vertically but not horizontally, even though they
49 # actually are outside the viewport. Otherwise you won’t be able to press
50 # `n` to get past those elements (instead, `n` would start over from the top
52 for index in [0...rangeCount]
53 range = selection.getRangeAt(index)
54 continue if range.collapsed
55 rect = range.getBoundingClientRect()
56 if (rect.top >= headerBottom - MIN_EDGE_DISTANCE and
57 rect.bottom <= footerTop + MIN_EDGE_DISTANCE) or
58 header?.contains(range.commonAncestorContainer) or
59 footer?.contains(range.commonAncestorContainer)
61 left: offset.left + rect.left
62 top: offset.top + rect.top
63 right: offset.left + rect.right
64 bottom: offset.top + rect.bottom
68 ranges.push({range, rect: adjustedRect})
70 # Note: accessing frameElement fails on oop iframes (fission), so we skip them
71 for frame in window.frames when (try frame.frameElement)
72 {viewport: frameViewport, offset: frameOffset} =
73 getFrameViewport(frame.frameElement, viewport) ? {}
74 continue unless frameViewport
76 left: offset.left + frameOffset.left
77 top: offset.top + frameOffset.top
79 frameRanges = getAllRangesInsideViewport(frame, frameViewport, newOffset)
80 ranges.push(frameRanges...)
84 getFirstNonWhitespace = (element) ->
85 window = element.ownerGlobal
86 viewport = getWindowViewport(window)
88 utils.walkTextNodes(element, (textNode) ->
89 return false unless /\S/.test(textNode.data)
90 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
92 result = [textNode, offset]
97 getFirstVisibleNonWhitespaceOffset = (textNode, viewport) ->
98 firstVisibleOffset = getFirstVisibleOffset(textNode, viewport)
99 if firstVisibleOffset?
100 offset = textNode.data.slice(firstVisibleOffset).search(/\S/)
101 return firstVisibleOffset + offset if offset >= 0
104 getFirstVisibleOffset = (textNode, viewport) ->
105 {length} = textNode.data
106 return null if length == 0
107 {headerBottom} = getFixedHeaderAndFooter(textNode.ownerGlobal, viewport)
108 [nonMatch, match] = utils.bisect(0, length - 1, (offset) ->
109 range = textNode.ownerDocument.createRange()
110 # Using a zero-width range sometimes gives a bad rect, so make it span one
112 range.setStart(textNode, offset)
113 range.setEnd(textNode, offset + 1)
114 rect = range.getBoundingClientRect()
115 # Ideally, we should also make sure that the text node is visible
116 # horizintally, but there seems to be no performant way of doing so.
117 # Luckily, horizontal scrolling is much less common than vertical.
118 return rect.top >= headerBottom - MIN_EDGE_DISTANCE
122 getFirstVisibleRange = (window, viewport) ->
123 ranges = getAllRangesInsideViewport(window, viewport)
126 if not first or item.rect.top < first.rect.top
128 return if first then first.range else null
130 getFirstVisibleText = (window, viewport) ->
131 for element in window.document.getElementsByTagName('*')
132 rect = element.getBoundingClientRect()
133 continue unless isInsideViewport(rect, viewport)
135 if element.contentWindow and
136 not utils.checkElementOrAncestor(element, isFixed)
137 {viewport: frameViewport} = getFrameViewport(element, viewport) ? {}
138 continue unless frameViewport
139 result = getFirstVisibleText(element.contentWindow, frameViewport)
140 return result if result
144 Array.prototype.filter.call(element.childNodes, utils.isNonEmptyTextNode)
145 continue if nonEmptyTextNodes.length == 0
147 continue if utils.checkElementOrAncestor(element, isFixed)
149 for textNode in nonEmptyTextNodes
150 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
151 return [textNode, offset] if offset >= 0
155 # Adapted from Firefox’s source code for `<space>` scrolling (which is where the
156 # arbitrary constants below come from).
158 # coffeelint: disable=max_line_length
159 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
160 # coffeelint: enable=max_line_length
161 getFixedHeaderAndFooter = (window) ->
162 viewport = getWindowViewport(window)
164 headerBottom = viewport.top
166 footerTop = viewport.bottom
167 maxHeight = viewport.height / 3
168 minWidth = Math.min(viewport.width / 2, 800)
170 # Restricting the candidates for headers and footers to the most likely set of
171 # elements results in a noticeable performance boost.
172 candidates = window.document.querySelectorAll(
173 'div, ul, nav, header, footer, section'
176 for candidate in candidates
177 rect = candidate.getBoundingClientRect()
178 continue unless rect.height <= maxHeight and rect.width >= minWidth
179 # Checking for `position: fixed;` is the absolutely most expensive
180 # operation, so that is done last.
182 when rect.top <= headerBottom and rect.bottom > headerBottom and
185 headerBottom = rect.bottom
186 when rect.bottom >= footerTop and rect.top < footerTop and
191 return {header, headerBottom, footer, footerTop}
193 getFrameViewport = (frame, parentViewport) ->
194 rect = frame.getBoundingClientRect()
195 return null unless isInsideViewport(rect, parentViewport)
197 # `.getComputedStyle()` may return `null` if the computed style isn’t availble
198 # yet. If so, consider the element not visible.
199 return null unless computedStyle = frame.ownerGlobal.getComputedStyle(frame)
202 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
203 parseFloat(computedStyle.getPropertyValue('padding-left'))
205 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
206 parseFloat(computedStyle.getPropertyValue('padding-top'))
208 parseFloat(computedStyle.getPropertyValue('border-right-width')) -
209 parseFloat(computedStyle.getPropertyValue('padding-right'))
210 bottom: rect.bottom -
211 parseFloat(computedStyle.getPropertyValue('border-bottom-width')) -
212 parseFloat(computedStyle.getPropertyValue('padding-bottom'))
215 # Calculate the visible part of the frame, according to the parent.
216 viewport = getWindowViewport(frame.contentWindow)
217 left = viewport.left + Math.max(parentViewport.left - offset.left, 0)
218 top = viewport.top + Math.max(parentViewport.top - offset.top, 0)
219 right = viewport.right + Math.min(parentViewport.right - offset.right, 0)
220 bottom = viewport.bottom + Math.min(parentViewport.bottom - offset.bottom, 0)
224 left, top, right, bottom
231 # Returns the minimum of `element.clientHeight` and the height of the viewport,
232 # taking fixed headers and footers into account.
233 getViewportCappedClientHeight = (element) ->
234 window = element.ownerGlobal
235 viewport = getWindowViewport(window)
236 {headerBottom, footerTop} = getFixedHeaderAndFooter(window)
237 return Math.min(element.clientHeight, footerTop - headerBottom)
239 getWindowViewport = (window) ->
241 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
242 scrollWidth, scrollHeight
243 } = utils.getRootElement(window.document)
244 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
245 # When there are no scrollbars `clientWidth` and `clientHeight` might be too
246 # small. Then we use `innerWidth` and `innerHeight` instead.
247 width = if scrollWidth > innerWidth then clientWidth else innerWidth
248 height = if scrollHeight > innerHeight then clientHeight else innerHeight
258 isInsideViewport = (rect, viewport) ->
260 rect.left <= viewport.right - MIN_EDGE_DISTANCE and
261 rect.top <= viewport.bottom - MIN_EDGE_DISTANCE and
262 rect.right >= viewport.left + MIN_EDGE_DISTANCE and
263 rect.bottom >= viewport.top + MIN_EDGE_DISTANCE
265 windowScrollProperties = {
266 clientHeight: 'innerHeight'
267 scrollTopMax: 'scrollMaxY'
268 scrollLeftMax: 'scrollMaxX'
272 element, {method, type, directions, amounts, properties, adjustment, smooth}
274 if element.ownerDocument.documentElement.localName == 'svg'
275 element = element.ownerGlobal
276 properties = properties?.map(
277 (property) -> windowScrollProperties[property] ? property
281 behavior: if smooth then 'smooth' else 'instant'
284 for direction, index in directions
285 amount = amounts[index]
286 options[direction] = -Math.sign(amount) * adjustment + switch type
291 if properties[index] == 'clientHeight'
292 getViewportCappedClientHeight(element)
294 element[properties[index]]
296 Math.min(amount, element[properties[index]])
298 element[method](options)
303 getAllRangesInsideViewport
304 getFirstNonWhitespace
305 getFirstVisibleNonWhitespaceOffset
306 getFirstVisibleOffset
309 getFixedHeaderAndFooter
311 getViewportCappedClientHeight