1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 20:04:24 +02:00

Upgrade to Ember 1.11-beta.1

HTMLBars goodness! Since there was some breakage and a lot of fiddling
around to get some things working, I took this opportunity to do a big
cleanup of the whole Ember app. I accidentally worked on some new
features too :3

Note that the app is still broken right now, pending on
https://github.com/emberjs/ember.js/issues/10401

Cleanup:
- Restructuring of components
- Consolidation of some stuff into mixins, cleanup of some APIs that
will be public
- Change all instances of .property() / .observes() / .on() to
Ember.computed() / Ember.observer() / Ember.on() respectively (I think
it is more readable)
- More comments
- Start conforming to a code style (2 spaces for indentation)

New features:
- Post hiding/restoring
- Mark individual discussions as read by clicking
- Clicking on a read discussion jumps to the end
- Mark all discussions as read
- Progressively mark the discussion as read as the page is scrolled
- Unordered list post formatting
- Post permalink popup

Demo once that Ember regression is fixed!
This commit is contained in:
Toby Zerner
2015-02-10 18:05:40 +10:30
parent cf88fda8c8
commit c28307903b
164 changed files with 4623 additions and 4587 deletions

View File

@@ -0,0 +1,107 @@
import Ember from 'ember';
import UseComposer from 'flarum/mixins/use-composer';
import FadeIn from 'flarum/mixins/fade-in';
import HasItemLists from 'flarum/mixins/has-item-lists';
import ComposerEdit from 'flarum/components/composer/composer-edit';
import PostHeaderUser from 'flarum/components/discussion/post-header/user';
import PostHeaderMeta from 'flarum/components/discussion/post-header/meta';
import PostHeaderEdited from 'flarum/components/discussion/post-header/edited';
import PostHeaderToggle from 'flarum/components/discussion/post-header/toggle';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for a `comment`-typed post. Displays a number of item lists
(controls, header, and footer) surrounding the post's HTML content. Allows
the post to be edited with the composer, hidden, or restored.
*/
export default Ember.Component.extend(FadeIn, HasItemLists, UseComposer, {
layoutName: 'components/discussion/post-comment',
tagName: 'article',
classNames: ['post', 'post-comment'],
classNameBindings: [
'post.isHidden:deleted',
'post.isEdited:edited',
'revealContent:reveal-content'
],
itemLists: ['controls', 'header', 'footer'],
// The stream-content component instansiates this component and sets the
// `content` property to the content of the item in the post-stream object.
// This happens to be our post model!
post: Ember.computed.alias('content'),
populateControls: function(items) {
if (this.get('post.isHidden')) {
this.addActionItem(items, 'restore', 'Restore', 'reply', 'post.canEdit');
this.addActionItem(items, 'delete', 'Delete', 'times', 'post.canDelete');
} else {
this.addActionItem(items, 'edit', 'Edit', 'pencil', 'post.canEdit');
this.addActionItem(items, 'hide', 'Delete', 'times', 'post.canEdit');
}
},
// Since we statically populated controls based on the value of
// `post.isHidden`, we'll need to refresh them every time that property
// changes.
refreshControls: Ember.observer('post.isHidden', function() {
this.initItemList('controls');
}),
populateHeader: function(items) {
var properties = this.getProperties('post');
items.pushObjectWithTag(PostHeaderUser.create(properties), 'user');
items.pushObjectWithTag(PostHeaderMeta.create(properties), 'meta');
items.pushObjectWithTag(PostHeaderEdited.create(properties), 'edited');
items.pushObjectWithTag(PostHeaderToggle.create(properties, {parent: this}), 'toggle');
},
savePost: function(post, data) {
post.setProperties(data);
return this.saveAndDismissComposer(post);
},
actions: {
// In the template, we render the "controls" dropdown with the contents of
// the `renderControls` property. This way, when a post is initially
// rendered, it doesn't have to go to the trouble of rendering the
// controls right away, which speeds things up. When the dropdown button
// is clicked, this will fill in the actual controls.
renderControls: function() {
this.set('renderControls', this.get('controls'));
},
edit: function() {
var post = this.get('post');
var component = this;
this.showComposer(function() {
return ComposerEdit.create({
user: post.get('user'),
post: post,
submit: function(data) { component.savePost(post, data); }
});
});
},
hide: function() {
var post = this.get('post');
post.setProperties({
isHidden: true,
deleteTime: new Date,
deleteUser: this.get('session.user')
});
post.save();
},
restore: function() {
var post = this.get('post');
post.setProperties({
isHidden: false,
deleteTime: null,
deleteUser: null
});
post.save();
}
}
});

View File

@@ -0,0 +1,38 @@
import Ember from 'ember';
import humanTime from 'flarum/utils/human-time';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for the edited pencil icon in a post header. Shows a tooltip on
hover which details who edited the post and when.
*/
export default Ember.Component.extend({
tagName: 'span',
classNames: ['post-edited'],
attributeBindings: ['title'],
layout: precompileTemplate('{{fa-icon "pencil"}}'),
title: Ember.computed('post.editTime', 'post.editUser', function() {
return 'Edited by '+this.get('post.editUser.username')+' '+humanTime(this.get('post.editTime'));
}),
// In the context of an item list, this item will be hidden if the post
// hasn't been edited, or if it's been hidden.
hideItem: Ember.computed('post.isEdited', 'post.isHidden', function() {
return !this.get('post.isEdited') || this.get('post.isHidden');
}),
didInsertElement: function() {
this.$().tooltip();
},
// Whenever the title changes, we need to tell the tooltip to update to
// reflect the new value.
updateTooltip: Ember.observer('title', function() {
Ember.run.scheduleOnce('afterRender', this, function() {
this.$().tooltip('fixTitle');
});
})
});

View File

@@ -0,0 +1,36 @@
import Ember from 'ember';
var $ = Ember.$;
/**
Component for the meta part of a post header. Displays the time, and when
clicked, shows a dropdown containing more information about the post
(number, full time, permalink).
*/
export default Ember.Component.extend({
tagName: 'li',
classNames: ['dropdown'],
layoutName: 'components/discussion/post-header/time',
// Construct a permalink by looking up the router in the container, and
// using it to generate a link to this post within its discussion.
permalink: Ember.computed('post.discusion', 'post.number', function() {
var router = this.get('controller').container.lookup('router:main');
var path = router.generate('discussion', this.get('post.discussion'), {queryParams: {start: this.get('post.number')}});
return window.location.origin+path;
}),
didInsertElement: function() {
// When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL.
var component = this;
this.$('a').click(function() {
setTimeout(function() { component.$('.permalink').select(); }, 1);
});
// Prevent clicking on the dropdown menu from closing it.
this.$('.dropdown-menu').click(function(e) {
e.stopPropagation();
});
}
});

View File

@@ -0,0 +1,21 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for the toggle button in a post header. Toggles the
`parent.revealContent` property when clicked. Only displays if the supplied
post is not hidden.
*/
export default Ember.Component.extend({
tagName: 'li',
layout: precompileTemplate('<a href="#" class="btn btn-default btn-more" {{action "toggle"}}>{{fa-icon "ellipsis-h"}}</a>'),
hideItem: Ember.computed.not('post.isHidden'),
actions: {
toggle: function() {
this.toggleProperty('parent.revealContent');
}
}
});

View File

@@ -0,0 +1,12 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for the username/avatar in a post header.
*/
export default Ember.Component.extend({
tagName: 'h3',
classNames: ['post-user'],
layout: precompileTemplate('{{#link-to "user" post.user}}{{user-avatar post.user}} {{post.user.username}}{{/link-to}}')
});

View File

@@ -0,0 +1,293 @@
import Ember from 'ember';
var $ = Ember.$;
/**
Component which renders items in a `post-stream` object. It handles scroll
events so that when the user scrolls to the top/bottom of the page, more
posts will load. In doing this is also sends an action so that the parent
controller's state can be updated. Finally, it can be sent actions to jump
to a certain position in the stream and load posts there.
*/
export default Ember.Component.extend({
classNames: ['stream'],
// The stream object.
stream: null,
// Pause window scroll event listeners. This is set to true while loading
// posts, because we don't want a scroll event to trigger another block of
// posts to be loaded.
paused: false,
// Whether or not the stream's initial content has loaded.
loaded: Ember.computed.bool('stream.loadedCount'),
// When the stream content is not "active", window scroll event listeners
// will be ignored. For the stream content to be active, its initial
// content must be loaded and it must not be "paused".
active: Ember.computed('loaded', 'paused', function() {
return this.get('loaded') && !this.get('paused');
}),
// Whenever the stream object changes (i.e. we have transitioned to a
// different discussion), pause events and cancel any pending state updates.
refresh: Ember.observer('stream', function() {
this.set('paused', true);
clearTimeout(this.updateStateTimeout);
}),
didInsertElement: function() {
$(window).on('scroll', {view: this}, this.windowWasScrolled);
},
willDestroyElement: function() {
$(window).off('scroll', this.windowWasScrolled);
},
windowWasScrolled: function(event) {
event.data.view.update();
},
// Run any checks/updates according to the window's current scroll
// position. We check to see if any terminal 'gaps' are in the viewport
// and trigger their loading mechanism if they are. We also update the
// controller's 'start' query param with the current position. Note: this
// observes the 'active' property, so if the stream is 'unpaused', then an
// update will be triggered.
update: Ember.observer('active', function() {
if (!this.get('active')) { return; }
var $items = this.$().find('.item'),
$window = $(window),
marginTop = this.getMarginTop(),
scrollTop = $window.scrollTop() + marginTop,
viewportHeight = $window.height() - marginTop,
loadAheadDistance = 300,
startNumber,
endNumber;
// Loop through each of the items in the stream. An 'item' is either a
// single post or a 'gap' of one or more posts that haven't been
// loaded yet.
$items.each(function() {
var $this = $(this);
var top = $this.offset().top;
var height = $this.outerHeight(true);
// If this item is above the top of the viewport (plus a bit of
// leeway for loading-ahead gaps), skip to the next one. If it's
// below the bottom of the viewport, break out of the loop.
if (top + height < scrollTop - loadAheadDistance) { return; }
if (top > scrollTop + viewportHeight + loadAheadDistance) { return false; }
// If this item is a gap, then we may proceed to check if it's a
// *terminal* gap and trigger its loading mechanism.
if ($this.hasClass('gap')) {
var gapView = Ember.View.views[$this.attr('id')];
if ($this.is(':first-child')) {
gapView.set('direction', 'up').load();
} else if ($this.is(':last-child')) {
gapView.set('direction', 'down').load();
}
} else {
if (top + height < scrollTop + viewportHeight) {
endNumber = $this.data('number');
}
// Check if this item is in the viewport, minus the distance we
// allow for load-ahead gaps. If we haven't yet stored a post's
// number, then this item must be the FIRST item in the viewport.
// Therefore, we'll grab its post number so we can update the
// controller's state later.
if (top + height > scrollTop && ! startNumber) {
startNumber = $this.data('number');
}
}
});
// Finally, we want to update the controller's state with regards to the
// current viewing position of the discussion. However, we don't want to
// do this on every single scroll event as it will slow things down. So,
// let's do it at a minimum of 250ms by clearing and setting a timeout.
var view = this;
clearTimeout(this.updateStateTimeout);
this.updateStateTimeout = setTimeout(function() {
view.sendAction('positionChanged', startNumber || 1, endNumber);
}, 500);
}),
loadingNumber: function(number, noAnimation) {
// The post with this number is being loaded. We want to scroll to where
// we think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.set('paused', true);
this.jumpToNumber(number, noAnimation);
},
loadedNumber: function(number, noAnimation) {
// The post with this number has been loaded. After we scroll to this
// post, we want to resume scroll events.
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.jumpToNumber(number, noAnimation).done(function() {
view.set('paused', false);
});
});
},
loadingIndex: function(index, noAnimation) {
// The post at this index is being loaded. We want to scroll to where we
// think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.set('paused', true);
this.jumpToIndex(index, noAnimation);
},
loadedIndex: function(index, noAnimation) {
// The post at this index has been loaded. After we scroll to this post,
// we want to resume scroll events.
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.jumpToIndex(index, noAnimation).done(function() {
view.set('paused', false);
});
});
},
// Scroll down to a certain post by number (or the gap which we think the
// post is in) and highlight it.
jumpToNumber: function(number, noAnimation) {
// Clear the highlight class from all posts, and attempt to find and
// highlight a post with the specified number. However, we don't apply
// the highlight to the first post in the stream because it's pretty
// obvious that it's the top one.
var $item = this.$('.item').removeClass('highlight').filter('[data-number='+number+']');
if (!$item.is(':first-child')) {
$item.addClass('highlight');
}
// If we didn't have any luck, then a post with this number either
// doesn't exist, or it hasn't been loaded yet. We'll find the item
// that's closest to the post with this number and scroll to that
// instead.
if (!$item.length) {
$item = this.findNearestToNumber(number);
}
return this.scrollToItem($item, noAnimation);
},
// Scroll down to a certain post by index (or the gap the post is in.)
jumpToIndex: function(index, noAnimation) {
var $item = this.findNearestToIndex(index);
return this.scrollToItem($item, noAnimation);
},
scrollToItem: function($item, noAnimation) {
var $container = $('html, body').stop(true);
if ($item.length) {
var marginTop = this.getMarginTop();
var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - marginTop;
if (noAnimation) {
$container.scrollTop(scrollTop);
} else if (scrollTop !== $(document).scrollTop()) {
$container.animate({scrollTop: scrollTop});
}
}
return $container.promise();
},
// Find the DOM element of the item that is nearest to a post with a certain
// number. This will either be another post (if the requested post doesn't
// exist,) or a gap presumed to contain the requested post.
findNearestToNumber: function(number) {
var $nearestItem = $();
this.$('.item').each(function() {
var $this = $(this);
if ($this.data('number') > number) {
return false;
}
$nearestItem = $this;
});
return $nearestItem;
},
findNearestToIndex: function(index) {
var $nearestItem = this.$('.item[data-start='+index+'][data-end='+index+']');
if (! $nearestItem.length) {
this.$('.item').each(function() {
$nearestItem = $(this);
if ($nearestItem.data('end') >= index) {
return false;
}
});
}
return $nearestItem;
},
// Get the distance from the top of the viewport to the point at which we
// would consider a post to be the first one visible.
getMarginTop: function() {
return $('#header').outerHeight() + parseInt(this.$().css('margin-top'));
},
actions: {
goToNumber: function(number, noAnimation) {
number = Math.max(number, 1);
// Let's start by telling our listeners that we're going to load
// posts near this number. Elsewhere we will listen and
// consequently scroll down to the appropriate position.
this.trigger('loadingNumber', number, noAnimation);
// Now we have to actually make sure the posts around this new start
// position are loaded. We will tell our listeners when they are.
// Again, a listener will scroll down to the appropriate post.
var controller = this;
this.get('stream').loadNearNumber(number).then(function() {
controller.trigger('loadedNumber', number, noAnimation);
});
},
goToIndex: function(index, backwards, noAnimation) {
// Let's start by telling our listeners that we're going to load
// posts at this index. Elsewhere we will listen and consequently
// scroll down to the appropriate position.
this.trigger('loadingIndex', index, noAnimation);
// Now we have to actually make sure the posts around this index
// are loaded. We will tell our listeners when they are. Again, a
// listener will scroll down to the appropriate post.
var controller = this;
this.get('stream').loadNearIndex(index, backwards).then(function() {
controller.trigger('loadedIndex', index, noAnimation);
});
},
goToFirst: function() {
this.send('goToIndex', 0);
},
goToLast: function() {
this.send('goToIndex', this.get('stream.count') - 1, true);
// If the post stream is loading some new posts, then after it's
// done we'll want to immediately scroll down to the bottom of the
// page.
if (! this.get('stream.lastLoaded')) {
this.get('stream').one('postsLoaded', function() {
Ember.run.scheduleOnce('afterRender', function() {
$('html, body').stop(true).scrollTop($('body').height());
});
});
}
},
loadRange: function(start, end, backwards) {
this.get('stream').loadRange(start, end, backwards);
}
}
});

View File

@@ -0,0 +1,125 @@
import Ember from 'ember';
var $ = Ember.$;
/**
A stream 'item' represents one item in the post stream - this may be a
single post, or it may represent a gap of many posts which have not been
loaded.
*/
export default Ember.Component.extend({
classNames: ['item'],
classNameBindings: ['gap', 'loading', 'direction'],
attributeBindings: [
'start:data-start',
'end:data-end',
'time:data-time',
'number:data-number'
],
start: Ember.computed.alias('item.indexStart'),
end: Ember.computed.alias('item.indexEnd'),
number: Ember.computed.alias('item.content.number'),
loading: Ember.computed.alias('item.loading'),
direction: Ember.computed.alias('item.direction'),
gap: Ember.computed.not('item.content'),
time: Ember.computed('item.content.time', function() {
var time = this.get('item.content.time');
return time ? time.toString() : null;
}),
count: Ember.computed('start', 'end', function() {
return this.get('end') - this.get('start') + 1;
}),
loadingChanged: Ember.observer('loading', function() {
this.rerender();
}),
render: function(buffer) {
if (this.get('item.content')) {
return this._super(buffer);
}
buffer.push('<span>');
if (this.get('loading')) {
buffer.push('&nbsp;');
} else {
buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : ''));
}
buffer.push('</span>');
},
didInsertElement: function() {
if (!this.get('gap')) {
return;
}
if (this.get('loading')) {
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.$().spin('small');
});
} else {
var self = this;
this.$().hover(function(e) {
if (! self.get('loading')) {
var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
self.set('direction', up ? 'up' : 'down');
}
});
}
},
load: function(relativeIndex) {
// If this item is not a gap, or if we're already loading its posts,
// then we don't need to do anything.
if (! this.get('gap') || this.get('loading')) {
return false;
}
// If new posts are being loaded in an upwards direction, then when
// they are rendered, the rest of the posts will be pushed down the
// page. If loaded in a downwards direction from the end of a
// discussion, the terminal gap will disappear and the page will
// scroll up a bit before the new posts are rendered. In order to
// maintain the current scroll position relative to the content
// before/after the gap, we need to find item directly after the gap
// and use it as an anchor.
var siblingFunc = this.get('direction') === 'up' ? 'nextAll' : 'prevAll';
var anchor = this.$()[siblingFunc]('.item:first');
// Immediately after the posts have been loaded (but before they
// have been rendered,) we want to grab the distance from the top of
// the viewport to the top of the anchor element.
this.get('stream').one('postsLoaded', function() {
if (anchor.length) {
var scrollOffset = anchor.offset().top - $(document).scrollTop();
}
// After they have been rendered, we scroll back to a position
// so that the distance from the top of the viewport to the top
// of the anchor element is the same as before. If there is no
// anchor (i.e. this gap is terminal,) then we'll scroll to the
// bottom of the document.
Ember.run.scheduleOnce('afterRender', function() {
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
});
});
// Tell the controller that we want to load the range of posts that this
// gap represents. We also specify which direction we want to load the
// posts from.
this.sendAction(
'loadRange',
this.get('start') + (relativeIndex || 0),
this.get('end'),
this.get('direction') === 'up'
);
},
click: function() {
this.load();
}
});

View File

@@ -0,0 +1,392 @@
import Ember from 'ember';
var $ = Ember.$;
/**
Component which allows the user to scrub along the scrubber-content
component with a scrollbar.
*/
export default Ember.Component.extend({
layoutName: 'components/discussion/stream-scrubber',
classNames: ['scrubber', 'stream-scrubber'],
classNameBindings: ['disabled'],
// The stream-content component to which this scrubber is linked.
streamContent: null,
// The current index of the stream visible at the top of the viewport, and
// the number of items visible within the viewport. These aren't
// necessarily integers.
index: -1,
visible: 1,
// The description displayed alongside the index in the scrubber. This is
// set to the date of the first visible post in the scroll event.
description: '',
stream: Ember.computed.alias('streamContent.stream'),
loaded: Ember.computed.alias('streamContent.loaded'),
count: Ember.computed.alias('stream.count'),
// The integer index of the last item that is visible in the viewport. This
// is display on the scrubber (i.e. X of 100 posts).
visibleIndex: Ember.computed('index', 'visible', function() {
return Math.min(this.get('count'), Math.ceil(Math.max(0, this.get('index')) + this.get('visible')));
}),
// Disable the scrubber if the stream's initial content isn't loaded, or
// if all of the posts in the discussion are visible in the viewport.
disabled: Ember.computed('loaded', 'visible', 'count', function() {
return !this.get('loaded') || this.get('visible') >= this.get('count');
}),
// Whenever the stream object changes to a new one (i.e. when
// transitioning to a different discussion,) reset some properties and
// update the scrollbar to a neutral state.
refresh: Ember.observer('stream', function() {
this.set('index', -1);
this.set('visible', 1);
this.updateScrollbar();
}),
didInsertElement: function() {
var view = this;
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
this.get('streamContent').on('loadingIndex', this, this.loadingIndex);
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
$(window).on('resize', {view: this}, this.windowWasResized).resize();
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
$(window).on('scroll', {view: this}, this.windowWasScrolled);
// When any part of the whole scrollbar is clicked, we want to jump to
// that position.
this.$('.scrubber-scrollbar')
.click(function(e) {
if (!view.get('streamContent.active')) { return; }
// Calculate the index which we want to jump to based on the
// click position.
// 1. Get the offset of the click from the top of the
// scrollbar, as a percentage of the scrollbar's height.
var $this = $(this);
var offsetPixels = e.clientY - $this.offset().top + $('body').scrollTop();
var offsetPercent = offsetPixels / $this.outerHeight() * 100;
// 2. We want the handle of the scrollbar to end up centered
// on the click position. Thus, we calculate the height of
// the handle in percent and use that to find a new
// offset percentage.
offsetPercent = offsetPercent - parseFloat($this.find('.scrubber-slider')[0].style.height) / 2;
// 3. Now we can convert the percentage into an index, and
// tell the stream-content component to jump to that index.
var offsetIndex = offsetPercent / view.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(view.get('count') - 1, offsetIndex));
view.get('streamContent').send('goToIndex', Math.floor(offsetIndex));
});
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
this.$('.scrubber-scrollbar')
.css({
cursor: 'pointer',
'user-select': 'none'
})
.bind('dragstart mousedown', function(e) {
e.preventDefault();
});
// When the mouse is pressed on the scrollbar handle, we capture some
// information about its current position. We will store this
// information in an object and pass it on to the document's
// mousemove/mouseup events later.
var dragData = {
view: this,
mouseStart: 0,
indexStart: 0,
handle: null
};
this.$('.scrubber-slider')
.css('cursor', 'move')
.mousedown(function(e) {
dragData.mouseStart = e.clientY;
dragData.indexStart = view.get('index');
dragData.handle = $(this);
view.set('streamContent.paused', true);
$('body').css('cursor', 'move');
})
// Exempt the scrollbar handle from the 'jump to' click event.
.click(function(e) {
e.stopPropagation();
});
// When the mouse moves and when it is released, we pass the
// information that we captured when the mouse was first pressed onto
// some event handlers. These handlers will move the scrollbar/stream-
// content as appropriate.
$(document)
.on('mousemove', dragData, this.mouseWasMoved)
.on('mouseup', dragData, this.mouseWasReleased);
// Finally, we'll just make sure the scrollbar is in the correct
// position according to the values of this.index/visible.
this.updateScrollbar(true);
},
willDestroyElement: function() {
this.get('streamContent').off('loadingIndex', this, this.loadingIndex);
$(window)
.off('resize', this.windowWasResized)
.off('scroll', this.windowWasScrolled);
$(document)
.off('mousemove', this.mouseWasMoved)
.off('mouseup', this.mouseWasReleased);
},
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
loadingIndex: function(index) {
this.set('index', index);
this.updateScrollbar(true);
},
windowWasResized: function(event) {
var view = event.data.view;
view.windowWasScrolled(event);
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.
var scrollbar = view.$('.scrubber-scrollbar');
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - $('#footer').outerHeight(true));
},
windowWasScrolled: function(event) {
var view = event.data.view;
if (view.get('streamContent.active')) {
view.update();
view.updateScrollbar();
}
},
mouseWasMoved: function(event) {
if (! event.data.handle) { return; }
var view = event.data.view;
// Work out how much the mouse has moved by - first in pixels, then
// convert it to a percentage of the scrollbar's height, and then
// finally convert it into an index. Add this delta index onto
// the index at which the drag was started, and then scroll there.
var deltaPixels = event.clientY - event.data.mouseStart;
var deltaPercent = deltaPixels / view.$('.scrubber-scrollbar').outerHeight() * 100;
var deltaIndex = deltaPercent / view.percentPerPost().index;
var newIndex = Math.min(event.data.indexStart + deltaIndex, view.get('count') - 1);
view.set('index', Math.max(0, newIndex));
view.updateScrollbar();
view.scrollToIndex(newIndex);
},
mouseWasReleased: function(event) {
if (!event.data.handle) { return; }
event.data.mouseStart = 0;
event.data.indexStart = 0;
event.data.handle = null;
$('body').css('cursor', '');
var view = event.data.view;
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
var intIndex = Math.floor(view.get('index'));
if (!view.get('stream').findNearestToIndex(intIndex).content) {
view.get('streamContent').send('goToIndex', intIndex);
} else {
view.set('streamContent.paused', false);
}
},
// When the stream-content component resumes being 'active' (for example,
// after a bunch of posts have been loaded), then we want to update the
// scrubber scrollbar according to the window's current scroll position.
resume: Ember.observer('streamContent.active', function() {
var scrubber = this;
Ember.run.scheduleOnce('afterRender', function() {
if (scrubber.get('streamContent.active')) {
scrubber.update();
scrubber.updateScrollbar(true);
}
});
}),
// Update the index/visible/description properties according to the
// window's current scroll position.
update: function() {
if (!this.get('streamContent.active')) { return; }
var $window = $(window);
var marginTop = this.get('streamContent').getMarginTop();
var scrollTop = $window.scrollTop() + marginTop;
var windowHeight = $window.height() - marginTop;
// Before looping through all of the posts, we reset the scrollbar
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
var $items = this.get('streamContent').$().find('.item');
var index = $items.first().data('end') - 1;
var visible = 0;
var period = '';
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
// been loaded yet.
$items.each(function() {
var $this = $(this);
var top = $this.offset().top;
var height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// post. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
return;
}
if (top > scrollTop + windowHeight) {
return false;
}
// If the bottom half of this item is visible at the top of the
// viewport, then add the visible proportion to the visible
// counter, and set the scrollbar index to whatever the visible
// proportion represents. For example, if a gap represents indexes
// 0-9, and the bottom 50% of the gap is visible in the viewport,
// then the scrollbar index will be 5.
if (top <= scrollTop && top + height > scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
}
// If the top half of this item is visible at the bottom of the
// viewport, then add the visible proportion to the visible
// counter.
else if (top + height >= scrollTop + windowHeight) {
visible += (scrollTop + windowHeight - top) / height;
}
// If the whole item is visible in the viewport, then increment the
// visible counter.
else {
visible++;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
if ($this.data('time')) {
period = $this.data('time');
}
});
this.set('index', index);
this.set('visible', visible);
this.set('description', period ? moment(period).format('MMMM YYYY') : '');
},
// Update the scrollbar's position to reflect the current values of the
// index/visible properties.
updateScrollbar: function(animate) {
var percentPerPost = this.percentPerPost();
var index = this.get('index');
var count = this.get('count');
var visible = this.get('visible');
var heights = {};
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
heights.slider = Math.min(100 - heights.before, percentPerPost.visible * visible);
heights.after = 100 - heights.before - heights.slider;
var $scrubber = this.$();
var func = animate ? 'animate' : 'css';
for (var part in heights) {
var $part = $scrubber.find('.scrubber-'+part);
$part.stop(true, true)[func]({height: heights[part]+'%'});
// jQuery likes to put overflow:hidden, but because the scrollbar
// handle has a negative margin-left, we need to override.
if (func === 'animate') {
$part.css('overflow', 'visible');
}
}
},
// Instantly scroll to a certain index in the discussion. The index doesn't
// have to be an integer; any fraction of a post will be scrolled to.
scrollToIndex: function(index) {
index = Math.min(index, this.get('count') - 1);
// Find the item for this index, whether it's a post corresponding to
// the index, or a gap which the index is within.
var indexFloor = Math.max(0, Math.floor(index));
var $nearestItem = this.get('streamContent').findNearestToIndex(indexFloor);
// Calculate the position of this item so that we can scroll to it. If
// the item is a gap, then we will mark it as 'active' to indicate to
// the user that it will expand if they release their mouse.
// Otherwise, we will add a proportion of the item's height onto the
// scroll position.
var pos = $nearestItem.offset().top - this.get('streamContent').getMarginTop();
if ($nearestItem.is('.gap')) {
$nearestItem.addClass('active');
} else {
if (index >= 0) {
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
} else {
pos += $nearestItem.offset().top * index;
}
}
// Remove the 'active' class from other gaps.
this.get('streamContent').$().find('.gap').not($nearestItem).removeClass('active');
$('html, body').scrollTop(pos);
},
percentPerPost: function() {
var count = this.get('count') || 1;
var visible = this.get('visible');
// To stop the slider of the scrollbar from getting too small when there
// are many posts, we define a minimum percentage height for the slider
// calculated from a 50 pixel limit. From this, we can calculate the
// minimum percentage per visible post. If this is greater than the
// actual percentage per post, then we need to adjust the 'before'
// percentage to account for it.
var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
var percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
return {
index: percentPerPost,
visible: percentPerVisiblePost
};
},
actions: {
first: function() {
this.get('streamContent').send('goToFirst');
},
last: function() {
this.get('streamContent').send('goToLast');
}
}
});