Gitter migration: Point people to app.gitter.im (rollout pt. 1)
[gitter.git] / public / js / utils / rollers.js
blob901ee71b77b014cc6c314609db34fcbb08a7c441
1 'use strict';
2 var Mutant = require('mutantjs');
3 var _ = require('lodash');
4 var rafUtils = require('./raf-utils');
5 var passiveEventListener = require('./passive-event-listener');
6 var raf = require('./raf');
8 module.exports = (function() {
9   /** @const */ var TRACK_BOTTOM = 1;
10   /** @const */ var STABLE = 3;
12   /** Number of pixels we need to be within before we say we're at the bottom */
13   /** @const */ var BOTTOM_MARGIN = 10;
14   /** Number of pixels to show above a message that we scroll to. Context FTW!
15   /** @const */ var TOP_OFFSET = 300;
17   /* Put your scrolling panels on rollers */
18   function Rollers(target, childContainer, options) {
19     options = options || {};
21     this._target = target;
22     this._childContainer = childContainer || target;
23     this._mutationHandlers = {};
24     this._mutationHandlers[TRACK_BOTTOM] = this.updateTrackBottom.bind(this);
25     this._mutationHandlers[STABLE] = this.updateStableTracking.bind(this);
27     this._stableElement = null;
29     if (options.doNotTrack) {
30       this._mode = STABLE;
31     } else {
32       this.initTrackingMode();
33     }
35     var adjustScroll = this.adjustScroll.bind(this);
37     this.mutant = new Mutant(target, adjustScroll, {
38       transitions: true,
39       observers: { attributes: false, characterData: false },
40       ignoreTransitions: [
41         'opacity',
42         'background-color',
43         'border',
44         'color',
45         'border-right-color',
46         'visibility'
47       ]
48       //ignoreFilter: function(mutationRecords) {
49       //  var filter = mutationRecords.reduce(function(accum, r) {
50       //    var v = r.type === 'attributes' && r.attributeName === 'class' && r.target.id === 'chat-container';
51       //    accum = accum && v;
52       //    return accum;
53       //  }, true);
54       //  return filter;
55       //}
56     });
58     var self = this;
59     function trackLocationAnimationFrame() {
60       raf(function() {
61         self.trackLocation();
62       });
63     }
65     var _trackLocation = _.throttle(trackLocationAnimationFrame, 100);
66     passiveEventListener.addEventListener(target, 'scroll', _trackLocation);
67     window.addEventListener('resize', adjustScroll, false);
68     window.addEventListener('focusin', adjustScroll, false);
69     window.addEventListener('focusout', adjustScroll, false);
70   }
72   Rollers.prototype = {
73     adjustScroll: function() {
74       this._mutationHandlers[this._mode]();
75       this._postMutateTop = this._target.scrollTop;
76       return true;
77     },
79     adjustScrollContinuously: function(ms) {
80       rafUtils.intervalUntil(this.adjustScroll.bind(this), ms);
81     },
83     initTrackingMode: function() {
84       if (this.isScrolledToBottom()) {
85         this._mode = TRACK_BOTTOM;
86       } else {
87         // Default to stable mode
88         this.stable();
89       }
90     },
92     stable: function(stableElement) {
93       var target = this._target;
94       this._mode = STABLE;
96       this._stableElement = stableElement || this.getBottomMostVisibleElement();
98       // nothing to stabilize (no content)
99       if (!this._stableElement) return;
101       // TODO: check that the element is within the targets DOM hierarchy
102       var scrollBottom = target.scrollTop + target.clientHeight;
103       var stableElementTop = this._stableElement.offsetTop - target.offsetTop;
105       // Calculate an record the distance of the stable element to the bottom of the view
106       this._stableElementFromBottom = scrollBottom - stableElementTop;
107     },
109     setModeLocked: function(value) {
110       this.modeLocked = value;
111       if (!value) {
112         this.trackLocation();
113       }
114     },
116     disableTrackBottom: function() {
117       this.disableTrackBottom = true;
118     },
120     enableTrackBottom: function() {
121       this.disableTrackBottom = true;
122       if (this.isScrolledToBottom()) {
123         this.trackLocation();
124       }
125     },
127     isScrolledToBottom: function() {
128       var target = this._target;
129       var atBottom = target.scrollTop >= target.scrollHeight - target.clientHeight - BOTTOM_MARGIN;
130       return atBottom;
131     },
133     /*
134      * Update the scroll position to follow the bottom of the scroll pane
135      */
136     updateTrackBottom: function() {
137       var target = this._target;
138       var scrollTop = target.scrollHeight - target.clientHeight;
139       this.scroll(scrollTop);
140     },
142     startTransition: function(element, maxTimeMs) {
143       this.mutant.startTransition(element, maxTimeMs);
144     },
146     endTransition: function(element) {
147       this.mutant.endTransition(element);
148     },
150     /*
151      * Scroll to the bottom and switch the mode to TRACK_BOTTOM
152      */
153     scrollToBottom: function() {
154       var target = this._target;
155       var scrollTop = target.scrollHeight - target.clientHeight;
156       this.scroll(scrollTop);
158       delete this._stableElement;
159       delete this._stableElementFromBottom;
160       this._mode = TRACK_BOTTOM;
161       this._postMutateTop = scrollTop;
162     },
164     /*
165      * Scroll to the bottom and switch the mode to TRACK_BOTTOM
166      */
167     scrollToElement: function(element, options) {
168       var target = this._target;
169       var scrollTop;
171       if (options && options.centre) {
172         // Centre the element in the viewport
173         var elementHeight = element.offsetHeight;
174         var viewportHeight = target.clientHeight;
175         if (elementHeight < viewportHeight) {
176           scrollTop = Math.floor(element.offsetTop + elementHeight / 2 - viewportHeight / 2);
177         }
178       }
180       if (!scrollTop) {
181         scrollTop = element.offsetTop - TOP_OFFSET;
182       }
184       if (scrollTop < 0) scrollTop = 0;
186       this.scroll(scrollTop);
188       this.stable(element);
189     },
191     /*
192      * Scroll to the bottom and switch the mode to TRACK_BOTTOM
193      */
194     scrollToBottomContinuously: function(ms) {
195       rafUtils.intervalUntil(this.scrollToBottom.bind(this), ms);
196     },
198     /* Update the scrollTop to adjust for reflow when in STABLE mode */
199     updateStableTracking: function() {
200       if (!this._stableElement) return;
201       var target = this._target;
203       var stableElementTop = this._stableElement.offsetTop - target.offsetTop;
204       var top = stableElementTop - target.clientHeight + this._stableElementFromBottom;
206       this.scroll(top);
207     },
209     /* Track current position */
210     trackLocation: function() {
211       var target = this._target;
212       if (this._postMutateTop === target.scrollTop) {
213         return true;
214       }
216       var atBottom = target.scrollTop >= target.scrollHeight - target.clientHeight - BOTTOM_MARGIN;
218       if (!this.modeLocked) {
219         if (atBottom) {
220           if (this._mode != TRACK_BOTTOM) {
221             this._mode = TRACK_BOTTOM;
222           }
223         } else {
224           if (this._mode != STABLE) {
225             this._mode = STABLE;
226           }
227         }
228       }
230       if (this._mode === STABLE) {
231         this._stableElement = this.getBottomMostVisibleElement();
233         if (!this._stableElement) return;
235         // TODO: check that the element is within the targets DOM hierarchy
236         var scrollBottom = target.scrollTop + target.clientHeight;
237         var stableElementTop = this._stableElement.offsetTop - target.offsetTop;
239         // Calculate an record the distance of the stable element to the bottom of the view
240         this._stableElementFromBottom = scrollBottom - stableElementTop;
241       }
243       return true;
244     },
246     /* Get the Y coordinate of the bottom of the viewport */
247     getScrollBottom: function() {
248       var scrollTop = this._target.scrollTop;
249       return this._target.clientHeight + scrollTop;
250     },
252     /* Get the element at the bottom of the viewport */
253     getBottomMostVisibleElement: function() {
254       var scrollTop = this._target.scrollTop;
255       var clientHeight = this._target.clientHeight;
256       var max = scrollTop + clientHeight;
257       var children = this._childContainer.children;
259       for (var i = children.length - 1; i >= 0; i--) {
260         var child = children[i];
261         if (child.offsetTop < max) {
262           return child;
263         }
264       }
266       return;
267     },
269     /* Get the element in the centre of the viewport */
270     getMostCenteredElement: function() {
271       var scrollTop = this._target.scrollTop;
272       var clientHeight = this._target.clientHeight;
273       var max = scrollTop + clientHeight;
274       var children = this._childContainer.children;
276       for (var i = children.length - 1; i >= 0; i--) {
277         var child = children[i];
278         var middle = clientHeight / 2;
279         var pos = max - child.offsetTop;
280         if (pos > middle) {
281           return child;
282         }
283       }
285       return;
286     },
288     scroll: function(pixelsFromTop) {
289       this._target.scrollTop = pixelsFromTop;
290     }
291   };
293   return Rollers;
294 })();