1
0
mirror of https://github.com/flarum/core.git synced 2025-08-11 19:04:29 +02:00

Implement redesign, refactor everything

- Write CSS for everything, update templates.
- Refactor discussion view. Stream is split into two components
(content and scrubber) which have their own responsibilities.
- Extract pane functionality into a mixin.
- Implement global “back button” system. You give a “paneable” target
to the application controller, the back button will modulate its
pane-related properties as necessary, and call an action when the
button is clicked.
- Extract welcome-hero into its own component.
- Lots of other general improvements/refactoring. The code is quite
well-commented so take a look!
This commit is contained in:
Toby Zerner
2015-01-16 17:26:10 +10:30
parent d204ca87cf
commit 74e80ea2df
69 changed files with 2564 additions and 1334 deletions

View File

@@ -0,0 +1,26 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['back-button'],
classNameBindings: ['active'],
active: Ember.computed.or('target.paneShowing', 'target.panePinned'),
mouseEnter: function() {
this.get('target').send('showPane');
},
mouseLeave: function() {
this.get('target').send('hidePane');
},
actions: {
back: function() {
this.get('target').send('transitionFromBackButton');
this.set('target', null);
},
togglePinned: function() {
this.get('target').send('togglePinned');
}
}
});

View File

@@ -2,20 +2,18 @@ import Ember from 'ember';
import TaggedArray from '../../utils/tagged-array';
import ActionButton from '../ui/controls/action-button';
import SeparatorItem from '../ui/items/separator-item';
export default Ember.Component.extend({
_init: function() {
// this.set('controls', Menu.create());
}.on('init'),
terminalPostType: 'last',
countType: 'unread',
tagName: 'li',
attributeBindings: ['discussionId:data-id'],
classNames: ['discussion-summary'],
classNameBindings: [
'discussion.unread:unread',
'discussion.sticky:sticky',
'discussion.locked:locked',
'discussion.following:following',
'discussion.isUnread:unread',
'active'
],
layoutName: 'components/discussions/discussion-listing',
@@ -24,9 +22,19 @@ export default Ember.Component.extend({
return this.get('childViews').anyBy('active');
}.property('childViews.@each.active'),
discussionId: function() {
return this.get('discussion.id');
}.property('discussion.id'),
displayUnread: function() {
return this.get('countType') == 'unread' && this.get('discussion.isUnread');
}.property('countType', 'discussion.isUnread'),
displayLastPost: function() {
return this.get('terminalPostType') == 'last' && this.get('discussion.repliesCount');
}.property('terminalPostType', 'discussion.repliesCount'),
start: function() {
return this.get('discussion.isUnread') ? this.get('discussion.readNumber') + 1 : 1;
}.property('discussion.isUnread', 'discussion.readNumber'),
discussionId: Ember.computed.alias('discussion.id'),
relevantPosts: function() {
if (this.get('controller.show') != 'posts') return [];
@@ -39,115 +47,106 @@ export default Ember.Component.extend({
return [this.get('discussion.lastPost')];
}
}.property('discussion.relevantPosts', 'discussion.startPost', 'discussion.lastPost'),
icon: function() {
if (this.get('discussion.unread')) return 'circle';
}.property('discussion.unread'),
iconAction: function() {
if (this.get('discussion.unread')) return function() {
};
}.property('discussion.unread'),
categoryClass: function() {
return 'category-'+this.get('discussion.category').toLowerCase();
}.property('discussion.category'),
didInsertElement: function() {
this.$().hide().fadeIn('slow');
var $this = this.$().css({opacity: 0});
this.$().find('.terminal-post a').tooltip();
setTimeout(function() {
$this.animate({opacity: 1}, 'fast');
}, 100);
var view = this;
this.$().find('a.info, .terminal-post a').click(function() {
view.set('controller.paneShowing', false);
});
if (this.get('discussion.isUnread')) {
this.$().find('.count').tooltip();
}
// var view = this;
// this.$().find('a.info').click(function() {
// view.set('controller.paneShowing', false);
// });
// https://github.com/nolimits4web/Framework7/blob/master/src/js/swipeout.js
this.$().find('.discussion').on('touchstart mousedown', function(e) {
var isMoved = false;
var isTouched = true;
var isScrolling = undefined;
var touchesStart = {
x: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
y: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
};
var touchStartTime = (new Date()).getTime();
// this.$().find('.discussion').on('touchstart mousedown', function(e) {
// var isMoved = false;
// var isTouched = true;
// var isScrolling = undefined;
// var touchesStart = {
// x: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
// y: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
// };
// var touchStartTime = (new Date()).getTime();
$(this).on('touchmove mousemove', function(e) {
if (! isTouched) return;
$(this).find('a.info').removeClass('pressed');
var touchesNow = {
x: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
y: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
};
if (typeof isScrolling === 'undefined') {
isScrolling = !!(isScrolling || Math.abs(touchesNow.y - touchesStart.y) > Math.abs(touchesNow.x - touchesStart.x));
}
if (isScrolling) {
isTouched = false;
return;
}
// $(this).on('touchmove mousemove', function(e) {
// if (! isTouched) return;
// $(this).find('a.info').removeClass('pressed');
// var touchesNow = {
// x: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
// y: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
// };
// if (typeof isScrolling === 'undefined') {
// isScrolling = !!(isScrolling || Math.abs(touchesNow.y - touchesStart.y) > Math.abs(touchesNow.x - touchesStart.x));
// }
// if (isScrolling) {
// isTouched = false;
// return;
// }
isMoved = true;
e.preventDefault();
// isMoved = true;
// e.preventDefault();
var diffX = touchesNow.x - touchesStart.x;
var translate = diffX;
var actionsRightWidth = 150;
// var diffX = touchesNow.x - touchesStart.x;
// var translate = diffX;
// var actionsRightWidth = 150;
if (translate < -actionsRightWidth) {
translate = -actionsRightWidth - Math.pow(-translate - actionsRightWidth, 0.8);
}
// if (translate < -actionsRightWidth) {
// translate = -actionsRightWidth - Math.pow(-translate - actionsRightWidth, 0.8);
// }
$(this).css('left', translate);
});
// $(this).css('left', translate);
// });
$(this).on('touchend mouseup', function(e) {
$(this).off('touchmove mousemove touchend mouseup');
$(this).find('a.info').removeClass('pressed');
if (!isTouched || !isMoved) {
isTouched = false;
isMoved = false;
return;
}
isTouched = false;
// isMoved = false;
// $(this).on('touchend mouseup', function(e) {
// $(this).off('touchmove mousemove touchend mouseup');
// $(this).find('a.info').removeClass('pressed');
// if (!isTouched || !isMoved) {
// isTouched = false;
// isMoved = false;
// return;
// }
// isTouched = false;
// // isMoved = false;
if (isMoved) {
e.preventDefault();
$(this).animate({left: -150});
}
});
$(this).find('a.info').addClass('pressed').on('click', function(e) {
if (isMoved) {
e.preventDefault();
e.stopImmediatePropagation();
}
$(this).off('click');
});
});
// if (isMoved) {
// e.preventDefault();
// $(this).animate({left: -150});
// }
// });
// $(this).find('a.info').addClass('pressed').on('click', function(e) {
// if (isMoved) {
// e.preventDefault();
// e.stopImmediatePropagation();
// }
// $(this).off('click');
// });
// });
var discussion = this.get('discussion');
// var controls = this.get('controls');
// controls.addItem('sticky', MenuItem.extend({title: 'Sticky', icon: 'thumb-tack', action: 'sticky'}));
// controls.addItem('lock', MenuItem.extend({title: 'Lock', icon: 'lock', action: 'lock'}));
// controls.addSeparator();
// controls.addItem('delete', MenuItem.extend({title: 'Delete', icon: 'times', className: 'delete', action: function() {
// // this.get('controller').send('delete', discussion);
// var discussion = view.$().slideUp().find('.discussion');
// discussion.css('position', 'relative').animate({left: -discussion.width()});
// }}));
this.set('controls', TaggedArray.create());
},
populateControlsDefault: function(controls) {
controls.pushObjectWithTag(ActionButton.create({
label: 'Delete',
icon: 'times',
className: 'delete'
}), 'delete');
}.on('populateControls'),
actions: {
icon: function() {
this.get('iconAction')();
populateControls: function() {
if ( ! this.get('controls.length')) {
this.trigger('populateControls', this.get('controls'));
}
}
}

View File

@@ -0,0 +1,8 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'article',
layoutName: 'components/discussions/post-content-comment',
editDescription: ''
});

View File

@@ -5,10 +5,12 @@ import ActionButton from '../ui/controls/action-button';
export default Ember.Component.extend({
tagName: 'article',
layoutName: 'components/discussions/post-full',
layoutName: 'components/discussions/post-wrapper',
// controls: null,
post: Ember.computed.alias('content'),
contentComponent: function() {
return 'discussions/post-content-'+this.get('post.type');
}.property('post.type'),
@@ -16,26 +18,31 @@ export default Ember.Component.extend({
classNames: ['post'],
classNameBindings: ['post.deleted', 'post.edited'],
construct: function() {
// this.set('controls', Menu.create());
// construct: function() {
// // this.set('controls', Menu.create());
// var post = this.get('post');
// // var post = this.get('post');
// if (post.get('deleted')) {
// this.addControl('restore', 'Restore', 'reply', 'canEdit');
// this.addControl('delete', 'Delete', 'times', 'canDelete');
// } else {
// if (post.get('type') == 'comment') {
// this.addControl('edit', 'Edit', 'pencil', 'canEdit');
// this.addControl('hide', 'Delete', 'times', 'canEdit');
// } else {
// this.addControl('delete', 'Delete', 'times', 'canDelete');
// }
// }
}.on('init'),
// // if (post.get('deleted')) {
// // this.addControl('restore', 'Restore', 'reply', 'canEdit');
// // this.addControl('delete', 'Delete', 'times', 'canDelete');
// // } else {
// // if (post.get('type') == 'comment') {
// // this.addControl('edit', 'Edit', 'pencil', 'canEdit');
// // this.addControl('hide', 'Delete', 'times', 'canEdit');
// // } else {
// // this.addControl('delete', 'Delete', 'times', 'canDelete');
// // }
// // }
// }.on('init'),
didInsertElement: function() {
this.$().hide().fadeIn('slow');
var $this = this.$();
$this.css({opacity: 0});
setTimeout(function() {
$this.animate({opacity: 1}, 'fast');
}, 100);
},
addControl: function(tag, title, icon, permissionAttribute) {

View File

@@ -0,0 +1,258 @@
import Ember from 'ember';
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: function() {
return this.get('loaded') && ! this.get('paused');
}.property('loaded', 'paused'),
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: 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,
currentNumber;
// 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),
top = $this.offset().top,
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();
}
}
// 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 && ! currentNumber) {
currentNumber = $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('updateStart', currentNumber || 1);
}, 250);
}.observes('active'),
loadingNumber: function(number) {
// 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);
},
loadedNumber: function(number) {
// 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).done(function() {
view.set('paused', false);
});
});
},
loadingIndex: function(index) {
// 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);
},
loadedIndex: function(index) {
// 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).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) {
// 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 (number > 1) {
$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);
},
// Scroll down to a certain post by index (or the gap the post is in.)
jumpToIndex: function(index) {
var $item = this.findNearestToIndex(index);
return this.scrollToItem($item);
},
scrollToItem: function($item) {
var $container = $('html, body');
if ($item.length) {
var marginTop = this.getMarginTop();
var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - marginTop;
if (scrollTop != $(document).scrollTop()) {
$container.stop(true).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) {
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);
// 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);
});
},
goToIndex: function(index) {
// 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);
// 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).then(function() {
controller.trigger('loadedIndex', index);
});
},
loadRange: function(start, end, backwards) {
this.get('stream').loadRange(start, end, backwards);
}
}
});

View File

@@ -14,34 +14,17 @@ export default Ember.Component.extend({
'number:data-number'
],
start: function() {
return this.get('item.indexStart');
}.property('item.indexStart'),
end: function() {
return this.get('item.indexEnd');
}.property('item.indexEnd'),
start: Ember.computed.alias('item.indexStart'),
end: Ember.computed.alias('item.indexEnd'),
time: Ember.computed.alias('item.content.time'),
number: Ember.computed.alias('item.content.number'),
loading: Ember.computed.alias('item.loading'),
direction: Ember.computed.alias('item.direction'),
count: function() {
return this.get('end') - this.get('start') + 1;
}.property('start', 'end'),
time: function() {
return this.get('item.post.time');
}.property('item.post.time'),
number: function() {
return this.get('item.post.number');
}.property('item.post.number'),
loading: function() {
return this.get('item.loading');
}.property('item.loading'),
direction: function() {
return this.get('item.direction');
}.property(),
loadingChanged: function() {
this.rerender();
}.observes('loading'),
@@ -73,8 +56,10 @@ export default Ember.Component.extend({
} else {
var self = this;
this.$().hover(function(e) {
var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
self.set('direction', up ? 'up' : 'down');
if (! self.get('loading')) {
var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
self.set('direction', up ? 'up' : 'down');
}
});
}
},
@@ -97,7 +82,7 @@ export default Ember.Component.extend({
// 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('controller.postStream').one('postsLoaded', function() {
this.get('stream').one('postsLoaded', function() {
if (anchor.length) {
var scrollOffset = anchor.offset().top - $(document).scrollTop();
}

View File

@@ -1,275 +0,0 @@
import Ember from 'ember';
import Scrollbar from '../../utils/scrollbar';
import PostStreamMixin from '../../mixins/post-stream';
export default Ember.View.extend(PostStreamMixin, {
layoutName: 'components/discussions/stream-scrollbar',
classNames: ['scrubber', 'discussion-scrubber'],
// An object which represents/ecapsulates the scrollbar.
scrollbar: null,
// Right after the controller finished loading a discussion, we want to
// trigger a scroll event on the window so the interface is kept up-to-date.
loadedChanged: function() {
this.scrollbar.setDisabled(! this.get('controller.loaded'));
}.observes('controller.loaded'),
countChanged: function() {
this.scrollbar.setCount(this.get('controller.postStream.count'));
}.observes('controller.postStream.count'),
windowWasResized: function(event) {
var view = event.data.view;
// view.scrollbar.$.height($('#sidebar-content').height() + $('#sidebar-content').offset().top - view.scrollbar.$.offset().top - 80);
view.scrollbar.update();
},
windowWasScrolled: function(event) {
var view = event.data.view,
$window = $(window);
if (! view.get('controller.loaded') || $window.data('disableScrollHandler')) {
return;
}
var scrollTop = $window.scrollTop(),
windowHeight = $window.height();
// 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 index = $('.posts .item:first').data('end');
var visiblePosts = 0;
var period = '';
var first = $('.posts .item[data-start=0]');
var offsetTop = first.length ? first.offset().top : 0;
// 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.
// @todo cache item top positions to speed this up?
$('.posts .item').each(function(k) {
var $this = $(this),
top = $this.offset().top - offsetTop,
height = $this.outerHeight();
// 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) {
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 visiblePosts
// 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) {
visiblePosts = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visiblePosts;
}
// If the top half of this item is visible at the bottom of the
// viewport, then add the visible proportion to the visiblePosts
// counter.
else if (top + height >= scrollTop + windowHeight) {
visiblePosts += (scrollTop + windowHeight - top) / height;
}
// If the whole item is visible in the viewport, then increment the
// visiblePosts counter.
else {
visiblePosts++;
}
// 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');
}
});
// Now that we've looped through all of the items and have worked out
// the scrollbar's current index and the number of posts visible in the
// viewport, we can update the scrollbar.
view.scrollbar.setIndex(index);
view.scrollbar.setVisible(visiblePosts);
view.scrollbar.update();
view.scrollbar.$.find('.index').text(Math.ceil(index + visiblePosts));
view.scrollbar.$.find('.description').text(moment(period).format('MMMM YYYY'));
},
mouseWasMoved: function(event) {
var view = event.data.view;
if ( ! event.data.handle) {
return;
}
var offsetPixels = event.clientY - event.data.mouseStart;
var offsetPercent = offsetPixels / view.scrollbar.$.outerHeight() * 100;
var offsetIndex = offsetPercent / view.scrollbar.percentPerPost().index;
var newIndex = Math.max(0, Math.min(event.data.indexStart + offsetIndex, view.scrollbar.count - 1));
view.scrollToIndex(newIndex);
},
mouseWasReleased: function(event) {
var view = event.data.view;
if (! event.data.handle) {
return;
}
event.data.mouseStart = 0;
event.data.indexStart = 0;
event.data.handle = null;
$(window).data('disableScrollHandler', false);
view.get('controller').send('jumpToIndex', Math.floor(view.scrollbar.index));
$(window).scroll();
$('body').css('cursor', '');
},
didInsertElement: function() {
var view = this;
// Set up scrollbar object
this.scrollbar = new Scrollbar($('.discussion-scrubber .scrollbar'));
this.scrollbar.setDisabled(true);
this.countChanged();
this.loadedChanged();
// 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);
this.get('controller').on('loadingIndex', this, this.loadingIndex);
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
this.scrollbar.$
.css('user-select', 'none')
.bind('dragstart mousedown', function(e) {
e.preventDefault();
});
// When the mouse is pressed on the scrollbar handle, we need to capture
// some information about the current position.
var scrollData = {
view: this,
mouseStart: 0,
indexStart: 0,
handle: null
};
this.scrollbar.$.find('.scrollbar-slider').css('cursor', 'move').mousedown(function(e) {
scrollData.mouseStart = e.clientY;
scrollData.indexStart = view.scrollbar.index;
scrollData.handle = $(this);
$(window).data('disableScrollHandler', true);
$('body').css('cursor', 'move');
});
// When the mouse moves,
$(document)
.on('mousemove', scrollData, this.mouseWasMoved)
.on('mouseup', scrollData, this.mouseWasReleased);
// When any part of the whole scrollbar is clicked, we want to jump to
// that position.
this.scrollbar.$.click(function(e) {
// Calculate the index which we want to jump to.
// @todo document how this complexity works.
var offsetPixels = e.clientY - view.scrollbar.$.offset().top + $('body').scrollTop();
var offsetPercent = offsetPixels / view.scrollbar.$.outerHeight() * 100;
var handleHeight = parseFloat(view.scrollbar.$.find('.scrollbar-slider')[0].style.height);
var offsetIndex = (offsetPercent - handleHeight / 2) / view.scrollbar.percentPerPost().index;
var newIndex = Math.max(0, Math.min(view.scrollbar.count - 1, offsetIndex));
view.get('controller').send('jumpToIndex', Math.floor(newIndex));
})
// Exempt the scrollbar handle from this 'jump to' click event.
this.scrollbar.$.find('.scrollbar-slider').click(function(e) {
e.stopPropagation();
});
},
actions: {
firstPost: function() {
this.get('controller').send('jumpToIndex', 0);
},
lastPost: function() {
this.get('controller').send('jumpToIndex', this.scrollbar.count - 1);
}
},
loadingIndex: function(index) {
this.scrollToIndex(index, true);
},
// 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, animate) {
index = Math.max(0, Math.min(index, this.scrollbar.count - 1));
var indexFloor = Math.floor(index);
// Find
var nearestItem = this.findNearestToIndex(indexFloor);
var first = $('.posts .item[data-start=0]');
var offsetTop = first.length ? first.offset().top : 0;
var pos = nearestItem.offset().top - offsetTop;
if (! nearestItem.is('.gap')) {
pos += nearestItem.outerHeight() * (index - indexFloor);
} else {
nearestItem.addClass('active');
}
$('.posts .item.gap').not(nearestItem).removeClass('active');
if (animate) {
// $('html, body').animate({scrollTop: pos});
} else {
$('html, body').scrollTop(pos);
}
this.scrollbar.setIndex(index);
this.scrollbar.update(animate);
},
willDestroyElement: function() {
$(window)
.off('resize', this.windowWasResized)
.off('scroll', this.windowWasScrolled);
$(document)
.off('mousemove', this.mouseWasMoved)
.off('mouseup', this.mouseWasReleased);
this.get('controller').off('loadingIndex', this, this.loadingIndex);
}
});

View File

@@ -0,0 +1,389 @@
import Ember from 'ember';
export default Ember.Component.extend({
layoutName: 'components/discussions/stream-scrubber',
classNames: ['scrubber', 'stream-scrubber'],
classNameBindings: ['disabled'],
// The stream-content component to which this scrubber is linked.
streamContent: null,
stream: Ember.computed.alias('streamContent.stream'),
loaded: Ember.computed.alias('streamContent.loaded'),
count: Ember.computed.alias('stream.count'),
// 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 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: function() {
return Math.min(this.get('count'), Math.ceil(Math.max(0, this.get('index')) + this.get('visible')));
}.property('index', 'visible'),
// 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: '',
// 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: function() {
return ! this.get('loaded') || this.get('visible') >= this.get('count');
}.property('loaded', 'visible', '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: function() {
this.set('index', -1);
this.set('visible', 1);
this.updateScrollbar();
}.observes('stream'),
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),
offsetPixels = e.clientY - $this.offset().top + $('body').scrollTop(),
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,
deltaPercent = deltaPixels / view.$('.scrubber-scrollbar').outerHeight() * 100,
deltaIndex = deltaPercent / view.percentPerPost().index,
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;
view.set('streamContent.paused', false);
// 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).gap) {
view.get('streamContent').send('goToIndex', intIndex);
}
},
// 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: function() {
if (this.get('streamContent.active')) {
this.update();
this.updateScrollbar(true);
}
}.observes('streamContent.active'),
// 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),
marginTop = this.get('streamContent').getMarginTop(),
scrollTop = $window.scrollTop() + marginTop,
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(k) {
var $this = $(this),
top = $this.offset().top,
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(),
index = this.get('index'),
count = this.get('count'),
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)),
$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,
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('goToIndex', 0);
},
last: function() {
this.get('streamContent').send('goToIndex', this.get('count') - 1);
}
}
});

View File

@@ -1,24 +1,28 @@
import Ember from 'ember';
export default Ember.Component.extend({
title: '',
label: '',
icon: '',
className: '',
action: null,
divider: false,
active: false,
classNames: ['btn', 'btn-default'],
classNames: [],
tagName: 'a',
attributeBindings: ['href'],
attributeBindings: ['href', 'title'],
classNameBindings: ['className'],
href: '#',
layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw"}} {{/if}}<span>{{title}}</span>'),
layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}<span>{{label}}</span>'),
click: function(e) {
e.preventDefault();
// this.sendAction('action');
this.get('action')();
var action = this.get('action');
if (typeof action == 'string') {
this.sendAction('action');
} else if (typeof action == 'function') {
action();
}
}
});

View File

@@ -4,12 +4,12 @@ export default Ember.Component.extend({
items: null, // TaggedArray
layoutName: 'components/ui/controls/dropdown-button',
classNames: ['dropdown', 'btn-group'],
classNameBindings: ['itemCountClass'],
classNameBindings: ['itemCountClass', 'class'],
title: 'Controls',
icon: 'ellipsis-v',
buttonClass: 'btn-default',
menuClass: 'pull-right',
buttonClass: 'btn btn-default',
menuClass: '',
dropdownMenuClass: function() {
return 'dropdown-menu '+this.get('menuClass');
@@ -17,5 +17,11 @@ export default Ember.Component.extend({
itemCountClass: function() {
return 'item-count-'+this.get('items.length');
}.property('items.length')
}.property('items.length'),
actions: {
buttonClick: function() {
this.sendAction('buttonClick');
}
}
});

View File

@@ -4,10 +4,10 @@ export default Ember.Component.extend({
items: [],
layoutName: 'components/ui/controls/dropdown-select',
classNames: ['dropdown', 'dropdown-select', 'btn-group'],
classNameBindings: ['itemCountClass'],
classNameBindings: ['itemCountClass', 'class'],
buttonClass: 'btn-default',
menuClass: 'pull-right',
buttonClass: 'btn btn-default',
menuClass: '',
icon: 'ellipsis-v',
mainButtonClass: function() {
@@ -25,9 +25,4 @@ export default Ember.Component.extend({
activeItem: function() {
return this.get('menu.childViews').findBy('active');
}.property('menu.childViews.@each.active')
}).reopenClass({
createWithItems: function(items) {
return this.create({items: items});
}
});
});

View File

@@ -5,6 +5,7 @@ import DropdownButton from './dropdown-button';
export default DropdownButton.extend({
layoutName: 'components/ui/controls/dropdown-split',
classNames: ['dropdown', 'dropdown-split', 'btn-group'],
menuClass: 'pull-right',
mainButtonClass: function() {
return 'btn '+this.get('buttonClass');

View File

@@ -1,9 +1,10 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['loading'],
classNames: ['loading-indicator'],
layout: Ember.Handlebars.compile('&nbsp;'),
size: 'small',
didInsertElement: function() {
this.$().spin(this.get('size'));

View File

@@ -4,6 +4,8 @@ export default Ember.Component.extend({
classNames: ['search-input'],
classNameBindings: ['active', 'value:clearable'],
layoutName: 'components/ui/controls/search-input',
didInsertElement: function() {
var self = this;
this.$().find('input').on('keydown', function(e) {
@@ -21,7 +23,7 @@ export default Ember.Component.extend({
clear: function() {
this.set('value', '');
this.sendAction('action', '');
this.send('search');
this.$().find('input').focus();
},
@@ -32,7 +34,7 @@ export default Ember.Component.extend({
actions: {
search: function() {
this.sendAction('action', this.get('value'));
this.get('action')(this.get('value'));
}
}
});

View File

@@ -2,6 +2,8 @@ import Ember from 'ember';
export default Ember.View.extend({
tagName: 'span',
classNames: ['select'],
layout: Ember.Handlebars.compile('{{view "select" content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value}} {{fa-icon "sort"}}')
classNames: ['select-input'],
optionValuePath: 'content',
optionLabelPath: 'content',
layout: Ember.Handlebars.compile('{{view "select" content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value class="form-control"}} {{fa-icon "sort"}}')
});

View File

@@ -2,7 +2,7 @@ import Ember from 'ember';
export default Ember.Component.extend({
icon: '',
title: '',
label: '',
action: null,
badge: '',
@@ -22,7 +22,7 @@ export default Ember.Component.extend({
// },
layout: function() {
return Ember.Handlebars.compile('{{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+' {{title}} <span class="count">{{badge}}</span>{{/link-to}}');
return Ember.Handlebars.compile('{{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+' {{label}} <span class="count">{{badge}}</span>{{/link-to}}');
}.property('linkTo', 'iconTemplate'),
iconTemplate: function() {

View File

@@ -0,0 +1,15 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'header',
classNames: ['hero', 'welcome-hero'],
didInsertElement: function() {
var hero = this.$();
hero.find('.close').click(function() {
hero.slideUp();
});
}
});