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