1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
|
#
# activatedElement is different from document.activeElement -- the latter seems to be reserved mostly for
# input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document.
#
activatedElement = null
# Previously, the main scrolling element was document.body. If the "experimental web platform features" flag
# is enabled, then we need to use document.scrollingElement instead. There's an explanation in #2168:
# https://github.com/philc/vimium/pull/2168#issuecomment-236488091
getScrollingElement = ->
getSpecialScrollingElement() ? document.scrollingElement ? document.body
# Return 0, -1 or 1: the sign of the argument.
# NOTE(smblott; 2014/12/17) We would like to use Math.sign(). However, according to this site
# (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign) Math.sign() was
# only introduced in Chrome 38. This caused problems in R1.48 for users with old Chrome installations. We
# can replace this with Math.sign() at some point.
getSign = (val) ->
if not val
0
else
if val < 0 then -1 else 1
scrollProperties =
x: {
axisName: 'scrollLeft'
max: 'scrollWidth'
viewSize: 'clientWidth'
}
y: {
axisName: 'scrollTop'
max: 'scrollHeight'
viewSize: 'clientHeight'
}
# Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative amount, or
# by `scrollTo` as an absolute amount). :direction must be "x" or "y". :amount may be either a number (in
# which case it is simply returned) or a string. If :amount is a string, then it is either "max" (meaning the
# height or width of element), or "viewSize". In both cases, we look up and return the requested amount,
# either in `element` or in `window`, as appropriate.
getDimension = (el, direction, amount) ->
if Utils.isString amount
name = amount
# the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the
# part visible through the window
if name is 'viewSize' and el is getScrollingElement()
# TODO(smblott) Should we not be returning the width/height of element, here?
if direction is 'x' then window.innerWidth else window.innerHeight
else
el[scrollProperties[direction][name]]
else
amount
# Perform a scroll. Return true if we successfully scrolled by any amount, and false otherwise.
performScroll = (element, direction, amount) ->
axisName = scrollProperties[direction].axisName
before = element[axisName]
if typeof element.scrollBy is "function"
scrollArg = behavior: "instant"
scrollArg[if direction is "x" then "left" else "top"] = amount
element.scrollBy scrollArg
else
element[axisName] += amount
element[axisName] != before
# Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled.
shouldScroll = (element, direction) ->
computedStyle = window.getComputedStyle(element)
# Elements with `overflow: hidden` must not be scrolled.
return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden"
# Elements which are not visible should not be scrolled.
return false if computedStyle.getPropertyValue("visibility") in ["hidden", "collapse"]
return false if computedStyle.getPropertyValue("display") == "none"
true
# Test whether element does actually scroll in the direction required when asked to do so. Due to chrome bug
# 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an element will scroll.
# Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). :factor is the factor by
# which :scrollBy and :scrollTo will later scale the scroll amount. :factor can be negative, so we need it
# here in order to decide whether we should test a forward scroll or a backward scroll.
# Bug last verified in Chrome 38.0.2125.104.
doesScroll = (element, direction, amount, factor) ->
# amount is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls (only
# gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero. In the former case,
# we're definitely scrolling forwards, so any positive value will do for delta. In the latter, we're
# definitely scrolling backwards, so a delta of -1 will do. For absolute scrolls, factor is always 1.
delta = factor * getDimension(element, direction, amount) || -1
delta = getSign delta # 1 or -1
performScroll(element, direction, delta) and performScroll(element, direction, -delta)
isScrollableElement = (element, direction = "y", amount = 1, factor = 1) ->
doesScroll(element, direction, amount, factor) and shouldScroll element, direction
# From element and its parents, find the first which we should scroll and which does scroll.
findScrollableElement = (element, direction, amount, factor) ->
while element != getScrollingElement() and not isScrollableElement element, direction, amount, factor
element = DomUtils.getContainingElement(element) ? getScrollingElement()
element
# On some pages, the scrolling element is not actually scrollable. Here, we search the document for the
# largest visible element which does scroll vertically. This is used to initialize activatedElement. See
# #1358.
firstScrollableElement = (element = null) ->
unless element
scrollingElement = getScrollingElement()
if doesScroll(scrollingElement, "y", 1, 1) or doesScroll(scrollingElement, "y", -1, 1)
return scrollingElement
else
element = document.body ? getScrollingElement()
if doesScroll(element, "y", 1, 1) or doesScroll(element, "y", -1, 1)
element
else
children = ({element: child, rect: DomUtils.getVisibleClientRect(child)} for child in element.children)
children = children.filter (child) -> child.rect # Filter out non-visible elements.
children.map (child) -> child.area = child.rect.width * child.rect.height
for child in children.sort((a,b) -> b.area - a.area) # Largest to smallest by visible area.
return ele if ele = firstScrollableElement child.element
null
checkVisibility = (element) ->
# If the activated element has been scrolled completely offscreen, then subsequent changes in its scroll
# position will not provide any more visual feedback to the user. Therefore, we deactivate it so that
# subsequent scrolls affect the parent element.
rect = activatedElement.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth)
activatedElement = element
# How scrolling is handled by CoreScroller.
# - For jump scrolling, the entire scroll happens immediately.
# - For smooth scrolling with distinct key presses, a separate animator is initiated for each key press.
# Therefore, several animators may be active at the same time. This ensures that two quick taps on `j`
# scroll to the same position as two slower taps.
# - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated animator
# continues scrolling at least until its keyup event is received. We never initiate a new animator on
# keyboard repeat.
# CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are ultimately
# translated to relative scrolls. CoreScroller is not exported.
CoreScroller =
init: ->
@time = 0
@lastEvent = @keyIsDown = null
@installCanceEventListener()
# This installs listeners for events which should cancel smooth scrolling.
installCanceEventListener: ->
# NOTE(smblott) With extreme keyboard configurations, Chrome sometimes does not get a keyup event for
# every keydown, in which case tapping "j" scrolls indefinitely. This appears to be a Chrome/OS/XOrg bug
# of some kind. See #1549.
handlerStack.push
_name: 'scroller/track-key-status'
keydown: (event) =>
handlerStack.alwaysContinueBubbling =>
@keyIsDown = true
@time += 1 unless event.repeat
@lastEvent = event
keyup: (event) =>
handlerStack.alwaysContinueBubbling =>
@keyIsDown = false
@time += 1
blur: (event) =>
handlerStack.alwaysContinueBubbling =>
@time += 1 if event.target == window
# Return true if CoreScroller would not initiate a new scroll right now.
wouldNotInitiateScroll: -> @lastEvent?.repeat and Settings.get "smoothScroll"
# Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We then
# increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or decrease it
# (until it is less than @minCalibration) if we guess that the scroll is too fast. The cutoff point for
# which guess we make is @calibrationBoundary. We require: 0 < @minCalibration <= 1 <= @maxCalibration.
minCalibration: 0.5 # Controls how much we're willing to slow scrolls down; smaller means more slow down.
maxCalibration: 1.6 # Controls how much we're willing to speed scrolls up; bigger means more speed up.
calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, or too fast.
# Scroll element by a relative amount (a number) in some direction.
scroll: (element, direction, amount, continuous = true) ->
return unless amount
unless Settings.get "smoothScroll"
# Jump scrolling.
performScroll element, direction, amount
checkVisibility element
return
# We don't activate new animators on keyboard repeats; rather, the most-recently activated animator
# continues scrolling.
return if @lastEvent?.repeat
activationTime = ++@time
myKeyIsStillDown = => @time == activationTime and @keyIsDown ? true
# Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive.
sign = getSign amount
amount = Math.abs amount
# Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls.
duration = Math.max 100, 20 * Math.log amount
totalDelta = 0
totalElapsed = 0.0
calibration = 1.0
previousTimestamp = null
cancelEventListener = @installCanceEventListener()
animate = (timestamp) =>
previousTimestamp ?= timestamp
return requestAnimationFrame(animate) if timestamp == previousTimestamp
# The elapsed time is typically about 16ms.
elapsed = timestamp - previousTimestamp
totalElapsed += elapsed
previousTimestamp = timestamp
# The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for
# distinct keypresses. For continuous scrolls, some scrolls are too slow, and others too fast. Here, we
# speed up the slower scrolls, and slow down the faster scrolls.
if myKeyIsStillDown() and 75 <= totalElapsed and @minCalibration <= calibration <= @maxCalibration
calibration *= 1.05 if 1.05 * calibration * amount < @calibrationBoundary # Speed up slow scrolls.
calibration *= 0.95 if @calibrationBoundary < 0.95 * calibration * amount # Slow down fast scrolls.
# Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account for the
# current scroll state.
delta = Math.ceil amount * (elapsed / duration) * calibration
delta = if myKeyIsStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta
if delta and performScroll element, direction, sign * delta
totalDelta += delta
requestAnimationFrame animate
else
# We're done.
handlerStack.remove cancelEventListener
checkVisibility element
# If we've been asked not to be continuous, then we advance time, so the myKeyIsStillDown test always
# fails.
++@time unless continuous
# Start scrolling.
requestAnimationFrame animate
# Scroller contains the two main scroll functions which are used by clients.
Scroller =
init: ->
handlerStack.push
_name: 'scroller/active-element'
DOMActivate: (event) -> handlerStack.alwaysContinueBubbling ->
# If event.path is present, the true event taget (potentially inside a Shadow DOM inside
# event.target) can be found as its first element.
# NOTE(mrmr1993): event.path has been renamed to event.deepPath in the spec, but this change is not
# yet implemented by Chrome.
activatedElement = event.deepPath?[0] ? event.path?[0] ? event.target
CoreScroller.init()
@reset()
reset: ->
activatedElement = null
# scroll the active element in :direction by :amount * :factor.
# :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions.
scrollBy: (direction, amount, factor = 1, continuous = true) ->
# if this is called before domReady, just use the window scroll function
if (!getScrollingElement() and amount instanceof Number)
if (direction == "x")
window.scrollBy(amount, 0)
else
window.scrollBy(0, amount)
return
activatedElement ||= (getScrollingElement() and firstScrollableElement()) or getScrollingElement()
return unless activatedElement
# Avoid the expensive scroll calculation if it will not be used. This reduces costs during smooth,
# continuous scrolls, and is just an optimization.
unless CoreScroller.wouldNotInitiateScroll()
element = findScrollableElement activatedElement, direction, amount, factor
elementAmount = factor * getDimension element, direction, amount
CoreScroller.scroll element, direction, elementAmount, continuous
scrollTo: (direction, pos) ->
activatedElement ||= (getScrollingElement() and firstScrollableElement()) or getScrollingElement()
return unless activatedElement
element = findScrollableElement activatedElement, direction, pos, 1
amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName]
CoreScroller.scroll element, direction, amount
# Is element scrollable and not the activated element?
isScrollableElement: (element) ->
activatedElement ||= (getScrollingElement() and firstScrollableElement()) or getScrollingElement()
element != activatedElement and isScrollableElement element
# Scroll the top, bottom, left and right of element into view. The is used by visual mode to ensure the
# focus remains visible.
scrollIntoView: (element) ->
activatedElement ||= getScrollingElement() and firstScrollableElement()
rect = element. getClientRects()?[0]
if rect?
# Scroll y axis.
if rect.bottom < 0
amount = rect.bottom - Math.min(rect.height, window.innerHeight)
element = findScrollableElement element, "y", amount, 1
CoreScroller.scroll element, "y", amount, false
else if window.innerHeight < rect.top
amount = rect.top + Math.min(rect.height - window.innerHeight, 0)
element = findScrollableElement element, "y", amount, 1
CoreScroller.scroll element, "y", amount, false
# Scroll x axis.
if rect.right < 0
amount = rect.right - Math.min(rect.width, window.innerWidth)
element = findScrollableElement element, "x", amount, 1
CoreScroller.scroll element, "x", amount, false
else if window.innerWidth < rect.left
amount = rect.left + Math.min(rect.width - window.innerWidth, 0)
element = findScrollableElement element, "x", amount, 1
CoreScroller.scroll element, "x", amount, false
getSpecialScrollingElement = ->
selector = specialScrollingElementMap[window.location.host]
if selector
document.querySelector selector
specialScrollingElementMap =
'twitter.com': 'div.permalink-container div.permalink[role=main]'
'reddit.com': '#overlayScrollContainer'
'new.reddit.com': '#overlayScrollContainer'
root = exports ? (window.root ?= {})
root.Scroller = Scroller
extend window, root unless exports?
|