]> git.gir.st - VimFx.git/blob - extension/lib/viewport.coffee
(half/full) page scrolling: don't take position:absolute headers into account
[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 adjustRectToViewport = (rect, viewport) ->
14 # The right and bottom values are subtracted by 1 because
15 # `document.elementFromPoint(right, bottom)` does not return the element
16 # otherwise.
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)
21
22 # Make sure that `right >= left and bottom >= top`, since we subtracted by 1
23 # above.
24 right = Math.max(right, left)
25 bottom = Math.max(bottom, top)
26
27 width = right - left
28 height = bottom - top
29 area = Math.round(width * height)
30
31 return {
32 left, right, top, bottom
33 height, width, area
34 }
35
36 getAllRangesInsideViewport = (window, viewport, offset = {left: 0, top: 0}) ->
37 selection = window.getSelection()
38 {rangeCount} = selection
39 ranges = []
40
41 if rangeCount > 0
42 {header, headerBottom, footer, footerTop} =
43 getFixedHeaderAndFooter(window, viewport)
44
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
51 # of the viewport).
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)
60 adjustedRect = {
61 left: offset.left + rect.left
62 top: offset.top + rect.top
63 right: offset.left + rect.right
64 bottom: offset.top + rect.bottom
65 width: rect.width
66 height: rect.height
67 }
68 ranges.push({range, rect: adjustedRect})
69
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
75 newOffset = {
76 left: offset.left + frameOffset.left
77 top: offset.top + frameOffset.top
78 }
79 frameRanges = getAllRangesInsideViewport(frame, frameViewport, newOffset)
80 ranges.push(frameRanges...)
81
82 return ranges
83
84 getFirstNonWhitespace = (element) ->
85 window = element.ownerGlobal
86 viewport = getWindowViewport(window)
87 result = null
88 utils.walkTextNodes(element, (textNode) ->
89 return false unless /\S/.test(textNode.data)
90 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
91 if offset >= 0
92 result = [textNode, offset]
93 return true
94 )
95 return result
96
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
102 return -1
103
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
111 # character instead.
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
119 )
120 return match
121
122 getFirstVisibleRange = (window, viewport) ->
123 ranges = getAllRangesInsideViewport(window, viewport)
124 first = null
125 for item in ranges
126 if not first or item.rect.top < first.rect.top
127 first = item
128 return if first then first.range else null
129
130 getFirstVisibleText = (window, viewport) ->
131 for element in window.document.getElementsByTagName('*')
132 rect = element.getBoundingClientRect()
133 continue unless isInsideViewport(rect, viewport)
134
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
141 continue
142
143 nonEmptyTextNodes =
144 Array.prototype.filter.call(element.childNodes, utils.isNonEmptyTextNode)
145 continue if nonEmptyTextNodes.length == 0
146
147 continue if utils.checkElementOrAncestor(element, isFixed)
148
149 for textNode in nonEmptyTextNodes
150 offset = getFirstVisibleNonWhitespaceOffset(textNode, viewport)
151 return [textNode, offset] if offset >= 0
152
153 return null
154
155 # Adapted from Firefox’s source code for `<space>` scrolling (which is where the
156 # arbitrary constants below come from).
157 #
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)
163 header = null
164 headerBottom = viewport.top
165 footer = null
166 footerTop = viewport.bottom
167 maxHeight = viewport.height / 3
168 minWidth = Math.min(viewport.width / 2, 800)
169
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'
174 )
175
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.
181 switch
182 when rect.top <= headerBottom and rect.bottom > headerBottom and
183 isFixed(candidate)
184 header = candidate
185 headerBottom = rect.bottom
186 when rect.bottom >= footerTop and rect.top < footerTop and
187 isFixed(candidate)
188 footer = candidate
189 footerTop = rect.top
190
191 return {header, headerBottom, footer, footerTop}
192
193 getFrameViewport = (frame, parentViewport) ->
194 rect = frame.getBoundingClientRect()
195 return null unless isInsideViewport(rect, parentViewport)
196
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)
200 offset = {
201 left: rect.left +
202 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
203 parseFloat(computedStyle.getPropertyValue('padding-left'))
204 top: rect.top +
205 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
206 parseFloat(computedStyle.getPropertyValue('padding-top'))
207 right: rect.right -
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'))
213 }
214
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)
221
222 return {
223 viewport: {
224 left, top, right, bottom
225 width: right - left
226 height: bottom - top
227 }
228 offset
229 }
230
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)
238
239 getWindowViewport = (window) ->
240 {
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
249 return {
250 left: 0
251 top: 0
252 right: width
253 bottom: height
254 width
255 height
256 }
257
258 isInsideViewport = (rect, viewport) ->
259 return \
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
264
265 windowScrollProperties = {
266 clientHeight: 'innerHeight'
267 scrollTopMax: 'scrollMaxY'
268 scrollLeftMax: 'scrollMaxX'
269 }
270
271 scroll = (
272 element, {method, type, directions, amounts, properties, adjustment, smooth}
273 ) ->
274 if element.ownerDocument.documentElement.localName == 'svg'
275 element = element.ownerGlobal
276 properties = properties?.map(
277 (property) -> windowScrollProperties[property] ? property
278 )
279
280 options = {
281 behavior: if smooth then 'smooth' else 'instant'
282 }
283
284 for direction, index in directions
285 amount = amounts[index]
286 options[direction] = -Math.sign(amount) * adjustment + switch type
287 when 'lines'
288 amount
289 when 'pages'
290 amount *
291 if properties[index] == 'clientHeight'
292 getViewportCappedClientHeight(element)
293 else
294 element[properties[index]]
295 when 'other'
296 Math.min(amount, element[properties[index]])
297
298 element[method](options)
299
300 module.exports = {
301 MIN_EDGE_DISTANCE
302 adjustRectToViewport
303 getAllRangesInsideViewport
304 getFirstNonWhitespace
305 getFirstVisibleNonWhitespaceOffset
306 getFirstVisibleOffset
307 getFirstVisibleRange
308 getFirstVisibleText
309 getFixedHeaderAndFooter
310 getFrameViewport
311 getViewportCappedClientHeight
312 getWindowViewport
313 isInsideViewport
314 scroll
315 }
Imprint / Impressum