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