aboutsummaryrefslogtreecommitdiffstats
path: root/content_scripts/scroller.coffee
blob: 4d1109c93c8d4b8af47d59c2116d9b2c3cf359e4 (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
window.Scroller = root = {}

#
# 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

root.init = (frontendSettings) ->
  settings = frontendSettings
  handlerStack.push DOMActivate: -> activatedElement = event.target

scrollProperties =
  x: {
    axisName: 'scrollLeft'
    max: 'scrollWidth'
    viewSize: 'clientHeight'
  }
  y: {
    axisName: 'scrollTop'
    max: 'scrollHeight'
    viewSize: 'clientWidth'
  }

getDimension = (el, direction, name) ->
  # 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]]

# Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149).
# Therefore we cannot figure out if we have scrolled to the bottom of an element by testing if scrollTop +
# clientHeight == scrollHeight. So just try to increase scrollTop blindly -- if it fails we know we have
# reached the end of the content.
ensureScrollChange = (direction, changeFn) ->
  axisName = scrollProperties[direction].axisName
  element = activatedElement
  progress = 0
  loop
    oldScrollValue = element[axisName]
    # Elements with `overflow: hidden` should not be scrolled.
    overflow = window.getComputedStyle(element).getPropertyValue("overflow-#{direction}")
    changeFn(element, axisName) unless overflow == "hidden"
    progress += element[axisName] - oldScrollValue
    break unless element[axisName] == oldScrollValue && element != document.body
    # we may have an orphaned element. if so, just scroll the body element.
    element = element.parentElement || document.body

  # 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.
  return progress

# Scroll by a relative amount in some direction, possibly smoothly.
# The constants below seem to roughly match chrome's scroll speeds for both short and long scrolls.
# TODO(smblott) For very-long scrolls, chrome implements a soft landing; we don't.
doScrollBy = do ->
  interval = 10 # Update interval (in ms).
  duration = 120 # This must be a multiple of interval (also in ms).
  fudgeFactor = 25
  timer = null

  clearTimer = ->
    if timer
      clearInterval timer
      timer = null

  # Allow a bit longer for longer scrolls.
  calculateExtraDuration = (amount) ->
    extra = fudgeFactor * Math.log Math.abs amount
    # Ensure we have a multiple of interval.
    return interval * Math.round (extra / interval)

  scroller = (direction,amount) ->
    return ensureScrollChange direction, (element, axisName) -> element[axisName] += amount

  (direction,amount,wantSmooth) ->
    clearTimer()

    unless wantSmooth and settings.get "smoothScroll"
      scroller direction, amount
      return

    requiredTicks = (duration + calculateExtraDuration amount) / interval
    # Round away from 0, so that we don't leave any requested scroll amount unscrolled.
    rounder = (if 0 <= amount then Math.ceil else Math.floor)
    delta = rounder(amount / requiredTicks)

    ticks = 0
    ticker = ->
      # If we haven't scrolled by the expected amount, then we've hit the top, bottom or side of the activated
      # element, so stop scrolling.
      if scroller(direction, delta) != delta or ++ticks == requiredTicks
        clearTimer()

    timer = setInterval ticker, interval
    ticker()

# 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.
root.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

  if (!activatedElement || !isRendered(activatedElement))
    activatedElement = document.body

  if Utils.isString amount
    elementAmount = getDimension activatedElement, direction, amount
  else
    elementAmount = amount
  elementAmount *= factor

  doScrollBy direction, elementAmount, true

root.scrollTo = (direction, pos, wantSmooth=false) ->
  return unless document.body

  if (!activatedElement || !isRendered(activatedElement))
    activatedElement = document.body

  if Utils.isString pos
    elementPos = getDimension activatedElement, direction, pos
  else
    elementPos = pos
  axisName = scrollProperties[direction].axisName
  elementAmount = elementPos - activatedElement[axisName]

  doScrollBy direction, elementAmount, wantSmooth

# TODO refactor and put this together with the code in getVisibleClientRect
isRendered = (element) ->
  computedStyle = window.getComputedStyle(element, null)
  return !(computedStyle.getPropertyValue("visibility") != "visible" ||
      computedStyle.getPropertyValue("display") == "none")