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) { replyAsNewTopic: function(post) {
var composerController = this.get('controllers.composer'); var composerController = this.get('controllers.composer'),
var promise = composerController.open({ promise = composerController.open({
action: Discourse.Composer.CREATE_TOPIC, action: Discourse.Composer.CREATE_TOPIC,
draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY
}); }),
var postUrl = "" + location.protocol + "//" + location.host + (post.get('url')); postUrl = "" + location.protocol + "//" + location.host + (post.get('url')),
var postLink = "[" + (this.get('title')) + "](" + postUrl + ")"; postLink = "[" + (this.get('title')) + "](" + postUrl + ")";
promise.then(function() { promise.then(function() {
Discourse.Post.loadQuote(post.get('id')).then(function(q) { Discourse.Post.loadQuote(post.get('id')).then(function(q) {
@ -459,6 +459,65 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
} }
return true; 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. 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 @class Eyeline
@namespace Discourse @namespace Discourse
@module Discourse @module Discourse
@ -16,107 +10,23 @@ Discourse.Eyeline = function Eyeline(selector) {
this.selector = 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 Call this whenever you want to consider what is being seen by the browser
@method update @method update
**/ **/
Discourse.Eyeline.prototype.update = function() { Discourse.Eyeline.prototype.update = function() {
var $elements, atBottom, bottomOffset, docViewBottom, docViewTop, documentHeight, foundElement, windowHeight, var docViewTop = $(window).scrollTop(),
_this = this; 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(); if (bottomOffset) {
windowHeight = $(window).height();
docViewBottom = docViewTop + windowHeight;
documentHeight = $(document).height();
$elements = $(this.selector);
atBottom = false;
if (bottomOffset = $elements.last().offset()) {
atBottom = (bottomOffset.top <= docViewBottom) && (bottomOffset.top >= docViewTop); atBottom = (bottomOffset.top <= docViewBottom) && (bottomOffset.top >= docViewTop);
} }
@ -124,14 +34,12 @@ Discourse.Eyeline.prototype.update = function() {
foundElement = false; foundElement = false;
return $elements.each(function(i, elem) { 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... // It's seen if...
// ...the element is vertically within the top and botom // ...the element is vertically within the top and botom
if ((elemTop <= docViewBottom) && (elemTop >= docViewTop)) markSeen = true; 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 you hit the bottom we mark all the elements as seen. Otherwise, just the first one
if (!atBottom) { if (!atBottom) {
_this.trigger('saw', { self.trigger('saw', { detail: $elem });
detail: $elem
});
if (i === 0) { if (i === 0) {
_this.trigger('sawTop', { detail: $elem }); self.trigger('sawTop', { detail: $elem });
} }
return false; return false;
} }
if (i === 0) { if (i === 0) {
_this.trigger('sawTop', { detail: $elem }); self.trigger('sawTop', { detail: $elem });
} }
if (i === ($elements.length - 1)) { 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 @method flushRest
**/ **/
Discourse.Eyeline.prototype.flushRest = function() { Discourse.Eyeline.prototype.flushRest = function() {
var eyeline = this; var self = this;
return $(this.selector).each(function(i, elem) { $(this.selector).each(function(i, elem) {
var $elem = $(elem); return self.trigger('saw', { detail: $(elem) });
return eyeline.trigger('saw', { detail: $elem });
}); });
}; };

View File

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

View File

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

View File

@ -7,6 +7,7 @@
@namespace Discourse @namespace Discourse
@module Discourse @module Discourse
**/ **/
Discourse.Scrolling = Em.Mixin.create({ Discourse.Scrolling = Em.Mixin.create({
/** /**
@ -18,20 +19,16 @@ Discourse.Scrolling = Em.Mixin.create({
bindScrolling: function(opts) { bindScrolling: function(opts) {
opts = opts || {debounce: 100}; opts = opts || {debounce: 100};
var scrollingMixin = this; var self = this,
var onScrollMethod; onScrollMethod = function(e) {
return Em.run.scheduleOnce('afterRender', self, 'scrolled');
};
if (opts.debounce) { if (opts.debounce) {
onScrollMethod = Discourse.debounce(function() { onScrollMethod = Discourse.debounce(onScrollMethod, opts.debounce);
return scrollingMixin.scrolled();
}, opts.debounce);
} else {
onScrollMethod = function() {
return scrollingMixin.scrolled();
};
} }
Discourse.ScrollingDOMMethods.bindOnScroll(onScrollMethod); Discourse.ScrollingDOMMethods.bindOnScroll(onScrollMethod, opts.name);
}, },
/** /**
@ -39,8 +36,8 @@ Discourse.Scrolling = Em.Mixin.create({
@method unbindScrolling @method unbindScrolling
*/ */
unbindScrolling: function() { unbindScrolling: function(name) {
Discourse.ScrollingDOMMethods.unbindOnScroll(); Discourse.ScrollingDOMMethods.unbindOnScroll(name);
} }
}); });
@ -56,14 +53,16 @@ Discourse.Scrolling = Em.Mixin.create({
**/ **/
Discourse.ScrollingDOMMethods = { Discourse.ScrollingDOMMethods = {
bindOnScroll: function(onScrollMethod) { bindOnScroll: function(onScrollMethod, name) {
$(document).bind('touchmove.discourse', onScrollMethod); name = name || 'default';
$(window).bind('scroll.discourse', onScrollMethod); $(document).bind('touchmove.discourse-' + name, onScrollMethod);
$(window).bind('scroll.discourse-' + name, onScrollMethod);
}, },
unbindOnScroll: function() { unbindOnScroll: function(name) {
$(window).unbind('scroll.discourse'); name = name || 'default';
$(document).unbind('touchmove.discourse'); $(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? Have we loaded the first post in the stream?
@property firstPostLoaded @property firstPostPresent
**/ **/
firstPostLoaded: function() { firstPostPresent: function() {
if (!this.get('hasLoadedData')) { return false; } if (!this.get('hasLoadedData')) { return false; }
return !!this.get('posts').findProperty('id', this.get('firstPostId')); return !!this.get('posts').findProperty('id', this.get('firstPostId'));
}.property('hasLoadedData', 'posts.[]', '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 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? Have we loaded the last post in the stream?
@property lastPostLoaded @property loadedAllPosts
**/ **/
lastPostLoaded: function() { loadedAllPosts: function() {
if (!this.get('hasLoadedData')) { return false; } if (!this.get('hasLoadedData')) { return false; }
return !!this.get('posts').findProperty('id', this.get('lastPostId')); return !!this.get('posts').findProperty('id', this.get('lastPostId'));
}.property('hasLoadedData', 'posts.@each.id', '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 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() { nextWindow: function() {
// If we can't find the last post loaded, bail // If we can't find the last post loaded, bail
var lastPost = _.last(this.get('posts')); var lastLoadedPost = this.get('lastLoadedPost');
if (!lastPost) { return []; } if (!lastLoadedPost) { return []; }
// Find the index of the last post loaded, if not found, bail // Find the index of the last post loaded, if not found, bail
var stream = this.get('stream'); var stream = this.get('stream');
var lastIndex = this.indexOf(lastPost); var lastIndex = this.indexOf(lastLoadedPost);
if (lastIndex === -1) { return []; } if (lastIndex === -1) { return []; }
if ((lastIndex + 1) >= this.get('filteredPostsCount')) { return []; } if ((lastIndex + 1) >= this.get('filteredPostsCount')) { return []; }
// find our window of posts // find our window of posts
return stream.slice(lastIndex+1, lastIndex+Discourse.SiteSettings.posts_per_page+1); 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() { toggleSummary: function() {
this.toggleProperty('summary'); 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. @returns {Ember.Deferred} a promise that is resolved when the posts have been inserted into the stream.
**/ **/
refresh: function(opts) { refresh: function(opts) {
opts = opts || {}; opts = opts || {};
opts.nearPost = parseInt(opts.nearPost, 10); opts.nearPost = parseInt(opts.nearPost, 10);
var topic = this.get('topic'); var topic = this.get('topic'),
var postStream = this; self = this;
// Do we already have the post in our list of posts? Jump there. // Do we already have the post in our list of posts? Jump there.
var postWeWant = this.get('posts').findProperty('post_number', opts.nearPost); var postWeWant = this.get('posts').findProperty('post_number', opts.nearPost);
if (postWeWant) { if (postWeWant) { return Ember.RSVP.resolve(); }
Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost);
return Ember.RSVP.reject();
}
// TODO: if we have all the posts in the filter, don't go to the server for them. // 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 // Request a topicView
return Discourse.PostStream.loadTopicView(topic.get('id'), opts).then(function (json) { return Discourse.PostStream.loadTopicView(topic.get('id'), opts).then(function (json) {
topic.updateFromJson(json); topic.updateFromJson(json);
postStream.updateFromJson(json.post_stream); self.updateFromJson(json.post_stream);
postStream.setProperties({ loadingFilter: false, loaded: true }); self.setProperties({ loadingFilter: false, loaded: true });
if (opts.nearPost) { Discourse.URL.set('queryParams', self.get('streamFilters'));
Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost); }).fail(function(result) {
} else { self.errorLoading(result);
Discourse.TopicView.jumpToPost(topic.get('id'), 1);
}
Discourse.URL.set('queryParams', postStream.get('streamFilters'));
}, function(result) {
postStream.errorLoading(result);
}); });
}, },
hasLoadedData: Em.computed.and('hasPosts', 'hasStream'), 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. @returns {Ember.Deferred} a promise that's resolved when the posts have been added.
**/ **/
appendMore: function() { appendMore: function() {
var postStream = this; var self = this;
// Make sure we can append more posts // 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(); } if (Ember.isEmpty(postIds)) { return Ember.RSVP.reject(); }
postStream.set('loadingBelow', true); self.set('loadingBelow', true);
var stopLoading = function() { 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) { posts.forEach(function(p) {
postStream.appendPost(p); self.appendPost(p);
}); });
stopLoading(); stopLoading();
}, stopLoading); }, stopLoading);
@ -349,7 +359,7 @@ Discourse.PostStream = Em.Object.extend({
}); });
// If we're at the end of the stream, add the post // If we're at the end of the stream, add the post
if (this.get('lastPostLoaded')) { if (this.get('loadedAllPosts')) {
this.appendPost(post); this.appendPost(post);
} }
@ -452,11 +462,11 @@ Discourse.PostStream = Em.Object.extend({
// We only trigger if there are no filters active // We only trigger if there are no filters active
if (!this.get('hasNoFilters')) { return; } if (!this.get('hasNoFilters')) { return; }
var lastPostLoaded = this.get('lastPostLoaded'); var loadedAllPosts = this.get('loadedAllPosts');
if (this.get('stream').indexOf(postId) === -1) { if (this.get('stream').indexOf(postId) === -1) {
this.get('stream').addObject(postId); this.get('stream').addObject(postId);
if (lastPostLoaded) { this.appendMore(); } if (loadedAllPosts) { this.appendMore(); }
} }
}, },
@ -702,8 +712,8 @@ Discourse.PostStream.reopenClass({
}, },
loadTopicView: function(topicId, args) { loadTopicView: function(topicId, args) {
var opts = _.merge({}, args); var opts = _.merge({}, args),
var url = Discourse.getURL("/t/") + topicId; url = Discourse.getURL("/t/") + topicId;
if (opts.nearPost) { if (opts.nearPost) {
url += "/" + opts.nearPost; url += "/" + opts.nearPost;
} }

View File

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

View File

@ -7,12 +7,12 @@
@module Discourse @module Discourse
**/ **/
Discourse.TopicRoute = Discourse.Route.extend({ Discourse.TopicRoute = Discourse.Route.extend({
abc: 'def',
redirect: function() { Discourse.redirectIfLoginRequired(this); }, redirect: function() { Discourse.redirectIfLoginRequired(this); },
actions: { actions: {
// Modals that can pop up within a topic // Modals that can pop up within a topic
showPosterExpansion: function(post) { showPosterExpansion: function(post) {
this.controllerFor('posterExpansion').show(post); this.controllerFor('posterExpansion').show(post);
}, },
@ -61,7 +61,17 @@ Discourse.TopicRoute = Discourse.Route.extend({
splitTopic: function() { splitTopic: function() {
Discourse.Route.showModal(this, 'splitTopic', this.modelFor('topic')); 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.loaded}}
{{#if postStream.firstPostLoaded}} {{#if postStream.firstPostPresent}}
<div id='topic-title'> <div id='topic-title'>
<div class='container'> <div class='container'>
<div class='inner'> <div class='inner'>
@ -64,7 +64,7 @@
{{/if}} {{/if}}
{{#unless postStream.loadingFilter}} {{#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}} {{/unless}}
{{#if postStream.loadingBelow}} {{#if postStream.loadingBelow}}
@ -76,7 +76,7 @@
{{#if postStream.loadingFilter}} {{#if postStream.loadingFilter}}
<div class='spinner small'>{{i18n loading}}</div> <div class='spinner small'>{{i18n loading}}</div>
{{else}} {{else}}
{{#if postStream.lastPostLoaded}} {{#if postStream.loadedAllPosts}}
{{view Discourse.TopicClosingView topicBinding="model"}} {{view Discourse.TopicClosingView topicBinding="model"}}
{{view Discourse.TopicFooterButtonsView 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'], attributeBindings: ['autocorrect', 'autocapitalize', 'autofocus'],
placeholder: function() { placeholder: function() {
if (this.get('placeholderKey')) {
if( this.get('placeholderKey') ) {
return I18n.t(this.get('placeholderKey')); return I18n.t(this.get('placeholderKey'));
} else { } else {
return ''; return '';

View File

@ -172,18 +172,17 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, {
// Add the quote controls to a post // Add the quote controls to a post
insertQuoteControls: function() { insertQuoteControls: function() {
var postView = this; var self = this;
return this.$('aside.quote').each(function(i, e) { return this.$('aside.quote').each(function(i, e) {
var $aside = $(e); var $aside = $(e);
postView.updateQuoteElements($aside, 'chevron-down'); self.updateQuoteElements($aside, 'chevron-down');
var $title = $('.title', $aside); var $title = $('.title', $aside);
// Unless it's a full quote, allow click to expand // Unless it's a full quote, allow click to expand
if (!($aside.data('full') || $title.data('has-quote-controls'))) { if (!($aside.data('full') || $title.data('has-quote-controls'))) {
$title.on('click', function(e) { $title.on('click', function(e) {
if ($(e.target).is('a')) return true; if ($(e.target).is('a')) return true;
postView.toggleQuote($aside); self.toggleQuote($aside);
}); });
$title.data('has-quote-controls', true); $title.data('has-quote-controls', true);
} }
@ -191,17 +190,34 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, {
}, },
willDestroyElement: function() { willDestroyElement: function() {
Discourse.ScreenTrack.current().stopTracking(this.$().prop('id')); Discourse.ScreenTrack.current().stopTracking(this.get('elementId'));
}, },
didInsertElement: function() { didInsertElement: function() {
var $post = this.$(), 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(); this.showLinkCounts();
// Track this post // 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 // Add syntax highlighting
Discourse.SyntaxHighlighting.apply($post); Discourse.SyntaxHighlighting.apply($post);
@ -211,7 +227,5 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, {
// Find all the quotes // Find all the quotes
this.insertQuoteControls(); 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 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'), postStream: Em.computed.alias('controller.postStream'),
updateBar: function() { updateBar: function() {
Em.run.scheduleOnce('afterRender', this, 'updateProgressBar'); Em.run.scheduleOnce('afterRender', this, '_updateProgressBar');
}.observes('controller.streamPercentage'), }.observes('controller.streamPercentage'),
updateProgressBar: function() { _updateProgressBar: function() {
var $topicProgress = this._topicProgress; var $topicProgress = this._topicProgress;
// cache lookup // cache lookup
@ -45,66 +47,36 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
.width(progressWidth); .width(progressWidth);
}, },
updateTitle: function() { _updateTitle: function() {
var title = this.get('topic.title'); var title = this.get('topic.title');
if (title) return Discourse.set('title', title); if (title) return Discourse.set('title', title);
}.observes('topic.loaded', 'topic.title'), }.observes('topic.loaded', 'topic.title'),
currentPostChanged: function() { _composeChanged: 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() {
var composerController = Discourse.get('router.composerController'); var composerController = Discourse.get('router.composerController');
composerController.clearState(); composerController.clearState();
composerController.set('topic', this.get('topic')); composerController.set('topic', this.get('topic'));
}.observes('composer'), }.observes('composer'),
enteredTopic: function() { _enteredTopic: function() {
this._topicProgress = undefined; this._topicProgress = undefined;
if (this.present('controller.enteredAt')) {
var topicView = this; // Ember is supposed to only call observers when values change but something
Em.run.schedule('afterRender', function() { // in our view set up is firing this observer with the same value. This check
topicView.updatePosition(); // 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'), }.observes('controller.enteredAt'),
didInsertElement: function(e) { didInsertElement: function(e) {
this.bindScrolling({debounce: 0}); this.bindScrolling({name: 'topic-view'});
var topicView = this; var topicView = this;
Em.run.schedule('afterRender', function () { $(window).resize('resize.discourse-on-scroll', function() {
$(window).resize('resize.discourse-on-scroll', function() { topicView.scrolled();
topicView.updatePosition();
});
}); });
// This get seems counter intuitive, but it's to trigger the observer on // 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 // This view is being removed. Shut down operations
willDestroyElement: function() { willDestroyElement: function() {
this.unbindScrolling('topic-view');
this.unbindScrolling();
$(window).unbind('resize.discourse-on-scroll'); $(window).unbind('resize.discourse-on-scroll');
// Unbind link tracking // Unbind link tracking
@ -170,128 +141,16 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
} }
}.observes("Discourse.hasFocus"), }.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() { resetExamineDockCache: function() {
this.set('docAt', false); 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. The user has scrolled the window, or it is finished rendering and ready for processing.
@method updatePosition @method scrolled
**/ **/
updatePosition: function() { scrolled: function(){
this.processSeenPosts();
var offset = window.pageYOffset || $('html').scrollTop(); var offset = window.pageYOffset || $('html').scrollTop();
if (!this.get('docAt')) { if (!this.get('docAt')) {
@ -324,11 +183,8 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
}.property(), }.property(),
browseMoreMessage: function() { browseMoreMessage: function() {
var opts = { var opts = { latestLink: "<a href=\"/\">" + (I18n.t("topic.view_latest_topics")) + "</a>" },
latestLink: "<a href=\"/\">" + (I18n.t("topic.view_latest_topics")) + "</a>" category = this.get('controller.content.category');
};
var category = this.get('controller.content.category');
if(Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) { if(Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) {
category = null; 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>"; opts.catLink = "<a href=\"" + Discourse.getURL("/categories") + "\">" + (I18n.t("topic.browse_all_categories")) + "</a>";
} }
var tracking = this.get('topicTrackingState'); var tracking = this.get('topicTrackingState'),
unreadTopics = tracking.countUnread(),
var unreadTopics = tracking.countUnread(); newTopics = tracking.countNew();
var newTopics = tracking.countNew();
if (newTopics + unreadTopics > 0) { if (newTopics + unreadTopics > 0) {
var hasBoth = unreadTopics > 0 && newTopics > 0; var hasBoth = unreadTopics > 0 && newTopics > 0;
@ -368,88 +223,23 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
Discourse.TopicView.reopenClass({ Discourse.TopicView.reopenClass({
// Scroll to a given post, if in the DOM. Returns whether it was in the DOM or not. jumpToPost: function(postNumber) {
jumpToPost: function(topicId, postNumber, avoidScrollIfPossible) { var holderId = '#post-cloak-' + postNumber;
this.disableScroll = true;
Em.run.scheduleOnce('afterRender', function() {
var rows = $('.topic-post.ready');
// Make sure we're looking at the topic we want to scroll to Em.run.schedule('afterRender', function() {
if (topicId !== parseInt($('#topic').data('topic-id'), 10)) { return false; } if (postNumber === 1) {
$(window).scrollTop(0);
var $post = $("#post_" + postNumber); return;
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);
} }
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 mousetrap.js
//= require rsvp.js //= require rsvp.js
//= require show-html.js //= require show-html.js
//= require lock-on.js
//= require ./discourse/helpers/i18n_helpers //= require ./discourse/helpers/i18n_helpers
//= require ./discourse/mixins/ajax //= require ./discourse/mixins/ajax

View File

@ -322,7 +322,7 @@ body {
color: inherit; color: inherit;
text-rendering: optimizelegibility; text-rendering: optimizelegibility;
} }
// this removes the unwanted top margin on a paragraph under a heading // this removes the unwanted top margin on a paragraph under a heading
h1+p, h2+p, h3+p, h4+p, h5+p, h6+p { h1+p, h2+p, h3+p, h4+p, h5+p, h6+p {

View File

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

View File

@ -5,6 +5,6 @@ test("Enter a Topic", function() {
visit("/t/internationalization-localization/280").then(function() { visit("/t/internationalization-localization/280").then(function() {
ok(exists("#topic"), "The was rendered"); 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"); equal(postStream.get('lastPostId'), 4, "the last post id is 4");
ok(!postStream.get('hasPosts'), "there are no posts by default"); ok(!postStream.get('hasPosts'), "there are no posts by default");
ok(!postStream.get('firstPostLoaded'), "the first post is not loaded"); ok(!postStream.get('firstPostPresent'), "the first post is not loaded");
ok(!postStream.get('lastPostLoaded'), "the last 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"); equal(postStream.get('posts.length'), 0, "it has no posts initially");
postStream.appendPost(Discourse.Post.create({id: 2, post_number: 2})); 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"); equal(postStream.get('posts.length'), 1, "it has one post in the stream");
postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4})); postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4}));
ok(!postStream.get('firstPostLoaded'), "the first post is still loaded"); ok(!postStream.get('firstPostPresent'), "the first post is still loaded");
ok(postStream.get('lastPostLoaded'), "the last post is now loaded"); ok(postStream.get('loadedAllPosts'), "the last post is now loaded");
equal(postStream.get('posts.length'), 2, "it has two posts in the stream"); equal(postStream.get('posts.length'), 2, "it has two posts in the stream");
postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4})); postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4}));
@ -54,8 +54,8 @@ test('appending posts', function() {
// change the stream // change the stream
postStream.set('stream', [1, 2, 4]); postStream.set('stream', [1, 2, 4]);
ok(!postStream.get('firstPostLoaded'), "the first post no longer loaded since the stream changed."); ok(!postStream.get('firstPostPresent'), "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('loadedAllPosts'), "the last post is still the last post in the new stream");
}); });
test('closestPostNumberFor', function() { 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 // 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 postStream = buildStream(10101, [1, 2]);
var postWithoutId = Discourse.Post.create({ raw: 'hello world this is my new post' }); 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(Discourse.Post.create({id: 1, post_number: 1}));
postStream.appendPost(postWithoutId); 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); 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() { test("comitting and triggerNewPostInStream race condition", function() {

View File

@ -2258,7 +2258,7 @@ function suspendListener(obj, eventName, target, method, callback) {
Suspends multiple listeners during a callback. Suspends multiple listeners during a callback.
@method suspendListeners @method suspendListeners
@for Ember @for Ember
@param obj @param obj
@ -2326,7 +2326,7 @@ function watchedEvents(obj) {
is skipped, and once listeners are removed. A listener without is skipped, and once listeners are removed. A listener without
a target is executed on the passed object. If an array of actions a target is executed on the passed object. If an array of actions
is not passed, the actions stored on the passed object are invoked. is not passed, the actions stored on the passed object are invoked.
@method sendEvent @method sendEvent
@for Ember @for Ember
@param obj @param obj
@ -3099,14 +3099,14 @@ Map.create = function() {
Map.prototype = { Map.prototype = {
/** /**
This property will change as the number of objects in the map changes. This property will change as the number of objects in the map changes.
@property length @property length
@type number @type number
@default 0 @default 0
*/ */
length: 0, length: 0,
/** /**
Retrieve the value associated with a given key. Retrieve the value associated with a given key.
@ -12589,7 +12589,7 @@ Ember.computed.sort = function (itemsKey, sortDefinition) {
(function() { (function() {
/** /**
Expose RSVP implementation Expose RSVP implementation
Documentation can be found here: https://github.com/tildeio/rsvp.js/blob/master/README.md Documentation can be found here: https://github.com/tildeio/rsvp.js/blob/master/README.md
@class RSVP @class RSVP
@ -14838,7 +14838,7 @@ function installPromise(proxy, promise) {
controller.get('lastName') //=> 'Penner' controller.get('lastName') //=> 'Penner'
``` ```
If the controller is backing a template, the attributes are If the controller is backing a template, the attributes are
bindable from within that template bindable from within that template
```handlebars ```handlebars
@ -16338,7 +16338,7 @@ function classToString() {
if (this[NAME_KEY]) { if (this[NAME_KEY]) {
ret = this[NAME_KEY]; ret = this[NAME_KEY];
} else if (this._toString) { } else if (this._toString) {
ret = this._toString; ret = this._toString;
} else { } else {
var str = superClassString(this); var str = superClassString(this);
if (str) { if (str) {
@ -17935,8 +17935,8 @@ var get = Ember.get, set = Ember.set, forEach = Ember.EnumerableUtils.forEach;
```javascript ```javascript
songsController.get('content').get('firstObject'); // Returns the unsorted original content songsController.get('content').get('firstObject'); // Returns the unsorted original content
songsController.get('firstObject'); // Returns the sorted content. songsController.get('firstObject'); // Returns the sorted content.
``` ```
Although the sorted content can also be accessed through the arrangedContent property, Although the sorted content can also be accessed through the arrangedContent property,
it is preferable to use the proxied class and not the arrangedContent array directly. it is preferable to use the proxied class and not the arrangedContent array directly.
@ -18025,7 +18025,7 @@ Ember.SortableMixin = Ember.Mixin.create(Ember.MutableEnumerable, {
/** /**
Overrides the default arrangedContent from arrayProxy in order to sort by sortFunction. Overrides the default arrangedContent from arrayProxy in order to sort by sortFunction.
Also sets up observers for each sortProperty on each item in the content Array. Also sets up observers for each sortProperty on each item in the content Array.
@property arrangedContent @property arrangedContent
*/ */
@ -22075,7 +22075,7 @@ Ember.merge(inBuffer, {
// when a view is rendered in a buffer, rerendering it simply // when a view is rendered in a buffer, rerendering it simply
// replaces the existing buffer with a new one // replaces the existing buffer with a new one
rerender: function(view) { 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 // when a view is rendered in a buffer, appending a child
@ -23581,7 +23581,7 @@ define("metamorph",
/** /**
* @public * @public
* *
* Remove this object (including starting and ending * Remove this object (including starting and ending
* placeholders). * placeholders).
* *
@ -27990,7 +27990,7 @@ helpers = this.merge(helpers, Ember.Handlebars.helpers); data = data || {};
var buffer = '', stack1, hashTypes, hashContexts, escapeExpression=this.escapeExpression, self=this; var buffer = '', stack1, hashTypes, hashContexts, escapeExpression=this.escapeExpression, self=this;
function program1(depth0,data) { function program1(depth0,data) {
var buffer = '', hashTypes, hashContexts; var buffer = '', hashTypes, hashContexts;
data.buffer.push("<option value=\"\">"); data.buffer.push("<option value=\"\">");
hashTypes = {}; hashTypes = {};
@ -28001,7 +28001,7 @@ function program1(depth0,data) {
} }
function program3(depth0,data) { function program3(depth0,data) {
var stack1, hashTypes, hashContexts; var stack1, hashTypes, hashContexts;
hashTypes = {}; hashTypes = {};
hashContexts = {}; hashContexts = {};
@ -28010,7 +28010,7 @@ function program3(depth0,data) {
else { data.buffer.push(''); } else { data.buffer.push(''); }
} }
function program4(depth0,data) { function program4(depth0,data) {
var hashContexts, hashTypes; var hashContexts, hashTypes;
hashContexts = {'content': depth0,'label': depth0}; hashContexts = {'content': depth0,'label': depth0};
hashTypes = {'content': "ID",'label': "ID"}; hashTypes = {'content': "ID",'label': "ID"};
@ -28021,7 +28021,7 @@ function program4(depth0,data) {
} }
function program6(depth0,data) { function program6(depth0,data) {
var stack1, hashTypes, hashContexts; var stack1, hashTypes, hashContexts;
hashTypes = {}; hashTypes = {};
hashContexts = {}; hashContexts = {};
@ -28030,7 +28030,7 @@ function program6(depth0,data) {
else { data.buffer.push(''); } else { data.buffer.push(''); }
} }
function program7(depth0,data) { function program7(depth0,data) {
var hashContexts, hashTypes; var hashContexts, hashTypes;
hashContexts = {'content': depth0}; hashContexts = {'content': depth0};
hashTypes = {'content': "ID"}; hashTypes = {'content': "ID"};
@ -28048,7 +28048,7 @@ function program7(depth0,data) {
stack1 = helpers['if'].call(depth0, "view.optionGroupPath", {hash:{},inverse:self.program(6, program6, data),fn:self.program(3, program3, data),contexts:[depth0],types:["ID"],hashContexts:hashContexts,hashTypes:hashTypes,data:data}); stack1 = helpers['if'].call(depth0, "view.optionGroupPath", {hash:{},inverse:self.program(6, program6, data),fn:self.program(3, program3, data),contexts:[depth0],types:["ID"],hashContexts:hashContexts,hashTypes:hashTypes,data:data});
if(stack1 || stack1 === 0) { data.buffer.push(stack1); } if(stack1 || stack1 === 0) { data.buffer.push(stack1); }
return buffer; return buffer;
}), }),
attributeBindings: ['multiple', 'disabled', 'tabindex', 'name'], attributeBindings: ['multiple', 'disabled', 'tabindex', 'name'],
@ -30515,7 +30515,7 @@ var get = Ember.get;
*/ */
/** /**
Finds a controller instance. Finds a controller instance.
@for Ember @for Ember
@ -30531,7 +30531,7 @@ Ember.controllerFor = function(container, controllerName, lookupOptions) {
The type of generated controller depends on the context. The type of generated controller depends on the context.
You can customize your generated controllers by defining You can customize your generated controllers by defining
`App.ObjectController` and `App.ArrayController` `App.ObjectController` and `App.ArrayController`
@for Ember @for Ember
@method generateController @method generateController
@private @private
@ -31237,7 +31237,7 @@ Ember.Route = Ember.Object.extend(Ember.ActionHandler, {
Transition into another route. Optionally supply model(s) for the Transition into another route. Optionally supply model(s) for the
route in question. If multiple models are supplied they will be applied route in question. If multiple models are supplied they will be applied
last to first recursively up the resource tree (see Multiple Models Example last to first recursively up the resource tree (see Multiple Models Example
below). The model(s) will be serialized into the URL using the appropriate below). The model(s) will be serialized into the URL using the appropriate
route's `serialize` hook. See also 'replaceWith'. route's `serialize` hook. See also 'replaceWith'.
Simple Transition Example Simple Transition Example
@ -31294,7 +31294,7 @@ Ember.Route = Ember.Object.extend(Ember.ActionHandler, {
/** /**
Transition into another route while replacing the current URL, if possible. Transition into another route while replacing the current URL, if possible.
This will replace the current history entry instead of adding a new one. This will replace the current history entry instead of adding a new one.
Beside that, it is identical to `transitionTo` in all other respects. See Beside that, it is identical to `transitionTo` in all other respects. See
'transitionTo' for additional information regarding multiple models. 'transitionTo' for additional information regarding multiple models.
@ -32650,44 +32650,44 @@ Ember.onLoad('Ember.Handlebars', function(Handlebars) {
To override this option for your entire application, see To override this option for your entire application, see
"Overriding Application-wide Defaults". "Overriding Application-wide Defaults".
### Disabling the `link-to` helper ### Disabling the `link-to` helper
By default `{{link-to}}` is enabled. By default `{{link-to}}` is enabled.
any passed value to `disabled` helper property will disable the `link-to` helper. any passed value to `disabled` helper property will disable the `link-to` helper.
static use: the `disabled` option: static use: the `disabled` option:
```handlebars ```handlebars
{{#link-to 'photoGallery' disabled=true}} {{#link-to 'photoGallery' disabled=true}}
Great Hamster Photos Great Hamster Photos
{{/link-to}} {{/link-to}}
``` ```
dynamic use: the `disabledWhen` option: dynamic use: the `disabledWhen` option:
```handlebars ```handlebars
{{#link-to 'photoGallery' disabledWhen=controller.someProperty}} {{#link-to 'photoGallery' disabledWhen=controller.someProperty}}
Great Hamster Photos Great Hamster Photos
{{/link-to}} {{/link-to}}
``` ```
any passed value to `disabled` will disable it except `undefined`. any passed value to `disabled` will disable it except `undefined`.
to ensure that only `true` disable the `link-to` helper you can to ensure that only `true` disable the `link-to` helper you can
override the global behaviour of `Ember.LinkView`. override the global behaviour of `Ember.LinkView`.
```javascript ```javascript
Ember.LinkView.reopen({ Ember.LinkView.reopen({
disabled: Ember.computed(function(key, value) { disabled: Ember.computed(function(key, value) {
if (value !== undefined) { if (value !== undefined) {
this.set('_isDisabled', value === true); this.set('_isDisabled', value === true);
} }
return value === true ? get(this, 'disabledClass') : false; return value === true ? get(this, 'disabledClass') : false;
}) })
}); });
``` ```
see "Overriding Application-wide Defaults" for more. see "Overriding Application-wide Defaults" for more.
### Handling `href` ### Handling `href`
`{{link-to}}` will use your application's Router to `{{link-to}}` will use your application's Router to
fill the element's `href` property with a url that fill the element's `href` property with a url that
@ -33007,13 +33007,13 @@ var get = Ember.get, set = Ember.set;
Ember.onLoad('Ember.Handlebars', function(Handlebars) { Ember.onLoad('Ember.Handlebars', function(Handlebars) {
/** /**
Calling ``{{render}}`` from within a template will insert another Calling ``{{render}}`` from within a template will insert another
template that matches the provided name. The inserted template will template that matches the provided name. The inserted template will
access its properties on its own controller (rather than the controller access its properties on its own controller (rather than the controller
of the parent template). of the parent template).
If a view class with the same name exists, the view class also will be used. If a view class with the same name exists, the view class also will be used.
Note: A given controller may only be used *once* in your app in this manner. Note: A given controller may only be used *once* in your app in this manner.
A singleton instance of the controller will be created for you. A singleton instance of the controller will be created for you.
@ -33035,7 +33035,7 @@ Ember.onLoad('Ember.Handlebars', function(Handlebars) {
<h1>My great app</h1> <h1>My great app</h1>
{{render navigaton}} {{render navigaton}}
``` ```
```html ```html
<h1>My great app</h1> <h1>My great app</h1>
<div class='ember-view'> <div class='ember-view'>
@ -33259,8 +33259,8 @@ Ember.onLoad('Ember.Handlebars', function(Handlebars) {
App.ApplicationController = Ember.Controller.extend({ App.ApplicationController = Ember.Controller.extend({
actions: { actions: {
anActionName: function() { anActionName: function() {
} }
} }
}); });
``` ```
@ -33580,7 +33580,7 @@ Ember.ControllerMixin.reopen({
this.resource('blogPost', {path:':blogPostId'}, function(){ this.resource('blogPost', {path:':blogPostId'}, function(){
this.resource('blogComment', {path: ':blogCommentId'}); this.resource('blogComment', {path: ':blogCommentId'});
}); });
aController.transitionToRoute('blogComment', aPost, aComment); aController.transitionToRoute('blogComment', aPost, aComment);
``` ```
@ -33611,7 +33611,7 @@ Ember.ControllerMixin.reopen({
/** /**
Transition into another route while replacing the current URL, if possible. Transition into another route while replacing the current URL, if possible.
This will replace the current history entry instead of adding a new one. This will replace the current history entry instead of adding a new one.
Beside that, it is identical to `transitionToRoute` in all other respects. Beside that, it is identical to `transitionToRoute` in all other respects.
```javascript ```javascript
@ -33635,7 +33635,7 @@ Ember.ControllerMixin.reopen({
this.resource('blogPost', {path:':blogPostId'}, function(){ this.resource('blogPost', {path:':blogPostId'}, function(){
this.resource('blogComment', {path: ':blogCommentId'}); this.resource('blogComment', {path: ':blogCommentId'});
}); });
aController.replaceRoute('blogComment', aPost, aComment); aController.replaceRoute('blogComment', aPost, aComment);
``` ```
@ -33825,7 +33825,7 @@ Ember.View.reopen({
// Add a new named queue after the 'actions' queue (where RSVP promises // Add a new named queue after the 'actions' queue (where RSVP promises
// resolve), which is used in router transitions to prevent unnecessary // resolve), which is used in router transitions to prevent unnecessary
// loading state entry if all context promises resolve on the // loading state entry if all context promises resolve on the
// 'actions' queue first. // 'actions' queue first.
var queues = Ember.run.queues, var queues = Ember.run.queues,
@ -35165,7 +35165,7 @@ var Application = Ember.Application = Ember.Namespace.extend(Ember.DeferredMixin
Call `advanceReadiness` after any asynchronous setup logic has completed. Call `advanceReadiness` after any asynchronous setup logic has completed.
Each call to `deferReadiness` must be matched by a call to `advanceReadiness` Each call to `deferReadiness` must be matched by a call to `advanceReadiness`
or the application will never become ready and routing will not begin. or the application will never become ready and routing will not begin.
@method advanceReadiness @method advanceReadiness
@see {Ember.Application#deferReadiness} @see {Ember.Application#deferReadiness}
*/ */
@ -36220,7 +36220,7 @@ var slice = [].slice,
* Register/Unregister additional test helpers. * Register/Unregister additional test helpers.
* Setup callbacks to be fired when the test helpers are injected into * Setup callbacks to be fired when the test helpers are injected into
your application. your application.
@class Test @class Test
@namespace Ember @namespace Ember
*/ */
@ -36967,7 +36967,7 @@ Ember.StateManager = generateRemovedClass("Ember.StateManager");
/** /**
This was exported to ember-states plugin for v 1.0.0 release. See: https://github.com/emberjs/ember-states This was exported to ember-states plugin for v 1.0.0 release. See: https://github.com/emberjs/ember-states
@class StateManager @class StateManager
@namespace Ember @namespace Ember
*/ */
@ -36976,7 +36976,7 @@ Ember.State = generateRemovedClass("Ember.State");
/** /**
This was exported to ember-states plugin for v 1.0.0 release. See: https://github.com/emberjs/ember-states This was exported to ember-states plugin for v 1.0.0 release. See: https://github.com/emberjs/ember-states
@class State @class State
@namespace Ember @namespace Ember
*/ */

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);