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
|
#
# 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
settings = null
scrollProperties =
x: {
axisName: 'scrollLeft'
max: 'scrollWidth'
viewSize: 'clientHeight'
}
y: {
axisName: 'scrollTop'
max: 'scrollHeight'
viewSize: 'clientWidth'
}
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 document.body
if direction is 'x' then window.innerWidth else window.innerHeight
else
el[scrollProperties[direction][name]]
else
amount
# Test whether element should be scrolled.
isScrollAllowed = (element, direction) ->
computedStyle = window.getComputedStyle(element)
# Elements with `overflow: hidden` should not be scrolled.
return computedStyle.getPropertyValue("overflow-#{direction}") != "hidden" and
["hidden", "collapse"].indexOf(computedStyle.getPropertyValue("visibility")) == -1 and
computedStyle.getPropertyValue("display") != "none"
# Test whether element actually scrolls 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.
isScrollPossible = (element, direction, amount, factor) ->
axisName = scrollProperties[direction].axisName
# delta, here, is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls
# (only gg, G, and friends), amount can be either 'max' or zero. In the former case, we're definitely
# scrolling forwards, so any positive value will do for delta. In the latter case, we're definitely
# scrolling backwards, so a delta of -1 will do.
delta = factor * getDimension(element, direction, amount) || -1
delta = delta / Math.abs delta # 1 or -1
before = element[axisName]
element[axisName] += delta
after = element[axisName]
element[axisName] = before
before != after
# Find the element we should and can scroll.
findScrollableElement = (element = document.body, direction, amount, factor = 1) ->
axisName = scrollProperties[direction].axisName
while element != document.body and
not (isScrollPossible(element, direction, amount, factor) and isScrollAllowed(element, direction))
element = element.parentElement || document.body
element
performScroll = (element, axisName, amount, checkVisibility = true) ->
before = element[axisName]
element[axisName] += amount
if checkVisibility
# if the activated element has been scrolled completely offscreen, subsequent changes in its scroll
# position will not provide any more visual feedback to the user. therefore we deactivate it so that
# subsequent scrolls only move the parent element.
rect = activatedElement.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth)
activatedElement = element
# Return the amount by which the scroll position has changed.
element[axisName] - before
# How scrolling is handled:
# - For non-smooth 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 (roughly) the same position as two slower taps.
# - For smooth scrolling with keyboard repeat, the most recently-activated animator continues scrolling
# until its corresponding keyup event is received. We never initiate a new animator on keyboard repeat.
# Scroll by a relative amount (a number) in some direction, possibly smoothly.
doScrollBy = do ->
time = 0
mostRecentActivationId = -1
lastEvent = null
keyHandler = null
(element, direction, amount, wantSmooth) ->
if not keyHandler
keyHandler = root.handlerStack.push
keydown: (event) -> lastEvent = event
keyup: -> time += 1
axisName = scrollProperties[direction].axisName
unless wantSmooth and settings.get "smoothScroll"
return performScroll element, axisName, amount
if mostRecentActivationId == time or lastEvent?.repeat
# Either the most-recently activated animator has not yet received its keyup event (so it's still
# scrolling), or this is a keyboard repeat (for which we don't initiate a new animator).
return
mostRecentActivationId = activationId = ++time
duration = 100 # Duration in ms.
fudgeFactor = 25
# Allow a bit longer for longer scrolls.
duration += fudgeFactor * Math.log Math.abs amount
# Round away from 0, so that we don't leave any scroll amount unscrolled.
roundOut = if 0 <= amount then Math.ceil else Math.floor
delta = roundOut(amount / duration)
animatorId = null
lastTime = null
start = null
scrolledAmount = 0
shouldStopScrolling = (progress) ->
if activationId == time
# This is the most recently-activated animator and we haven't yet seen its keyup event, so keep going.
false
else
duration <= progress
animate = (timestamp) ->
start ?= timestamp
progress = timestamp - start
scrollDelta = roundOut(delta * progress) - scrolledAmount
scrolledAmount += scrollDelta
if performScroll(element, axisName, scrollDelta, false) != scrollDelta or shouldStopScrolling progress
# One final call of performScroll to check the visibility of the activated element.
performScroll(element, axisName, 0, true)
window.cancelAnimationFrame(animatorId)
else
animatorId = window.requestAnimationFrame(animate)
animatorId = window.requestAnimationFrame(animate)
Scroller =
init: (frontendSettings) ->
settings = frontendSettings
handlerStack.push DOMActivate: -> activatedElement = event.target
# 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) ->
# if this is called before domReady, just use the window scroll function
if (!document.body and amount instanceof Number)
if (direction == "x")
window.scrollBy(amount, 0)
else
window.scrollBy(0, amount)
return
activatedElement ||= document.body
return unless activatedElement
element = findScrollableElement activatedElement, direction, amount, factor
elementAmount = factor * getDimension element, direction, amount
doScrollBy element, direction, elementAmount, true
scrollTo: (direction, pos, wantSmooth = false) ->
return unless document.body or activatedElement
activatedElement ||= document.body
element = findScrollableElement activatedElement, direction, pos
amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName]
doScrollBy element, direction, amount, wantSmooth
root = exports ? window
root.Scroller = Scroller
|