Post Cloaking:

* We now use a new custom view, {{cloaked-collection}} to display posts in a topic.

* Posts are removed and inserted (cloaked/uncloaked) into the DOM dynamically based on whether they
  are visible in the current browser viewport.

* There's been a lot of refactoring to ensure the relationship between the post views and the topic
  controller is sane.

* Lots of fixes involving jumping to a post, including a new LockOn component to that tries to stay
  focused on an element even if stuff is loading before it in the DOM that would normally push it
  down.
This commit is contained in:
Robin Ward 2013-11-20 16:33:36 -05:00
parent 8a9bef944f
commit 40f86829f7
21 changed files with 631 additions and 557 deletions

View File

@ -200,13 +200,13 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
},
replyAsNewTopic: function(post) {
var composerController = this.get('controllers.composer');
var promise = composerController.open({
action: Discourse.Composer.CREATE_TOPIC,
draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY
});
var postUrl = "" + location.protocol + "//" + location.host + (post.get('url'));
var postLink = "[" + (this.get('title')) + "](" + postUrl + ")";
var composerController = this.get('controllers.composer'),
promise = composerController.open({
action: Discourse.Composer.CREATE_TOPIC,
draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY
}),
postUrl = "" + location.protocol + "//" + location.host + (post.get('url')),
postLink = "[" + (this.get('title')) + "](" + postUrl + ")";
promise.then(function() {
Discourse.Post.loadQuote(post.get('id')).then(function(q) {
@ -459,6 +459,65 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
}
return true;
}
},
// If our current post is changed, notify the router
_currentPostChanged: function() {
var currentPost = this.get('currentPost');
if (currentPost) {
this.send('postChangedRoute', currentPost);
}
}.observes('currentPost'),
sawObjects: function(posts) {
if (posts) {
var self = this,
lastReadPostNumber = this.get('last_read_post_number');
posts.forEach(function(post) {
var postNumber = post.get('post_number');
if (postNumber > lastReadPostNumber) {
lastReadPostNumber = postNumber;
}
post.set('read', true);
});
self.set('last_read_post_number', lastReadPostNumber);
}
},
topVisibleChanged: function(post) {
var postStream = this.get('postStream'),
firstLoadedPost = postStream.get('firstLoadedPost');
this.set('currentPost', post.get('post_number'));
if (firstLoadedPost && firstLoadedPost === post) {
// Note: jQuery shouldn't be done in a controller, but how else can we
// trigger a scroll after a promise resolves in a controller? We need
// to do this to preserve upwards infinte scrolling.
var $body = $('body'),
$elem = $('#post-cloak-' + post.get('post_number')),
distToElement = $body.scrollTop() - $elem.position().top;
postStream.prependMore().then(function() {
Em.run.next(function () {
$elem = $('#post-cloak-' + post.get('post_number'));
$('html, body').scrollTop($elem.position().top + distToElement);
});
});
}
},
bottomVisibleChanged: function(post) {
this.set('progressPosition', post.get('post_number'));
var postStream = this.get('postStream'),
lastLoadedPost = postStream.get('lastLoadedPost');
if (lastLoadedPost && lastLoadedPost === post) {
postStream.appendMore();
}
}

View File

@ -1,12 +1,6 @@
/**
Track visible elemnts on the screen.
You can register for triggers on:
`focusChanged` the top element we're focusing on
`seenElement` if we've seen the element
@class Eyeline
@namespace Discourse
@module Discourse
@ -16,107 +10,23 @@ Discourse.Eyeline = function Eyeline(selector) {
this.selector = selector;
};
/**
Call this to analyze the positions of all the nodes in a set
returns: a hash with top, bottom and onScreen items
{top: , bottom:, onScreen:}
**/
Discourse.Eyeline.analyze = function(rows) {
var current, goingUp, i, increment, offset,
winHeight, winOffset, detected, onScreen,
bottom, top, outerHeight;
if (rows.length === 0) return;
i = parseInt(rows.length / 2, 10);
increment = parseInt(rows.length / 4, 10);
goingUp = undefined;
winOffset = window.pageYOffset || $('html').scrollTop();
winHeight = window.innerHeight || $(window).height();
while (true) {
if (i === 0 || (i >= rows.length - 1)) {
break;
}
current = $(rows[i]);
offset = current.offset();
if (offset.top - winHeight < winOffset) {
if (offset.top + current.outerHeight() - window.innerHeight > winOffset) {
break;
} else {
i = i + increment;
if (goingUp !== undefined && increment === 1 && !goingUp) {
break;
}
goingUp = true;
}
} else {
i = i - increment;
if (goingUp !== undefined && increment === 1 && goingUp) {
break;
}
goingUp = false;
}
if (increment > 1) {
increment = parseInt(increment / 2, 10);
goingUp = undefined;
}
if (increment === 0) {
increment = 1;
goingUp = undefined;
}
}
onScreen = [];
bottom = i;
// quick analysis of whats on screen
while(true) {
if(i < 0) { break;}
current = $(rows[i]);
offset = current.offset();
outerHeight = current.outerHeight();
// on screen
if(offset.top > winOffset && offset.top + outerHeight < winOffset + winHeight) {
onScreen.unshift(i);
} else {
if(offset.top < winOffset) {
top = i;
break;
} else {
// bottom
}
}
i -=1;
}
return({top: top, bottom: bottom, onScreen: onScreen});
};
/**
Call this whenever you want to consider what is being seen by the browser
@method update
**/
Discourse.Eyeline.prototype.update = function() {
var $elements, atBottom, bottomOffset, docViewBottom, docViewTop, documentHeight, foundElement, windowHeight,
_this = this;
var docViewTop = $(window).scrollTop(),
windowHeight = $(window).height(),
docViewBottom = docViewTop + windowHeight,
documentHeight = $(document).height(),
$elements = $(this.selector),
atBottom = false,
foundElement = false,
bottomOffset = $elements.last().offset(),
self = this;
docViewTop = $(window).scrollTop();
windowHeight = $(window).height();
docViewBottom = docViewTop + windowHeight;
documentHeight = $(document).height();
$elements = $(this.selector);
atBottom = false;
if (bottomOffset = $elements.last().offset()) {
if (bottomOffset) {
atBottom = (bottomOffset.top <= docViewBottom) && (bottomOffset.top >= docViewTop);
}
@ -124,14 +34,12 @@ Discourse.Eyeline.prototype.update = function() {
foundElement = false;
return $elements.each(function(i, elem) {
var $elem, elemBottom, elemTop, markSeen;
var $elem = $(elem),
elemTop = $elem.offset().top,
elemBottom = elemTop + $elem.height(),
markSeen = false;
$elem = $(elem);
elemTop = $elem.offset().top;
elemBottom = elemTop + $elem.height();
markSeen = false;
// It's seen if...
// ...the element is vertically within the top and botom
if ((elemTop <= docViewBottom) && (elemTop >= docViewTop)) markSeen = true;
@ -145,19 +53,17 @@ Discourse.Eyeline.prototype.update = function() {
// If you hit the bottom we mark all the elements as seen. Otherwise, just the first one
if (!atBottom) {
_this.trigger('saw', {
detail: $elem
});
self.trigger('saw', { detail: $elem });
if (i === 0) {
_this.trigger('sawTop', { detail: $elem });
self.trigger('sawTop', { detail: $elem });
}
return false;
}
if (i === 0) {
_this.trigger('sawTop', { detail: $elem });
self.trigger('sawTop', { detail: $elem });
}
if (i === ($elements.length - 1)) {
return _this.trigger('sawBottom', { detail: $elem });
return self.trigger('sawBottom', { detail: $elem });
}
});
};
@ -169,10 +75,9 @@ Discourse.Eyeline.prototype.update = function() {
@method flushRest
**/
Discourse.Eyeline.prototype.flushRest = function() {
var eyeline = this;
return $(this.selector).each(function(i, elem) {
var $elem = $(elem);
return eyeline.trigger('saw', { detail: $elem });
var self = this;
$(this.selector).each(function(i, elem) {
return self.trigger('saw', { detail: $(elem) });
});
};

View File

@ -6,10 +6,13 @@
@namespace Discourse
@module Discourse
**/
var PAUSE_UNLESS_SCROLLED = 1000 * 60 * 3,
MAX_TRACKING_TIME = 1000 * 60 * 6;
Discourse.ScreenTrack = Ember.Object.extend({
init: function() {
var screenTrack = this;
this.reset();
},
@ -24,9 +27,9 @@ Discourse.ScreenTrack = Ember.Object.extend({
// Create an interval timer if we don't have one.
if (!this.get('interval')) {
var screenTrack = this;
var self = this;
this.set('interval', setInterval(function () {
screenTrack.tick();
self.tick();
}, 1000));
}
@ -57,13 +60,15 @@ Discourse.ScreenTrack = Ember.Object.extend({
// Reset our timers
reset: function() {
this.set('lastTick', new Date().getTime());
this.set('lastScrolled', new Date().getTime());
this.set('lastFlush', 0);
this.set('cancelled', false);
this.set('timings', {});
this.set('totalTimings', {});
this.set('topicTime', 0);
this.setProperties({
lastTick: new Date().getTime(),
lastScrolled: new Date().getTime(),
lastFlush: 0,
cancelled: false,
timings: {},
totalTimings: {},
topicTime: 0
});
},
scrolled: function() {
@ -76,24 +81,23 @@ Discourse.ScreenTrack = Ember.Object.extend({
// We don't log anything unless we're logged in
if (!Discourse.User.current()) return;
var newTimings = {};
// Update our total timings
var totalTimings = this.get('totalTimings');
var newTimings = {},
totalTimings = this.get('totalTimings');
_.each(this.get('timings'), function(timing,key) {
if (!totalTimings[timing.postNumber])
totalTimings[timing.postNumber] = 0;
if (timing.time > 0 && totalTimings[timing.postNumber] < Discourse.ScreenTrack.MAX_TRACKING_TIME) {
if (timing.time > 0 && totalTimings[timing.postNumber] < MAX_TRACKING_TIME) {
totalTimings[timing.postNumber] += timing.time;
newTimings[timing.postNumber] = timing.time;
}
timing.time = 0;
});
var topicId = parseInt(this.get('topicId'), 10);
var highestSeen = 0;
var topicId = parseInt(this.get('topicId'), 10),
highestSeen = 0;
_.each(newTimings, function(time,postNumber) {
highestSeen = Math.max(highestSeen, parseInt(postNumber, 10));
});
@ -103,6 +107,7 @@ Discourse.ScreenTrack = Ember.Object.extend({
highestSeenByTopic[topicId] = highestSeen;
Discourse.TopicTrackingState.current().updateSeen(topicId, highestSeen);
}
if (!$.isEmptyObject(newTimings)) {
Discourse.ajax('/topics/timings', {
data: {
@ -126,7 +131,7 @@ Discourse.ScreenTrack = Ember.Object.extend({
// If the user hasn't scrolled the browser in a long time, stop tracking time read
var sinceScrolled = new Date().getTime() - this.get('lastScrolled');
if (sinceScrolled > Discourse.ScreenTrack.PAUSE_UNLESS_SCROLLED) {
if (sinceScrolled > PAUSE_UNLESS_SCROLLED) {
this.reset();
return;
}
@ -142,18 +147,16 @@ Discourse.ScreenTrack = Ember.Object.extend({
if (!Discourse.get("hasFocus")) return;
this.set('topicTime', this.get('topicTime') + diff);
var docViewTop = $(window).scrollTop() + $('header').height();
var docViewBottom = docViewTop + $(window).height();
var docViewTop = $(window).scrollTop() + $('header').height(),
docViewBottom = docViewTop + $(window).height();
// TODO: Eyeline has a smarter more accurate function here. It's bad to do jQuery
// in a model like component, so we should refactor this out later.
var screenTrack = this;
_.each(this.get('timings'),function(timing,id) {
var $element, elemBottom, elemTop;
$element = $(id);
var $element = $(id);
if ($element.length === 1) {
elemTop = $element.offset().top;
elemBottom = elemTop + $element.height();
var elemTop = $element.offset().top,
elemBottom = elemTop + $element.height();
// If part of the element is on the screen, increase the counter
if (((docViewTop <= elemTop && elemTop <= docViewBottom)) || ((docViewTop <= elemBottom && elemBottom <= docViewBottom))) {
@ -165,13 +168,5 @@ Discourse.ScreenTrack = Ember.Object.extend({
});
Discourse.ScreenTrack.reopenClass(Discourse.Singleton, {
// Don't send events if we haven't scrolled in a long time
PAUSE_UNLESS_SCROLLED: 1000 * 60 * 3,
// After 6 minutes stop tracking read position on post
MAX_TRACKING_TIME: 1000 * 60 * 6
});
Discourse.ScreenTrack.reopenClass(Discourse.Singleton);

View File

@ -32,7 +32,7 @@ Discourse.URL = Em.Object.createWithMixins({
// which triggers a replaceState even though the topic hasn't fully loaded yet!
Em.run.next(function() {
var location = Discourse.URL.get('router.location');
if (location.replaceURL) { location.replaceURL(path); }
if (location && location.replaceURL) { location.replaceURL(path); }
});
}
},
@ -133,15 +133,21 @@ Discourse.URL = Em.Object.createWithMixins({
Discourse.URL.replaceState(path);
var topicController = Discourse.__container__.lookup('controller:topic'),
opts = {};
opts = {},
postStream = topicController.get('postStream');
if (newMatches[3]) opts.nearPost = newMatches[3];
var postStream = topicController.get('postStream');
var closest = opts.nearPost || 1;
postStream.refresh(opts).then(function() {
topicController.setProperties({
currentPost: opts.nearPost || 1,
progressPosition: opts.nearPost || 1
currentPost: closest,
progressPosition: closest,
highlightOnInsert: closest,
enteredAt: new Date().getTime().toString()
});
}).then(function() {
Discourse.TopicView.jumpToPost(closest);
});
// Abort routing, we have replaced our state.

View File

@ -7,6 +7,7 @@
@namespace Discourse
@module Discourse
**/
Discourse.Scrolling = Em.Mixin.create({
/**
@ -18,20 +19,16 @@ Discourse.Scrolling = Em.Mixin.create({
bindScrolling: function(opts) {
opts = opts || {debounce: 100};
var scrollingMixin = this;
var onScrollMethod;
var self = this,
onScrollMethod = function(e) {
return Em.run.scheduleOnce('afterRender', self, 'scrolled');
};
if (opts.debounce) {
onScrollMethod = Discourse.debounce(function() {
return scrollingMixin.scrolled();
}, opts.debounce);
} else {
onScrollMethod = function() {
return scrollingMixin.scrolled();
};
onScrollMethod = Discourse.debounce(onScrollMethod, opts.debounce);
}
Discourse.ScrollingDOMMethods.bindOnScroll(onScrollMethod);
Discourse.ScrollingDOMMethods.bindOnScroll(onScrollMethod, opts.name);
},
/**
@ -39,8 +36,8 @@ Discourse.Scrolling = Em.Mixin.create({
@method unbindScrolling
*/
unbindScrolling: function() {
Discourse.ScrollingDOMMethods.unbindOnScroll();
unbindScrolling: function(name) {
Discourse.ScrollingDOMMethods.unbindOnScroll(name);
}
});
@ -56,14 +53,16 @@ Discourse.Scrolling = Em.Mixin.create({
**/
Discourse.ScrollingDOMMethods = {
bindOnScroll: function(onScrollMethod) {
$(document).bind('touchmove.discourse', onScrollMethod);
$(window).bind('scroll.discourse', onScrollMethod);
bindOnScroll: function(onScrollMethod, name) {
name = name || 'default';
$(document).bind('touchmove.discourse-' + name, onScrollMethod);
$(window).bind('scroll.discourse-' + name, onScrollMethod);
},
unbindOnScroll: function() {
$(window).unbind('scroll.discourse');
$(document).unbind('touchmove.discourse');
unbindOnScroll: function(name) {
name = name || 'default';
$(window).unbind('scroll.discourse-' + name);
$(document).unbind('touchmove.discourse-' + name);
}
};

View File

@ -50,14 +50,32 @@ Discourse.PostStream = Em.Object.extend({
/**
Have we loaded the first post in the stream?
@property firstPostLoaded
@property firstPostPresent
**/
firstPostLoaded: function() {
firstPostPresent: function() {
if (!this.get('hasLoadedData')) { return false; }
return !!this.get('posts').findProperty('id', this.get('firstPostId'));
}.property('hasLoadedData', 'posts.[]', 'firstPostId'),
firstPostNotLoaded: Em.computed.not('firstPostLoaded'),
firstPostNotLoaded: Em.computed.not('firstPostPresent'),
/**
The first post that we have loaded. Useful for checking to see if we should scroll upwards
@property firstLoadedPost
**/
firstLoadedPost: function() {
return _.first(this.get('posts'));
}.property('posts.@each'),
/**
The last post we have loaded. Useful for checking to see if we should load more
@property lastLoadedPost
**/
lastLoadedPost: function() {
return _.last(this.get('posts'));
}.property('posts.@each'),
/**
Returns the id of the first post in the set
@ -80,14 +98,14 @@ Discourse.PostStream = Em.Object.extend({
/**
Have we loaded the last post in the stream?
@property lastPostLoaded
@property loadedAllPosts
**/
lastPostLoaded: function() {
loadedAllPosts: function() {
if (!this.get('hasLoadedData')) { return false; }
return !!this.get('posts').findProperty('id', this.get('lastPostId'));
}.property('hasLoadedData', 'posts.@each.id', 'lastPostId'),
lastPostNotLoaded: Em.computed.not('lastPostLoaded'),
lastPostNotLoaded: Em.computed.not('loadedAllPosts'),
/**
Returns a JS Object of current stream filter options. It should match the query
@ -163,18 +181,18 @@ Discourse.PostStream = Em.Object.extend({
**/
nextWindow: function() {
// If we can't find the last post loaded, bail
var lastPost = _.last(this.get('posts'));
if (!lastPost) { return []; }
var lastLoadedPost = this.get('lastLoadedPost');
if (!lastLoadedPost) { return []; }
// Find the index of the last post loaded, if not found, bail
var stream = this.get('stream');
var lastIndex = this.indexOf(lastPost);
var lastIndex = this.indexOf(lastLoadedPost);
if (lastIndex === -1) { return []; }
if ((lastIndex + 1) >= this.get('filteredPostsCount')) { return []; }
// find our window of posts
return stream.slice(lastIndex+1, lastIndex+Discourse.SiteSettings.posts_per_page+1);
}.property('posts.@each', 'stream.@each'),
}.property('lastLoadedPost', 'stream.@each'),
/**
@ -197,7 +215,7 @@ Discourse.PostStream = Em.Object.extend({
**/
toggleSummary: function() {
this.toggleProperty('summary');
this.refresh();
return this.refresh();
},
/**
@ -227,39 +245,31 @@ Discourse.PostStream = Em.Object.extend({
@returns {Ember.Deferred} a promise that is resolved when the posts have been inserted into the stream.
**/
refresh: function(opts) {
opts = opts || {};
opts.nearPost = parseInt(opts.nearPost, 10);
var topic = this.get('topic');
var postStream = this;
var topic = this.get('topic'),
self = this;
// Do we already have the post in our list of posts? Jump there.
var postWeWant = this.get('posts').findProperty('post_number', opts.nearPost);
if (postWeWant) {
Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost);
return Ember.RSVP.reject();
}
if (postWeWant) { return Ember.RSVP.resolve(); }
// TODO: if we have all the posts in the filter, don't go to the server for them.
postStream.set('loadingFilter', true);
self.set('loadingFilter', true);
opts = _.merge(opts, postStream.get('streamFilters'));
opts = _.merge(opts, self.get('streamFilters'));
// Request a topicView
return Discourse.PostStream.loadTopicView(topic.get('id'), opts).then(function (json) {
topic.updateFromJson(json);
postStream.updateFromJson(json.post_stream);
postStream.setProperties({ loadingFilter: false, loaded: true });
self.updateFromJson(json.post_stream);
self.setProperties({ loadingFilter: false, loaded: true });
if (opts.nearPost) {
Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost);
} else {
Discourse.TopicView.jumpToPost(topic.get('id'), 1);
}
Discourse.URL.set('queryParams', postStream.get('streamFilters'));
}, function(result) {
postStream.errorLoading(result);
Discourse.URL.set('queryParams', self.get('streamFilters'));
}).fail(function(result) {
self.errorLoading(result);
});
},
hasLoadedData: Em.computed.and('hasPosts', 'hasStream'),
@ -271,23 +281,23 @@ Discourse.PostStream = Em.Object.extend({
@returns {Ember.Deferred} a promise that's resolved when the posts have been added.
**/
appendMore: function() {
var postStream = this;
var self = this;
// Make sure we can append more posts
if (!postStream.get('canAppendMore')) { return Ember.RSVP.reject(); }
if (!self.get('canAppendMore')) { return Ember.RSVP.reject(); }
var postIds = postStream.get('nextWindow');
var postIds = self.get('nextWindow');
if (Ember.isEmpty(postIds)) { return Ember.RSVP.reject(); }
postStream.set('loadingBelow', true);
self.set('loadingBelow', true);
var stopLoading = function() {
postStream.set('loadingBelow', false);
self.set('loadingBelow', false);
};
return postStream.findPostsByIds(postIds).then(function(posts) {
return self.findPostsByIds(postIds).then(function(posts) {
posts.forEach(function(p) {
postStream.appendPost(p);
self.appendPost(p);
});
stopLoading();
}, stopLoading);
@ -349,7 +359,7 @@ Discourse.PostStream = Em.Object.extend({
});
// If we're at the end of the stream, add the post
if (this.get('lastPostLoaded')) {
if (this.get('loadedAllPosts')) {
this.appendPost(post);
}
@ -452,11 +462,11 @@ Discourse.PostStream = Em.Object.extend({
// We only trigger if there are no filters active
if (!this.get('hasNoFilters')) { return; }
var lastPostLoaded = this.get('lastPostLoaded');
var loadedAllPosts = this.get('loadedAllPosts');
if (this.get('stream').indexOf(postId) === -1) {
this.get('stream').addObject(postId);
if (lastPostLoaded) { this.appendMore(); }
if (loadedAllPosts) { this.appendMore(); }
}
},
@ -702,8 +712,8 @@ Discourse.PostStream.reopenClass({
},
loadTopicView: function(topicId, args) {
var opts = _.merge({}, args);
var url = Discourse.getURL("/t/") + topicId;
var opts = _.merge({}, args),
url = Discourse.getURL("/t/") + topicId;
if (opts.nearPost) {
url += "/" + opts.nearPost;
}

View File

@ -7,16 +7,16 @@
@module Discourse
**/
Discourse.TopicFromParamsRoute = Discourse.Route.extend({
abc: 'asdfasdf',
setupController: function(controller, params) {
params = params || {};
params.track_visit = true;
var topic = this.modelFor('topic');
var postStream = topic.get('postStream');
var topic = this.modelFor('topic'),
postStream = topic.get('postStream'),
queryParams = Discourse.URL.get('queryParams');
var queryParams = Discourse.URL.get('queryParams');
if (queryParams) {
// Set summary on the postStream if present
postStream.set('summary', Em.get(queryParams, 'filter') === 'summary');
@ -41,9 +41,12 @@ Discourse.TopicFromParamsRoute = Discourse.Route.extend({
topicController.setProperties({
currentPost: closest,
progressPosition: closest,
enteredAt: new Date().getTime()
enteredAt: new Date().getTime().toString(),
highlightOnInsert: closest
});
Discourse.TopicView.jumpToPost(closest);
if (topic.present('draft')) {
composerController.open({
draft: Discourse.Draft.getLocal(topic.get('draft_key'), topic.get('draft')),

View File

@ -7,12 +7,12 @@
@module Discourse
**/
Discourse.TopicRoute = Discourse.Route.extend({
abc: 'def',
redirect: function() { Discourse.redirectIfLoginRequired(this); },
actions: {
// Modals that can pop up within a topic
showPosterExpansion: function(post) {
this.controllerFor('posterExpansion').show(post);
},
@ -61,7 +61,17 @@ Discourse.TopicRoute = Discourse.Route.extend({
splitTopic: function() {
Discourse.Route.showModal(this, 'splitTopic', this.modelFor('topic'));
}
},
// Use replaceState to update the URL once it changes
postChangedRoute: Discourse.debounce(function(currentPost) {
var topic = this.modelFor('topic');
if (topic && currentPost) {
var postUrl = topic.get('url');
if (currentPost > 1) { postUrl += "/" + currentPost; }
Discourse.URL.replaceState(postUrl);
}
}, 1000)
},

View File

@ -2,7 +2,7 @@
{{#if postStream.loaded}}
{{#if postStream.firstPostLoaded}}
{{#if postStream.firstPostPresent}}
<div id='topic-title'>
<div class='container'>
<div class='inner'>
@ -64,7 +64,7 @@
{{/if}}
{{#unless postStream.loadingFilter}}
{{collection itemViewClass="Discourse.PostView" contentBinding="postStream.posts" topicViewBinding="view"}}
{{cloaked-collection cloakView="post" idProperty="post_number" defaultHeight="200" content=postStream.posts}}
{{/unless}}
{{#if postStream.loadingBelow}}
@ -76,7 +76,7 @@
{{#if postStream.loadingFilter}}
<div class='spinner small'>{{i18n loading}}</div>
{{else}}
{{#if postStream.lastPostLoaded}}
{{#if postStream.loadedAllPosts}}
{{view Discourse.TopicClosingView topicBinding="model"}}
{{view Discourse.TopicFooterButtonsView topicBinding="model"}}

View File

@ -0,0 +1,154 @@
/**
Display a list of cloaked items
@class CloakedContainerView
@extends Discourse.View
@namespace Discourse
@module Discourse
**/
var SLACK_RATIO = 0.75;
Discourse.CloakedCollectionView = Ember.CollectionView.extend(Discourse.Scrolling, {
topVisible: null,
bottomVisible: null,
init: function() {
var cloakView = this.get('cloakView'),
idProperty = this.get('idProperty') || 'id';
this.set('itemViewClass', Discourse.CloakedView.extend({
classNames: [cloakView + '-cloak'],
cloaks: Em.String.classify(cloakView) + 'View',
defaultHeight: this.get('defaultHeight') || 100,
init: function() {
this._super();
this.set('elementId', cloakView + '-cloak-' + this.get('content.' + idProperty));
}
}));
this._super();
Ember.run.next(this, 'scrolled');
},
/**
If the topmost visible view changed, we will notify the controller if it has an appropriate hook.
@method _topVisibleChanged
@observes topVisible
**/
_topVisibleChanged: function() {
var controller = this.get('controller');
if (controller.topVisibleChanged) { controller.topVisibleChanged(this.get('topVisible')); }
}.observes('topVisible'),
/**
If the bottommost visible view changed, we will notify the controller if it has an appropriate hook.
@method _bottomVisible
@observes bottomVisible
**/
_bottomVisible: function() {
var controller = this.get('controller');
if (controller.bottomVisibleChanged) { controller.bottomVisibleChanged(this.get('bottomVisible')); }
}.observes('bottomVisible'),
/**
Binary search for finding the topmost view on screen.
@method findTopView
@param {Array} childViews the childViews to search through
@param {Number} windowTop The top of the viewport to search against
@param {Number} min The minimum index to search through of the child views
@param {Number} max The max index to search through of the child views
@returns {Number} the index into childViews of the topmost view
**/
findTopView: function(childViews, viewportTop, min, max) {
if (max < min) { return min; }
var mid = Math.floor((min + max) / 2),
$view = childViews[mid].$(),
viewBottom = $view.offset().top + $view.height();
if (viewBottom > viewportTop) {
return this.findTopView(childViews, viewportTop, min, mid-1);
} else {
return this.findTopView(childViews, viewportTop, mid+1, max);
}
},
/**
Determine what views are onscreen and cloak/uncloak them as necessary.
@method scrolled
**/
scrolled: function() {
var childViews = this.get('childViews'),
toUncloak = [],
$w = $(window),
windowHeight = $w.height(),
windowTop = $w.scrollTop(),
slack = Math.round(windowHeight * SLACK_RATIO),
viewportTop = windowTop - slack,
windowBottom = windowTop + windowHeight,
viewportBottom = windowBottom + slack,
topView = this.findTopView(childViews, viewportTop, 0, childViews.length-1),
bodyHeight = $('body').height(),
bottomView = topView,
onscreen = [];
if (windowBottom > bodyHeight) { windowBottom = bodyHeight; }
if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; }
// Find the bottom view and what's onscreen
while (bottomView < childViews.length) {
var view = childViews[bottomView],
$view = view.$(),
viewTop = $view.offset().top,
viewBottom = viewTop + $view.height();
if (viewTop > viewportBottom) { break; }
toUncloak.push(view);
if (viewBottom > windowTop && viewTop <= windowBottom) {
onscreen.push(view.get('content'));
}
bottomView++;
}
if (bottomView >= childViews.length) { bottomView = childViews.length - 1; }
// If our controller has a `sawObjects` method, pass the on screen objects to it.
var controller = this.get('controller');
if (onscreen.length) {
this.setProperties({topVisible: onscreen[0], bottomVisible: onscreen[onscreen.length-1]});
if (controller && controller.sawObjects) {
Em.run.schedule('afterRender', function() {
controller.sawObjects(onscreen);
});
}
} else {
this.setProperties({topVisible: null, bottomVisible: null});
}
var toCloak = childViews.slice(0, topView).concat(childViews.slice(bottomView+1));
Em.run.schedule('afterRender', function() {
toUncloak.forEach(function (v) { v.uncloak(); });
toCloak.forEach(function (v) { v.cloak(); });
});
},
didInsertElement: function() {
this.bindScrolling({debounce: 10});
},
willDestroyElement: function() {
this.unbindScrolling();
}
});
Discourse.View.registerHelper('cloaked-collection', Discourse.CloakedCollectionView);

View File

@ -0,0 +1,75 @@
/**
A cloaked view is one that removes its content when scrolled off the screen
@class CloakedView
@extends Discourse.View
@namespace Discourse
@module Discourse
**/
Discourse.CloakedView = Discourse.View.extend({
attributeBindings: ['style'],
init: function() {
this._super();
this.set('style', 'height: ' + this.get('defaultHeight') + 'px');
},
/**
Triggers the set up for rendering a view that is cloaked.
@method uncloak
*/
uncloak: function() {
var containedView = this.get('containedView');
if (!containedView) {
this.setProperties({
style: null,
containedView: this.createChildView(Discourse[this.get('cloaks')], { content: this.get('content') })
});
this.rerender();
}
},
/**
Removes the view from the DOM and tears down all observers.
@method cloak
*/
cloak: function() {
var containedView = this.get('containedView'),
self = this;
if (containedView && this.get('state') === 'inDOM') {
var style = 'height: ' + this.$().height() + 'px;';
this.set('style', style);
this.$().prop('style', style);
// We need to remove the container after the height of the element has taken
// effect.
Ember.run.schedule('afterRender', function() {
self.set('containedView', null);
containedView.willDestroyElement();
containedView.remove();
});
}
},
/**
Render the cloaked view if applicable.
@method render
*/
render: function(buffer) {
var containedView = this.get('containedView');
if (containedView && containedView.get('state') !== 'inDOM') {
containedView.renderToBuffer(buffer);
containedView.transitionTo('inDOM');
Em.run.schedule('afterRender', function() {
containedView.didInsertElement();
});
}
}
});

View File

@ -10,8 +10,7 @@ Discourse.TextField = Ember.TextField.extend({
attributeBindings: ['autocorrect', 'autocapitalize', 'autofocus'],
placeholder: function() {
if( this.get('placeholderKey') ) {
if (this.get('placeholderKey')) {
return I18n.t(this.get('placeholderKey'));
} else {
return '';

View File

@ -172,18 +172,17 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, {
// Add the quote controls to a post
insertQuoteControls: function() {
var postView = this;
var self = this;
return this.$('aside.quote').each(function(i, e) {
var $aside = $(e);
postView.updateQuoteElements($aside, 'chevron-down');
self.updateQuoteElements($aside, 'chevron-down');
var $title = $('.title', $aside);
// Unless it's a full quote, allow click to expand
if (!($aside.data('full') || $title.data('has-quote-controls'))) {
$title.on('click', function(e) {
if ($(e.target).is('a')) return true;
postView.toggleQuote($aside);
self.toggleQuote($aside);
});
$title.data('has-quote-controls', true);
}
@ -191,17 +190,34 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, {
},
willDestroyElement: function() {
Discourse.ScreenTrack.current().stopTracking(this.$().prop('id'));
Discourse.ScreenTrack.current().stopTracking(this.get('elementId'));
},
didInsertElement: function() {
var $post = this.$(),
post = this.get('post');
post = this.get('post'),
postNumber = post.get('post_number'),
highlightNumber = this.get('controller.highlightOnInsert');
// If we're meant to highlight a post
if ((highlightNumber > 1) && (highlightNumber === postNumber)) {
this.set('controller.highlightOnInsert', null);
var $contents = $('.topic-body .contents', $post),
origColor = $contents.data('orig-color') || $contents.css('backgroundColor');
$contents.data("orig-color", origColor);
$contents
.addClass('highlighted')
.stop()
.animate({ backgroundColor: origColor }, 2500, 'swing', function(){
$contents.removeClass('highlighted');
});
}
this.showLinkCounts();
// Track this post
Discourse.ScreenTrack.current().track(this.$().prop('id'), this.get('post.post_number'));
Discourse.ScreenTrack.current().track(this.$().prop('id'), postNumber);
// Add syntax highlighting
Discourse.SyntaxHighlighting.apply($post);
@ -211,7 +227,5 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, {
// Find all the quotes
this.insertQuoteControls();
$post.addClass('ready');
}
});

View File

@ -1,3 +1,5 @@
/*global LockOn:true*/
/**
This view is for rendering an icon representing the status of a topic
@ -21,10 +23,10 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
postStream: Em.computed.alias('controller.postStream'),
updateBar: function() {
Em.run.scheduleOnce('afterRender', this, 'updateProgressBar');
Em.run.scheduleOnce('afterRender', this, '_updateProgressBar');
}.observes('controller.streamPercentage'),
updateProgressBar: function() {
_updateProgressBar: function() {
var $topicProgress = this._topicProgress;
// cache lookup
@ -45,66 +47,36 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
.width(progressWidth);
},
updateTitle: function() {
_updateTitle: function() {
var title = this.get('topic.title');
if (title) return Discourse.set('title', title);
}.observes('topic.loaded', 'topic.title'),
currentPostChanged: function() {
var current = this.get('controller.currentPost');
var topic = this.get('topic');
if (!(current && topic)) return;
if (current > (this.get('maxPost') || 0)) {
this.set('maxPost', current);
}
var postUrl = topic.get('url');
if (current > 1) { postUrl += "/" + current; }
// TODO: @Robin, this should all be integrated into the router,
// the view should not be performing routing work
//
// This workaround ensures the router is aware the route changed,
// without it, the up button was broken on long topics.
// To repro, go to a topic with 50 posts, go to first post,
// scroll to end, click up button ... nothing happens
var handler =_.first(
_.where(Discourse.URL.get("router.router.currentHandlerInfos"),
function(o) {
return o.name === "topic.fromParams";
})
);
if(handler){
handler.context = {nearPost: current};
}
Discourse.URL.replaceState(postUrl);
}.observes('controller.currentPost', 'highest_post_number'),
composeChanged: function() {
_composeChanged: function() {
var composerController = Discourse.get('router.composerController');
composerController.clearState();
composerController.set('topic', this.get('topic'));
}.observes('composer'),
enteredTopic: function() {
_enteredTopic: function() {
this._topicProgress = undefined;
if (this.present('controller.enteredAt')) {
var topicView = this;
Em.run.schedule('afterRender', function() {
topicView.updatePosition();
});
// Ember is supposed to only call observers when values change but something
// in our view set up is firing this observer with the same value. This check
// prevents scrolled from being called twice.
var enteredAt = this.get('controller.enteredAt');
if (enteredAt && (this.get('lastEnteredAt') !== enteredAt)) {
this.scrolled();
this.set('lastEnteredAt', enteredAt);
}
}.observes('controller.enteredAt'),
didInsertElement: function(e) {
this.bindScrolling({debounce: 0});
this.bindScrolling({name: 'topic-view'});
var topicView = this;
Em.run.schedule('afterRender', function () {
$(window).resize('resize.discourse-on-scroll', function() {
topicView.updatePosition();
});
$(window).resize('resize.discourse-on-scroll', function() {
topicView.scrolled();
});
// This get seems counter intuitive, but it's to trigger the observer on
@ -120,8 +92,7 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
// This view is being removed. Shut down operations
willDestroyElement: function() {
this.unbindScrolling();
this.unbindScrolling('topic-view');
$(window).unbind('resize.discourse-on-scroll');
// Unbind link tracking
@ -170,128 +141,16 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
}
}.observes("Discourse.hasFocus"),
getPost: function($post){
var post, postView;
postView = Ember.View.views[$post.prop('id')];
if (postView) {
return postView.get('post');
}
return null;
},
// Called for every post seen, returns the post number
postSeen: function($post) {
var post = this.getPost($post);
if (post) {
var postNumber = post.get('post_number');
if (postNumber > (this.get('controller.last_read_post_number') || 0)) {
this.set('controller.last_read_post_number', postNumber);
}
if (!post.get('read')) {
post.set('read', true);
}
return post.get('post_number');
}
},
resetExamineDockCache: function() {
this.set('docAt', false);
},
updateDock: function(postView) {
if (!postView) return;
var post = postView.get('post');
if (!post) return;
this.set('controller.progressPosition', this.get('postStream').indexOf(post) + 1);
},
throttledPositionUpdate: Discourse.debounce(function() {
Discourse.ScreenTrack.current().scrolled();
var model = this.get('controller.model');
if (model && this.get('nextPositionUpdate')) {
this.set('controller.currentPost', this.get('nextPositionUpdate'));
}
},500),
scrolled: function(){
this.updatePosition();
},
/**
Process the posts the current user has seen in the topic.
@private
@method processSeenPosts
**/
processSeenPosts: function() {
var rows = $('.topic-post.ready');
if (!rows || rows.length === 0) { return; }
// if we have no rows
var info = Discourse.Eyeline.analyze(rows);
if(!info) { return; }
// We disable scrolling of the topic while performing initial positioning
// This code needs to be refactored, the pipline for positioning posts is wack
// Be sure to test on safari as well when playing with this
if(!Discourse.TopicView.disableScroll) {
// are we scrolling upwards?
if(info.top === 0 || info.onScreen[0] === 0 || info.bottom === 0) {
var $body = $('body'),
$elem = $(rows[0]),
distToElement = $body.scrollTop() - $elem.position().top;
this.get('postStream').prependMore().then(function() {
Em.run.next(function () {
$('html, body').scrollTop($elem.position().top + distToElement);
});
});
}
}
// are we scrolling down?
var currentPost;
if(info.bottom === rows.length-1) {
currentPost = this.postSeen($(rows[info.bottom]));
this.get('postStream').appendMore();
}
// update dock
this.updateDock(Ember.View.views[rows[info.bottom].id]);
// mark everything on screen read
var topicView = this;
_.each(info.onScreen,function(item){
var seen = topicView.postSeen($(rows[item]));
currentPost = currentPost || seen;
});
var currentForPositionUpdate = currentPost;
if (!currentForPositionUpdate) {
var postView = this.getPost($(rows[info.bottom]));
if (postView) { currentForPositionUpdate = postView.get('post_number'); }
}
if (currentForPositionUpdate) {
this.set('nextPositionUpdate', currentPost || currentForPositionUpdate);
this.throttledPositionUpdate();
} else {
console.error("can't update position ");
}
},
/**
The user has scrolled the window, or it is finished rendering and ready for processing.
@method updatePosition
@method scrolled
**/
updatePosition: function() {
this.processSeenPosts();
scrolled: function(){
var offset = window.pageYOffset || $('html').scrollTop();
if (!this.get('docAt')) {
@ -324,11 +183,8 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
}.property(),
browseMoreMessage: function() {
var opts = {
latestLink: "<a href=\"/\">" + (I18n.t("topic.view_latest_topics")) + "</a>"
};
var category = this.get('controller.content.category');
var opts = { latestLink: "<a href=\"/\">" + (I18n.t("topic.view_latest_topics")) + "</a>" },
category = this.get('controller.content.category');
if(Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) {
category = null;
@ -340,10 +196,9 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
opts.catLink = "<a href=\"" + Discourse.getURL("/categories") + "\">" + (I18n.t("topic.browse_all_categories")) + "</a>";
}
var tracking = this.get('topicTrackingState');
var unreadTopics = tracking.countUnread();
var newTopics = tracking.countNew();
var tracking = this.get('topicTrackingState'),
unreadTopics = tracking.countUnread(),
newTopics = tracking.countNew();
if (newTopics + unreadTopics > 0) {
var hasBoth = unreadTopics > 0 && newTopics > 0;
@ -368,88 +223,23 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
Discourse.TopicView.reopenClass({
// Scroll to a given post, if in the DOM. Returns whether it was in the DOM or not.
jumpToPost: function(topicId, postNumber, avoidScrollIfPossible) {
this.disableScroll = true;
Em.run.scheduleOnce('afterRender', function() {
var rows = $('.topic-post.ready');
jumpToPost: function(postNumber) {
var holderId = '#post-cloak-' + postNumber;
// Make sure we're looking at the topic we want to scroll to
if (topicId !== parseInt($('#topic').data('topic-id'), 10)) { return false; }
var $post = $("#post_" + postNumber);
if ($post.length) {
var postTop = $post.offset().top;
var highlight = true;
var header = $('header');
var title = $('#topic-title');
var expectedOffset = title.height() - header.find('.contents').height();
if (expectedOffset < 0) {
expectedOffset = 0;
}
var offset = (header.outerHeight(true) + expectedOffset);
var windowScrollTop = $('html, body').scrollTop();
if (avoidScrollIfPossible && postTop > windowScrollTop + offset && postTop < windowScrollTop + $(window).height() + 100) {
// in view
} else {
// not in view ... bring into view
if (postNumber === 1) {
$(window).scrollTop(0);
highlight = false;
} else {
var desired = $post.offset().top - offset;
$(window).scrollTop(desired);
// TODO @Robin, I am seeing multiple events in chrome issued after
// jumpToPost if I refresh a page, sometimes I see 2, sometimes 3
//
// 1. Where are they coming from?
// 2. On refresh we should only issue a single scrollTop
// 3. If you are scrolled down in BoingBoing desired sometimes is wrong
// due to vanishing header, we should not be rendering it imho until after
// we render the posts
var first = true;
var t = new Date();
// console.log("DESIRED:" + desired);
var enforceDesired = function(){
if($(window).scrollTop() !== desired) {
console.log("GOT EVENT " + $(window).scrollTop());
console.log("Time " + (new Date() - t));
console.trace();
if(first) {
$(window).scrollTop(desired);
first = false;
}
// $(document).unbind("scroll", enforceDesired);
}
};
// uncomment this line to help debug this issue.
// $(document).scroll(enforceDesired);
}
}
if(highlight) {
var $contents = $('.topic-body .contents', $post);
var origColor = $contents.data('orig-color') || $contents.css('backgroundColor');
$contents.data("orig-color", origColor);
$contents
.addClass('highlighted')
.stop()
.animate({ backgroundColor: origColor }, 2500, 'swing', function(){
$contents.removeClass('highlighted');
});
}
setTimeout(function(){Discourse.TopicView.disableScroll = false;}, 500);
Em.run.schedule('afterRender', function() {
if (postNumber === 1) {
$(window).scrollTop(0);
return;
}
new LockOn(holderId, {offsetCalculator: function() {
var $header = $('header'),
$title = $('#topic-title'),
expectedOffset = $title.height() - $header.find('.contents').height();
return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset);
}}).lock();
});
}
});

View File

@ -31,6 +31,7 @@
//= require mousetrap.js
//= require rsvp.js
//= require show-html.js
//= require lock-on.js
//= require ./discourse/helpers/i18n_helpers
//= require ./discourse/mixins/ajax

View File

@ -17,7 +17,7 @@ h1 .topic-statuses .topic-status i {margin-right: 5px;}
.logo-small {margin-right: 8px;}
.topic-post {
.post-cloak {
padding: 0;
&:first-of-type {

View File

@ -5,6 +5,6 @@ test("Enter a Topic", function() {
visit("/t/internationalization-localization/280").then(function() {
ok(exists("#topic"), "The was rendered");
ok(exists("#topic .topic-post"), "The topic has posts");
ok(exists("#topic .post-cloak"), "The topic has cloaked posts");
});
});

View File

@ -29,17 +29,17 @@ test('appending posts', function() {
equal(postStream.get('lastPostId'), 4, "the last post id is 4");
ok(!postStream.get('hasPosts'), "there are no posts by default");
ok(!postStream.get('firstPostLoaded'), "the first post is not loaded");
ok(!postStream.get('lastPostLoaded'), "the last post is not loaded");
ok(!postStream.get('firstPostPresent'), "the first post is not loaded");
ok(!postStream.get('loadedAllPosts'), "the last post is not loaded");
equal(postStream.get('posts.length'), 0, "it has no posts initially");
postStream.appendPost(Discourse.Post.create({id: 2, post_number: 2}));
ok(!postStream.get('firstPostLoaded'), "the first post is still not loaded");
ok(!postStream.get('firstPostPresent'), "the first post is still not loaded");
equal(postStream.get('posts.length'), 1, "it has one post in the stream");
postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4}));
ok(!postStream.get('firstPostLoaded'), "the first post is still loaded");
ok(postStream.get('lastPostLoaded'), "the last post is now loaded");
ok(!postStream.get('firstPostPresent'), "the first post is still loaded");
ok(postStream.get('loadedAllPosts'), "the last post is now loaded");
equal(postStream.get('posts.length'), 2, "it has two posts in the stream");
postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4}));
@ -54,8 +54,8 @@ test('appending posts', function() {
// change the stream
postStream.set('stream', [1, 2, 4]);
ok(!postStream.get('firstPostLoaded'), "the first post no longer loaded since the stream changed.");
ok(postStream.get('lastPostLoaded'), "the last post is still the last post in the new stream");
ok(!postStream.get('firstPostPresent'), "the first post no longer loaded since the stream changed.");
ok(postStream.get('loadedAllPosts'), "the last post is still the last post in the new stream");
});
test('closestPostNumberFor', function() {
@ -383,18 +383,18 @@ test('triggerNewPostInStream', function() {
});
test("lastPostLoaded when the id changes", function() {
test("loadedAllPosts when the id changes", function() {
// This can happen in a race condition between staging a post and it coming through on the
// message bus. If the id of a post changes we should reconsider the lastPostLoaded property.
// message bus. If the id of a post changes we should reconsider the loadedAllPosts property.
var postStream = buildStream(10101, [1, 2]);
var postWithoutId = Discourse.Post.create({ raw: 'hello world this is my new post' });
postStream.appendPost(Discourse.Post.create({id: 1, post_number: 1}));
postStream.appendPost(postWithoutId);
ok(!postStream.get('lastPostLoaded'), 'the last post is not loaded');
ok(!postStream.get('loadedAllPosts'), 'the last post is not loaded');
postWithoutId.set('id', 2);
ok(postStream.get('lastPostLoaded'), 'the last post is loaded now that the post has an id');
ok(postStream.get('loadedAllPosts'), 'the last post is loaded now that the post has an id');
});
test("comitting and triggerNewPostInStream race condition", function() {

View File

@ -22075,7 +22075,7 @@ Ember.merge(inBuffer, {
// when a view is rendered in a buffer, rerendering it simply
// replaces the existing buffer with a new one
rerender: function(view) {
throw new Ember.Error("Something you did caused a view to re-render after it rendered but before it was inserted into the DOM.");
throw new Ember.Error("Something you did caused a view to re-render after it rendered but before it was inserted into the DOM." + view.get('content.id'));
},
// when a view is rendered in a buffer, appending a child

54
vendor/assets/javascripts/lock-on.js vendored Normal file
View File

@ -0,0 +1,54 @@
(function (exports) {
var scrollEvents = "scroll.lock-on touchmove.lock-on mousedown.lock-on wheel.lock-on DOMMouseScroll.lock-on mousewheel.lock-on keyup.lock-on";
var LockOn = function(selector, options) {
this.selector = selector;
this.options = options || {};
};
LockOn.prototype.elementTop = function() {
var offsetCalculator = this.options.offsetCalculator;
return $(this.selector).offset().top - (offsetCalculator ? offsetCalculator() : 0);
};
LockOn.prototype.lock = function() {
var self = this,
previousTop = this.elementTop(),
startedAt = new Date().getTime()
i = 0;
$(window).scrollTop(previousTop);
var interval = setInterval(function() {
i = i + 1;
var top = self.elementTop(),
scrollTop = $(window).scrollTop();
if ((top !== previousTop) || (scrollTop !== top)) {
$(window).scrollTop(top);
previousTop = top;
}
// We commit suicide after 1s just to clean up
var nowTime = new Date().getTime();
if (nowTime - startedAt > 1000) {
$('body,html').off(scrollEvents)
clearInterval(interval);
}
}, 50);
$('body,html').off(scrollEvents).on(scrollEvents, function(e){
if ( e.which > 0 || e.type === "mousedown" || e.type === "mousewheel" || e.type === "touchmove") {
$('body,html').off(scrollEvents);
clearInterval(interval);
}
})
};
exports.LockOn = LockOn;
})(window);