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 for frame in window.frames
73 {viewport: frameViewport, offset: frameOffset} =
74 getFrameViewport(frame.frameElement, viewport) ? {}
75 continue unless frameViewport
77 left: offset.left + frameOffset.left
78 top: offset.top + frameOffset.top
80 frameRanges = getAllRangesInsideViewport(frame, frameViewport, newOffset)
81 ranges.push(frameRanges...)
85 getFirstNonWhitespace = (element) ->
86 window = element.ownerGlobal
87 viewport = getWindowViewport(window)
89 utils.walkTextNodes(element, (textNode) ->
90 return false unless /\S/.test(textNode.data)
91 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
93 result = [textNode, offset]
98 getFirstVisibleNonWhitespaceOffset = (textNode, viewport) ->
99 firstVisibleOffset = getFirstVisibleOffset(textNode, viewport)
100 if firstVisibleOffset?
101 offset = textNode.data.slice(firstVisibleOffset).search(/\S/)
102 return firstVisibleOffset + offset if offset >= 0
105 getFirstVisibleOffset = (textNode, viewport) ->
106 {length} = textNode.data
107 return null if length == 0
108 {headerBottom} = getFixedHeaderAndFooter(textNode.ownerGlobal, viewport)
109 [nonMatch, match] = utils.bisect(0, length - 1, (offset) ->
110 range = textNode.ownerDocument.createRange()
111 # Using a zero-width range sometimes gives a bad rect, so make it span one
113 range.setStart(textNode, offset)
114 range.setEnd(textNode, offset + 1)
115 rect = range.getBoundingClientRect()
116 # Ideally, we should also make sure that the text node is visible
117 # horizintally, but there seems to be no performant way of doing so.
118 # Luckily, horizontal scrolling is much less common than vertical.
119 return rect.top >= headerBottom - MIN_EDGE_DISTANCE
123 getFirstVisibleRange = (window, viewport) ->
124 ranges = getAllRangesInsideViewport(window, viewport)
127 if not first or item.rect.top < first.rect.top
129 return if first then first.range else null
131 getFirstVisibleText = (window, viewport) ->
132 for element in window.document.getElementsByTagName('*')
133 rect = element.getBoundingClientRect()
134 continue unless isInsideViewport(rect, viewport)
136 if element.contentWindow and
137 not utils.checkElementOrAncestor(element, isFixed)
138 {viewport: frameViewport} = getFrameViewport(element, viewport) ? {}
139 continue unless frameViewport
140 result = getFirstVisibleText(element.contentWindow, frameViewport)
141 return result if result
145 Array.prototype.filter.call(element.childNodes, utils.isNonEmptyTextNode)
146 continue if nonEmptyTextNodes.length == 0
148 continue if utils.checkElementOrAncestor(element, isFixed)
150 for textNode in nonEmptyTextNodes
151 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
152 return [textNode, offset] if offset >= 0
156 # Adapted from Firefox’s source code for `<space>` scrolling (which is where the
157 # arbitrary constants below come from).
159 # coffeelint: disable=max_line_length
160 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
161 # coffeelint: enable=max_line_length
162 getFixedHeaderAndFooter = (window) ->
163 viewport = getWindowViewport(window)
165 headerBottom = viewport.top
167 footerTop = viewport.bottom
168 maxHeight = viewport.height / 3
169 minWidth = Math.min(viewport.width / 2, 800)
171 # Restricting the candidates for headers and footers to the most likely set of
172 # elements results in a noticeable performance boost.
173 candidates = window.document.querySelectorAll(
174 'div, ul, nav, header, footer, section'
177 for candidate in candidates
178 rect = candidate.getBoundingClientRect()
179 continue unless rect.height <= maxHeight and rect.width >= minWidth
180 # Checking for `position: fixed;` or `position: absolute;` is the absolutely
181 # most expensive operation, so that is done last.
183 when rect.top <= headerBottom and rect.bottom > headerBottom and
184 isFixedOrAbsolute(candidate)
186 headerBottom = rect.bottom
187 when rect.bottom >= footerTop and rect.top < footerTop and
188 isFixedOrAbsolute(candidate)
192 return {header, headerBottom, footer, footerTop}
194 getFrameViewport = (frame, parentViewport) ->
195 rect = frame.getBoundingClientRect()
196 return null unless isInsideViewport(rect, parentViewport)
198 # `.getComputedStyle()` may return `null` if the computed style isn’t availble
199 # yet. If so, consider the element not visible.
200 return null unless computedStyle = frame.ownerGlobal.getComputedStyle(frame)
203 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
204 parseFloat(computedStyle.getPropertyValue('padding-left'))
206 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
207 parseFloat(computedStyle.getPropertyValue('padding-top'))
209 parseFloat(computedStyle.getPropertyValue('border-right-width')) -
210 parseFloat(computedStyle.getPropertyValue('padding-right'))
211 bottom: rect.bottom -
212 parseFloat(computedStyle.getPropertyValue('border-bottom-width')) -
213 parseFloat(computedStyle.getPropertyValue('padding-bottom'))
216 # Calculate the visible part of the frame, according to the parent.
217 viewport = getWindowViewport(frame.contentWindow)
218 left = viewport.left + Math.max(parentViewport.left - offset.left, 0)
219 top = viewport.top + Math.max(parentViewport.top - offset.top, 0)
220 right = viewport.right + Math.min(parentViewport.right - offset.right, 0)
221 bottom = viewport.bottom + Math.min(parentViewport.bottom - offset.bottom, 0)
225 left, top, right, bottom
232 # Returns the minimum of `element.clientHeight` and the height of the viewport,
233 # taking fixed headers and footers into account.
234 getViewportCappedClientHeight = (element) ->
235 window = element.ownerGlobal
236 viewport = getWindowViewport(window)
237 {headerBottom, footerTop} = getFixedHeaderAndFooter(window)
238 return Math.min(element.clientHeight, footerTop - headerBottom)
240 getWindowViewport = (window) ->
242 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
243 scrollWidth, scrollHeight
244 } = utils.getRootElement(window.document)
245 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
246 # When there are no scrollbars `clientWidth` and `clientHeight` might be too
247 # small. Then we use `innerWidth` and `innerHeight` instead.
248 width = if scrollWidth > innerWidth then clientWidth else innerWidth
249 height = if scrollHeight > innerHeight then clientHeight else innerHeight
259 isInsideViewport = (rect, viewport) ->
261 rect.left <= viewport.right - MIN_EDGE_DISTANCE and
262 rect.top <= viewport.bottom - MIN_EDGE_DISTANCE and
263 rect.right >= viewport.left + MIN_EDGE_DISTANCE and
264 rect.bottom >= viewport.top + MIN_EDGE_DISTANCE
266 windowScrollProperties = {
267 clientHeight: 'innerHeight'
268 scrollTopMax: 'scrollMaxY'
269 scrollLeftMax: 'scrollMaxX'
273 element, {method, type, directions, amounts, properties, adjustment, smooth}
275 if element.ownerDocument.documentElement.localName == 'svg'
276 element = element.ownerGlobal
277 properties = properties?.map(
278 (property) -> windowScrollProperties[property] ? property
282 behavior: if smooth then 'smooth' else 'instant'
285 for direction, index in directions
286 amount = amounts[index]
287 options[direction] = -Math.sign(amount) * adjustment + switch type
292 if properties[index] == 'clientHeight'
293 getViewportCappedClientHeight(element)
295 element[properties[index]]
297 Math.min(amount, element[properties[index]])
299 element[method](options)
304 getAllRangesInsideViewport
305 getFirstNonWhitespace
306 getFirstVisibleNonWhitespaceOffset
307 getFirstVisibleOffset
310 getFixedHeaderAndFooter
312 getViewportCappedClientHeight