]> git.gir.st - VimFx.git/blob - extension/lib/viewport.coffee
Improve `zv` caret position
[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 getFirstNonWhitespace = (element) ->
50 window = element.ownerGlobal
51 viewport = getWindowViewport(window)
52 for node in element.childNodes then switch node.nodeType
53 when 3 # TextNode.
54 firstVisibleOffset = getFirstVisibleOffset(node, viewport)
55 if firstVisibleOffset?
56 offset = node.data.slice(firstVisibleOffset).search(/\S/)
57 return [node, firstVisibleOffset + offset] if offset >= 0
58 when 1 # Element.
59 result = getFirstNonWhitespace(node)
60 return result if result
61 return null
62
63 getFirstVisibleOffset = (textNode, viewport) ->
64 {length} = textNode.data
65 return null if length == 0
66 [header] = getFixedHeaderAndFooter(textNode.ownerGlobal, viewport)
67 headerBottom =
68 if header then header.getBoundingClientRect().bottom else viewport.top
69 [nonMatch, match] = utils.bisect(0, length - 1, (offset) ->
70 range = textNode.ownerDocument.createRange()
71 # Using a zero-width range sometimes gives a bad rect, so make it span one
72 # character instead.
73 range.setStart(textNode, offset)
74 range.setEnd(textNode, offset + 1)
75 rect = range.getBoundingClientRect()
76 # Ideally, we should also make sure that the text node is visible
77 # horizintally, but there seems to be no performant way of doing so.
78 # Luckily, horizontal scrolling is much less common than vertical.
79 return rect.top >= headerBottom - MINIMUM_EDGE_DISTANCE
80 )
81 return match
82
83 # Adapted from Firefox’s source code for `<space>` scrolling (which is where the
84 # arbitrary constants below come from).
85 #
86 # coffeelint: disable=max_line_length
87 # <https://hg.mozilla.org/mozilla-central/file/4d75bd6fd234/layout/generic/nsGfxScrollFrame.cpp#l3829>
88 # coffeelint: enable=max_line_length
89 getFixedHeaderAndFooter = (window) ->
90 viewport = getWindowViewport(window)
91 header = null
92 headerBottom = viewport.top
93 footer = null
94 footerTop = viewport.bottom
95 maxHeight = viewport.height / 3
96 minWidth = Math.min(viewport.width / 2, 800)
97
98 # Restricting the candidates for headers and footers to the most likely set of
99 # elements results in a noticeable performance boost.
100 candidates = window.document.querySelectorAll(
101 'div, ul, nav, header, footer, section'
102 )
103
104 for candidate in candidates
105 rect = candidate.getBoundingClientRect()
106 continue unless rect.height <= maxHeight and rect.width >= minWidth
107 # Checking for `position: fixed;` is the absolutely most expensive
108 # operation, so that is done last.
109 switch
110 when rect.top <= headerBottom and rect.bottom > headerBottom and
111 utils.isPositionFixed(candidate)
112 header = candidate
113 headerBottom = rect.bottom
114 when rect.bottom >= footerTop and rect.top < footerTop and
115 utils.isPositionFixed(candidate)
116 footer = candidate
117 footerTop = rect.top
118
119 return [header, footer]
120
121 getFrameViewport = (frame, parentViewport) ->
122 rect = frame.getBoundingClientRect()
123 return null unless isInsideViewport(rect, parentViewport)
124
125 # `.getComputedStyle()` may return `null` if the computed style isn’t availble
126 # yet. If so, consider the element not visible.
127 return null unless computedStyle = frame.ownerGlobal.getComputedStyle(frame)
128 offset = {
129 left: rect.left +
130 parseFloat(computedStyle.getPropertyValue('border-left-width')) +
131 parseFloat(computedStyle.getPropertyValue('padding-left'))
132 top: rect.top +
133 parseFloat(computedStyle.getPropertyValue('border-top-width')) +
134 parseFloat(computedStyle.getPropertyValue('padding-top'))
135 right: rect.right -
136 parseFloat(computedStyle.getPropertyValue('border-right-width')) -
137 parseFloat(computedStyle.getPropertyValue('padding-right'))
138 bottom: rect.bottom -
139 parseFloat(computedStyle.getPropertyValue('border-bottom-width')) -
140 parseFloat(computedStyle.getPropertyValue('padding-bottom'))
141 }
142
143 # Calculate the visible part of the frame, according to the parent.
144 viewport = getWindowViewport(frame.contentWindow)
145 left = viewport.left + Math.max(parentViewport.left - offset.left, 0)
146 top = viewport.top + Math.max(parentViewport.top - offset.top, 0)
147 right = viewport.right + Math.min(parentViewport.right - offset.right, 0)
148 bottom = viewport.bottom + Math.min(parentViewport.bottom - offset.bottom, 0)
149
150 return {
151 viewport: {
152 left, top, right, bottom
153 width: right - left
154 height: bottom - top
155 }
156 offset
157 }
158
159 # Returns the minimum of `element.clientHeight` and the height of the viewport,
160 # taking fixed headers and footers into account.
161 getViewportCappedClientHeight = (element) ->
162 window = element.ownerGlobal
163 viewport = getWindowViewport(window)
164 [header, footer] = getFixedHeaderAndFooter(window)
165 headerBottom =
166 if header then header.getBoundingClientRect().bottom else viewport.top
167 footerTop =
168 if footer then footer.getBoundingClientRect().bottom else viewport.bottom
169 return Math.min(element.clientHeight, footerTop - headerBottom)
170
171 getWindowViewport = (window) ->
172 {
173 clientWidth, clientHeight # Viewport size excluding scrollbars, usually.
174 scrollWidth, scrollHeight
175 } = utils.getRootElement(window.document)
176 {innerWidth, innerHeight} = window # Viewport size including scrollbars.
177 # When there are no scrollbars `clientWidth` and `clientHeight` might be too
178 # small. Then we use `innerWidth` and `innerHeight` instead.
179 width = if scrollWidth > innerWidth then clientWidth else innerWidth
180 height = if scrollHeight > innerHeight then clientHeight else innerHeight
181 return {
182 left: 0
183 top: 0
184 right: width
185 bottom: height
186 width
187 height
188 }
189
190 isInsideViewport = (rect, viewport) ->
191 return \
192 rect.left <= viewport.right - MINIMUM_EDGE_DISTANCE and
193 rect.top <= viewport.bottom + MINIMUM_EDGE_DISTANCE and
194 rect.right >= viewport.left + MINIMUM_EDGE_DISTANCE and
195 rect.bottom >= viewport.top - MINIMUM_EDGE_DISTANCE
196
197 scroll = (element, args) ->
198 {method, type, directions, amounts, properties, adjustment, smooth} = args
199 options = {
200 behavior: if smooth then 'smooth' else 'instant'
201 }
202 for direction, index in directions
203 amount = amounts[index]
204 options[direction] = -Math.sign(amount) * adjustment + switch type
205 when 'lines'
206 amount
207 when 'pages'
208 amount *
209 if properties[index] == 'clientHeight'
210 getViewportCappedClientHeight(element)
211 else
212 element[properties[index]]
213 when 'other'
214 Math.min(amount, element[properties[index]])
215 element[method](options)
216
217 module.exports = {
218 MINIMUM_EDGE_DISTANCE
219 adjustRectToViewport
220 getFirstNonWhitespace
221 getFirstVisibleOffset
222 getFixedHeaderAndFooter
223 getFrameViewport
224 getViewportCappedClientHeight
225 getWindowViewport
226 isInsideViewport
227 scroll
228 }
Imprint / Impressum