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