aboutsummaryrefslogtreecommitdiffstats
path: root/content_scripts/scroller.coffee
blob: 8d6ca12820aa5158e2053979484b5323f74b37ac (plain)
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
#
# 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.
shouldScroll = (element, direction) ->
  computedStyle = window.getComputedStyle(element)
  # Elements with `overflow: hidden` should not be scrolled.
  return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden"
  # Non-visible elements should not be scrolled.
  return false if ["hidden", "collapse"].indexOf(computedStyle.getPropertyValue("visibility")) != -1
  return false if computedStyle.getPropertyValue("display") == "none"
  true

# 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 (then put it back).
# Bug verified in Chrome 38.0.2125.104.
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, direction, amount, factor = 1) ->
  axisName = scrollProperties[direction].axisName
  while element != document.body and
    not (isScrollPossible(element, direction, amount, factor) and shouldScroll(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 # Logical time.
  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