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:
107
ember/app/components/discussion/post-comment.js
Normal file
107
ember/app/components/discussion/post-comment.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
38
ember/app/components/discussion/post-header/edited.js
Normal file
38
ember/app/components/discussion/post-header/edited.js
Normal 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');
|
||||
});
|
||||
})
|
||||
});
|
36
ember/app/components/discussion/post-header/meta.js
Normal file
36
ember/app/components/discussion/post-header/meta.js
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
21
ember/app/components/discussion/post-header/toggle.js
Normal file
21
ember/app/components/discussion/post-header/toggle.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
12
ember/app/components/discussion/post-header/user.js
Normal file
12
ember/app/components/discussion/post-header/user.js
Normal 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}}')
|
||||
});
|
293
ember/app/components/discussion/stream-content.js
Normal file
293
ember/app/components/discussion/stream-content.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
125
ember/app/components/discussion/stream-item.js
Normal file
125
ember/app/components/discussion/stream-item.js
Normal 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(' ');
|
||||
} 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();
|
||||
}
|
||||
});
|
392
ember/app/components/discussion/stream-scrubber.js
Normal file
392
ember/app/components/discussion/stream-scrubber.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user