mirror of
https://github.com/flarum/core.git
synced 2025-02-25 03:33:42 +01:00
New and improved post stream.
This commit is contained in:
parent
2741923714
commit
cafa6c7b5d
@ -1,10 +1,9 @@
|
||||
import Component from 'flarum/component';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import PostStream from 'flarum/utils/post-stream';
|
||||
import DiscussionList from 'flarum/components/discussion-list';
|
||||
import DiscussionHero from 'flarum/components/discussion-hero';
|
||||
import StreamContent from 'flarum/components/stream-content';
|
||||
import StreamScrubber from 'flarum/components/stream-scrubber';
|
||||
import PostStream from 'flarum/components/post-stream';
|
||||
import PostScrubber from 'flarum/components/post-scrubber';
|
||||
import ReplyComposer from 'flarum/components/reply-composer';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
@ -22,24 +21,13 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
super(props);
|
||||
|
||||
this.discussion = m.prop();
|
||||
|
||||
// Set up the stream. The stream is an object that represents the posts in
|
||||
// a discussion, as they're displayed on the screen (i.e. missing posts
|
||||
// are condensed into "load more" gaps).
|
||||
this.stream = m.prop();
|
||||
|
||||
// Get the discussion. We may already have a copy of it in our store, so
|
||||
// we'll start off with that. If we do have a copy of the discussion, and
|
||||
// its posts relationship has been loaded (i.e. we've viewed this
|
||||
// discussion before), then we can proceed with displaying it immediately.
|
||||
// If not, we'll make an API request first.
|
||||
this.refresh();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
if (!(app.current instanceof DiscussionPage)) {
|
||||
app.cache.discussionList.subtrees.map(subtree => subtree.invalidate());
|
||||
} else {
|
||||
m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter even is triggered so it doesn't hide
|
||||
m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter event is triggered so it doesn't hide
|
||||
}
|
||||
app.pane.enable();
|
||||
app.pane.hide();
|
||||
@ -74,25 +62,6 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
|
||||
*/
|
||||
setupDiscussion(discussion) {
|
||||
this.discussion(discussion);
|
||||
|
||||
var includedPosts = [];
|
||||
discussion.payload.included && discussion.payload.included.forEach(record => {
|
||||
if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) {
|
||||
includedPosts.push(record.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up the post stream for this discussion, and add all of the posts we
|
||||
// have loaded so far.
|
||||
this.stream(new PostStream(discussion));
|
||||
this.stream().addPosts(discussion.posts().filter(value => value && includedPosts.indexOf(value.id()) !== -1));
|
||||
this.streamContent = new StreamContent({
|
||||
stream: this.stream(),
|
||||
className: 'discussion-posts posts',
|
||||
positionChanged: this.positionChanged.bind(this)
|
||||
});
|
||||
|
||||
// Hold up there skippy! If the slug in the URL doesn't match up, we'll
|
||||
// redirect so we have the correct one.
|
||||
// Waiting on https://github.com/lhorie/mithril.js/issues/539
|
||||
@ -104,11 +73,19 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
this.streamContent.goToNumber(this.currentNear, true);
|
||||
|
||||
this.discussion(discussion);
|
||||
app.setTitle(discussion.title());
|
||||
|
||||
this.trigger('loaded');
|
||||
var includedPosts = [];
|
||||
discussion.payload.included && discussion.payload.included.forEach(record => {
|
||||
if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) {
|
||||
includedPosts.push(app.store.getById('posts', record.id));
|
||||
}
|
||||
});
|
||||
|
||||
this.stream = new PostStream({ discussion, includedPosts });
|
||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
||||
this.stream.goToNumber(m.route.param('near') || 1, true);
|
||||
}
|
||||
|
||||
onload(element, isInitialized, context) {
|
||||
@ -134,7 +111,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
if (m.route.param('id') == discussion.id()) {
|
||||
e.preventDefault();
|
||||
if (m.route.param('near') != this.currentNear) {
|
||||
this.streamContent.goToNumber(m.route.param('near'));
|
||||
this.stream.goToNumber(m.route.param('near') || 1);
|
||||
}
|
||||
this.currentNear = null;
|
||||
return;
|
||||
@ -160,7 +137,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
m('nav.discussion-nav', [
|
||||
m('ul', listItems(this.sidebarItems().toArray()))
|
||||
]),
|
||||
this.streamContent.view()
|
||||
this.stream.view()
|
||||
])
|
||||
] : LoadingIndicator.component({className: 'loading-indicator-block'}))
|
||||
]);
|
||||
@ -219,8 +196,8 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
);
|
||||
|
||||
items.add('scrubber',
|
||||
StreamScrubber.component({
|
||||
streamContent: this.streamContent,
|
||||
PostScrubber.component({
|
||||
stream: this.stream,
|
||||
wrapperClass: 'title-control'
|
||||
})
|
||||
);
|
||||
|
11
js/forum/src/components/post-loading.js
Normal file
11
js/forum/src/components/post-loading.js
Normal file
@ -0,0 +1,11 @@
|
||||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
export default class PostLoadingComponent extends Component {
|
||||
view() {
|
||||
return m('div.post.comment-post.loading-post.fake-post',
|
||||
m('header.post-header', avatar(), m('div.fake-text')),
|
||||
m('div.post-body', m('div.fake-text'), m('div.fake-text'), m('div.fake-text'))
|
||||
);
|
||||
}
|
||||
}
|
@ -7,26 +7,19 @@ import computed from 'flarum/utils/computed';
|
||||
/**
|
||||
|
||||
*/
|
||||
export default class StreamScrubber extends Component {
|
||||
export default class PostScrubber extends Component {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
var streamContent = this.props.streamContent;
|
||||
var stream = this.props.stream;
|
||||
this.handlers = {};
|
||||
|
||||
// When the stream-content component begins loading posts at a certain
|
||||
// index, we want our scrubber scrollbar to jump to that position.
|
||||
streamContent.on('loadingIndex', this.handlers.loadingIndex = this.loadingIndex.bind(this));
|
||||
streamContent.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
|
||||
|
||||
/**
|
||||
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.
|
||||
*/
|
||||
this.disabled = () => !streamContent.loaded() || this.visible() >= this.count();
|
||||
stream.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
|
||||
|
||||
/**
|
||||
The integer index of the last item that is visible in the viewport. This
|
||||
@ -36,16 +29,11 @@ export default class StreamScrubber extends Component {
|
||||
return Math.min(count, Math.ceil(Math.max(0, index) + visible));
|
||||
});
|
||||
|
||||
this.count = () => this.props.streamContent.props.stream.count();
|
||||
this.count = () => this.props.stream.count();
|
||||
this.index = m.prop(-1);
|
||||
this.visible = m.prop(1);
|
||||
this.description = m.prop();
|
||||
|
||||
this.unreadCount = () => {
|
||||
var discussion = this.props.streamContent.props.stream.discussion;
|
||||
return discussion.lastPostNumber() - discussion.readNumber();
|
||||
};
|
||||
|
||||
// Define a handler to update the state of the scrollbar to reflect the
|
||||
// current scroll position of the page.
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
@ -58,13 +46,21 @@ export default class StreamScrubber extends Component {
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
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() {
|
||||
return this.visible() >= this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
view() {
|
||||
var retain = this.subtree.retain();
|
||||
var streamContent = this.props.streamContent;
|
||||
var unreadCount = this.unreadCount();
|
||||
var stream = this.props.stream;
|
||||
var unreadCount = this.props.stream.discussion.unreadCount();
|
||||
var unreadPercent = unreadCount / this.count();
|
||||
|
||||
return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [
|
||||
@ -74,7 +70,11 @@ export default class StreamScrubber extends Component {
|
||||
]),
|
||||
m('div.dropdown-menu', [
|
||||
m('div.scrubber', [
|
||||
m('a.scrubber-first[href=javascript:;]', {onclick: streamContent.goToFirst.bind(streamContent)}, [icon('angle-double-up'), ' Original Post']),
|
||||
m('a.scrubber-first[href=javascript:;]', {onclick: () => {
|
||||
stream.goToFirst();
|
||||
this.index(0);
|
||||
this.renderScrollbar(true);
|
||||
}}, [icon('angle-double-up'), ' Original Post']),
|
||||
m('div.scrubber-scrollbar', [
|
||||
m('div.scrubber-before'),
|
||||
m('div.scrubber-slider', [
|
||||
@ -89,7 +89,7 @@ export default class StreamScrubber extends Component {
|
||||
style: {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'},
|
||||
config: function(element, isInitialized, context) {
|
||||
var $element = $(element);
|
||||
var newStyle = {top: $element.css('top'), height: $element.css('height')};
|
||||
var newStyle = {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'};
|
||||
if (context.oldStyle) {
|
||||
$element.stop(true).css(context.oldStyle).animate(newStyle);
|
||||
}
|
||||
@ -97,16 +97,20 @@ export default class StreamScrubber extends Component {
|
||||
}
|
||||
}, unreadCount+' unread') : ''
|
||||
]),
|
||||
m('a.scrubber-last[href=javascript:;]', {onclick: streamContent.goToLast.bind(streamContent)}, [icon('angle-double-down'), ' Now'])
|
||||
m('a.scrubber-last[href=javascript:;]', {onclick: () => {
|
||||
stream.goToLast();
|
||||
this.index(stream.count());
|
||||
this.renderScrollbar(true);
|
||||
}}, [icon('angle-double-down'), ' Now'])
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
onscroll(top) {
|
||||
var streamContent = this.props.streamContent;
|
||||
var stream = this.props.stream;
|
||||
|
||||
if (!streamContent.active() || !streamContent.$()) { return; }
|
||||
if (stream.paused() || !stream.$()) { return; }
|
||||
|
||||
this.update(top);
|
||||
this.renderScrollbar();
|
||||
@ -117,10 +121,10 @@ export default class StreamScrubber extends Component {
|
||||
current scroll position.
|
||||
*/
|
||||
update(top) {
|
||||
var streamContent = this.props.streamContent;
|
||||
var stream = this.props.stream;
|
||||
|
||||
var $window = $(window);
|
||||
var marginTop = streamContent.getMarginTop();
|
||||
var marginTop = stream.getMarginTop();
|
||||
var scrollTop = $window.scrollTop() + marginTop;
|
||||
var windowHeight = $window.height() - marginTop;
|
||||
|
||||
@ -128,8 +132,8 @@ export default class StreamScrubber extends Component {
|
||||
// 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 = streamContent.$('.item');
|
||||
var index = $items.first().data('end') - 1;
|
||||
var $items = stream.$('> .item');
|
||||
var index = $items.first().data('index');
|
||||
var visible = 0;
|
||||
var period = '';
|
||||
|
||||
@ -146,7 +150,7 @@ export default class StreamScrubber extends Component {
|
||||
// loop.
|
||||
if (top + height < scrollTop) {
|
||||
visible = (top + height - scrollTop) / height;
|
||||
index = parseFloat($this.data('end')) + 1 - visible;
|
||||
index = parseFloat($this.data('index')) + 1 - visible;
|
||||
return;
|
||||
}
|
||||
if (top > scrollTop + windowHeight) {
|
||||
@ -154,14 +158,10 @@ export default class StreamScrubber extends Component {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// viewport
|
||||
if (top <= scrollTop && top + height > scrollTop) {
|
||||
visible = (top + height - scrollTop) / height;
|
||||
index = parseFloat($this.data('end')) + 1 - visible;
|
||||
index = parseFloat($this.data('index')) + 1 - visible;
|
||||
}
|
||||
|
||||
// If the top half of this item is visible at the bottom of the
|
||||
@ -206,69 +206,30 @@ export default class StreamScrubber extends Component {
|
||||
// so that it fills the height of the sidebar.
|
||||
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
|
||||
|
||||
var self = this;
|
||||
|
||||
// When any part of the whole scrollbar is clicked, we want to jump to
|
||||
// that position.
|
||||
this.$('.scrubber-scrollbar')
|
||||
.bind('click touchstart', function(e) {
|
||||
if (!self.props.streamContent.active()) { return; }
|
||||
.bind('click touchstart', this.onclick.bind(this))
|
||||
|
||||
// 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 || e.originalEvent.touches[0].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 / self.percentPerPost().index;
|
||||
offsetIndex = Math.max(0, Math.min(self.count() - 1, offsetIndex));
|
||||
self.props.streamContent.goToIndex(Math.floor(offsetIndex));
|
||||
|
||||
self.$().removeClass('open');
|
||||
});
|
||||
|
||||
// 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 touchstart', function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
// Now we want to make the scrollbar handle draggable. Let's start by
|
||||
// preventing default browser events from messing things up.
|
||||
.css({ cursor: 'pointer', 'user-select': 'none' })
|
||||
.bind('dragstart mousedown touchstart', 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.
|
||||
this.dragging = false;
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.handle = null;
|
||||
|
||||
this.$('.scrubber-slider')
|
||||
.css('cursor', 'move')
|
||||
.bind('mousedown touchstart', function(e) {
|
||||
self.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||
self.indexStart = self.index();
|
||||
self.handle = $(this);
|
||||
self.props.streamContent.paused(true);
|
||||
$('body').css('cursor', 'move');
|
||||
})
|
||||
.bind('mousedown touchstart', this.onmousedown.bind(this))
|
||||
|
||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||
.click(function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
.click(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
|
||||
@ -282,8 +243,7 @@ export default class StreamScrubber extends Component {
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
|
||||
this.props.streamContent.off('loadingIndex', this.handlers.loadingIndex);
|
||||
this.props.streamContent.off('unpaused', this.handlers.unpaused);
|
||||
this.props.stream.off('unpaused', this.handlers.unpaused);
|
||||
|
||||
$(window)
|
||||
.off('resize', this.handlers.onresize);
|
||||
@ -305,7 +265,6 @@ export default class StreamScrubber extends Component {
|
||||
|
||||
var $scrubber = this.$();
|
||||
$scrubber.find('.index').text(this.visibleIndex());
|
||||
// $scrubber.find('.count').text(count);
|
||||
$scrubber.find('.description').text(this.description());
|
||||
$scrubber.toggleClass('disabled', this.disabled());
|
||||
|
||||
@ -350,16 +309,7 @@ export default class StreamScrubber extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
When the stream-content component begins loading posts at a certain
|
||||
index, we want our scrubber scrollbar to jump to that position.
|
||||
*/
|
||||
loadingIndex(index) {
|
||||
this.index(index);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
onresize(event) {
|
||||
onresize() {
|
||||
this.scrollListener.update(true);
|
||||
|
||||
// Adjust the height of the scrollbar so that it fills the height of
|
||||
@ -368,81 +318,68 @@ export default class StreamScrubber extends Component {
|
||||
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom')));
|
||||
}
|
||||
|
||||
onmousemove(event) {
|
||||
if (! this.handle) { return; }
|
||||
onmousedown() {
|
||||
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||
this.indexStart = this.index();
|
||||
this.dragging = true;
|
||||
this.props.stream.paused(true);
|
||||
$('body').css('cursor', 'move');
|
||||
}
|
||||
|
||||
onmousemove() {
|
||||
if (! this.dragging) { return; }
|
||||
|
||||
// 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.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||
var deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||
var deltaPercent = deltaPixels / this.$('.scrubber-scrollbar').outerHeight() * 100;
|
||||
var deltaIndex = deltaPercent / this.percentPerPost().index;
|
||||
var newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
|
||||
|
||||
this.index(Math.max(0, newIndex));
|
||||
this.renderScrollbar();
|
||||
|
||||
if (! this.$().is('.open')) {
|
||||
this.scrollToIndex(newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onmouseup(event) {
|
||||
if (!this.handle) { return; }
|
||||
onmouseup() {
|
||||
if (!this.dragging) { return; }
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.handle = null;
|
||||
this.dragging = false;
|
||||
$('body').css('cursor', '');
|
||||
|
||||
if (this.$().is('.open')) {
|
||||
this.scrollToIndex(this.index());
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
this.$().removeClass('open');
|
||||
|
||||
// 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(this.index());
|
||||
if (!this.props.streamContent.props.stream.findNearestToIndex(intIndex).post) {
|
||||
this.props.streamContent.goToIndex(intIndex);
|
||||
} else {
|
||||
this.props.streamContent.paused(false);
|
||||
}
|
||||
this.props.stream.goToIndex(intIndex);
|
||||
this.renderScrollbar(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(index) {
|
||||
var streamContent = this.props.streamContent;
|
||||
onclick(e) {
|
||||
// Calculate the index which we want to jump to based on the click position.
|
||||
|
||||
index = Math.min(index, this.count() - 1);
|
||||
// 1. Get the offset of the click from the top of the scrollbar, as a
|
||||
// percentage of the scrollbar's height.
|
||||
var $scrollbar = this.$('.scrubber-scrollbar');
|
||||
var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
|
||||
var offsetPercent = offsetPixels / $scrollbar.outerHeight() * 100;
|
||||
|
||||
// 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 = streamContent.findNearestToIndex(indexFloor);
|
||||
// 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($scrollbar.find('.scrubber-slider')[0].style.height) / 2;
|
||||
|
||||
// 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 - 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;
|
||||
}
|
||||
}
|
||||
// 3. Now we can convert the percentage into an index, and tell the stream-
|
||||
// content component to jump to that index.
|
||||
var offsetIndex = offsetPercent / this.percentPerPost().index;
|
||||
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
|
||||
this.props.stream.goToIndex(Math.floor(offsetIndex));
|
||||
this.index(offsetIndex);
|
||||
this.renderScrollbar(true);
|
||||
|
||||
// Remove the 'active' class from other gaps.
|
||||
streamContent.$().find('.gap').not($nearestItem).removeClass('active');
|
||||
|
||||
$('html, body').scrollTop(pos);
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
}
|
463
js/forum/src/components/post-stream.js
Normal file
463
js/forum/src/components/post-stream.js
Normal file
@ -0,0 +1,463 @@
|
||||
import Component from 'flarum/component';
|
||||
import ScrollListener from 'flarum/utils/scroll-listener';
|
||||
import PostLoading from 'flarum/components/post-loading';
|
||||
import anchorScroll from 'flarum/utils/anchor-scroll';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
|
||||
class PostStream extends mixin(Component, evented) {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.discussion = this.props.discussion;
|
||||
this.setup(this.props.includedPosts);
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
|
||||
this.paused = m.prop(false);
|
||||
|
||||
this.loadPageTimeouts = {};
|
||||
this.pagesLoading = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll to a post with a certain number.
|
||||
*/
|
||||
goToNumber(number, noAnimation) {
|
||||
this.paused(true);
|
||||
|
||||
var promise = this.loadNearNumber(number);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
m.redraw(true);
|
||||
|
||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll to a certain index within the discussion.
|
||||
*/
|
||||
goToIndex(index, backwards, noAnimation) {
|
||||
this.paused(true);
|
||||
|
||||
var promise = this.loadNearIndex(index);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
anchorScroll(this.$('.item:'+(backwards ? 'last' : 'first')), () => m.redraw(true));
|
||||
|
||||
this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll up to the first post in the discussion.
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll down to the last post in the discussion.
|
||||
*/
|
||||
goToLast() {
|
||||
return this.goToIndex(this.count() - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
Update the stream to reflect any posts that have been added/removed from the
|
||||
discussion.
|
||||
*/
|
||||
sync() {
|
||||
var addedPosts = this.discussion.addedPosts();
|
||||
if (addedPosts) addedPosts.forEach(this.pushPost.bind(this));
|
||||
this.discussion.pushData({links: {addedPosts: null}});
|
||||
|
||||
var removedPosts = this.discussion.removedPosts();
|
||||
if (removedPosts) removedPosts.forEach(this.removePost.bind(this));
|
||||
this.discussion.pushData({removedPosts: null});
|
||||
}
|
||||
|
||||
/**
|
||||
Add a post to the end of the stream. Nothing will be done if the end of the
|
||||
stream is not visible.
|
||||
*/
|
||||
pushPost(post) {
|
||||
if (this.visibleEnd == this.count() - 1) {
|
||||
this.posts.push(post);
|
||||
this.visibleEnd++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Search for and remove a specific post from the stream. Nothing will be done
|
||||
if the post is not visible.
|
||||
*/
|
||||
removePost(id) {
|
||||
this.posts.some((item, i) => {
|
||||
if (item && item.id() === id) {
|
||||
this.posts.splice(i, 1);
|
||||
this.visibleEnd--;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Get the total number of posts in the discussion.
|
||||
*/
|
||||
count() {
|
||||
return this.discussion.postIds().length;
|
||||
}
|
||||
|
||||
/**
|
||||
Make sure that the given index is not outside of the possible range of
|
||||
indexes in the discussion.
|
||||
*/
|
||||
sanitizeIndex(index) {
|
||||
return Math.max(0, Math.min(this.count(), index));
|
||||
}
|
||||
|
||||
/**
|
||||
Set up the stream with the given array of posts.
|
||||
*/
|
||||
setup(posts) {
|
||||
this.posts = posts;
|
||||
this.visibleStart = this.discussion.postIds().indexOf(posts[0].id());
|
||||
this.visibleEnd = this.visibleStart + posts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
Clear the stream and fill it with placeholder posts.
|
||||
*/
|
||||
clear(start, end) {
|
||||
this.visibleStart = start || 0;
|
||||
this.visibleEnd = end || this.constructor.loadCount;
|
||||
this.posts = [];
|
||||
for (var i = this.visibleStart; i < this.visibleEnd; i++) {
|
||||
this.posts.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Construct a vDOM containing an element for each post that is visible in the
|
||||
stream. Posts that have not been loaded will be rendered as placeholders.
|
||||
*/
|
||||
view() {
|
||||
function fadeIn(element, isInitialized, context) {
|
||||
if (!context.fadedIn) $(element).hide().fadeIn();
|
||||
context.fadedIn = true;
|
||||
}
|
||||
|
||||
return m('div.discussion-posts.posts', {config: this.onload.bind(this)},
|
||||
this.posts.map((post, i) => {
|
||||
var content;
|
||||
var attributes = {};
|
||||
attributes['data-index'] = attributes.key = this.visibleStart + i;
|
||||
|
||||
if (post) {
|
||||
var PostComponent = app.postComponentRegistry[post.contentType()];
|
||||
content = PostComponent ? PostComponent.component({post}) : '';
|
||||
attributes.config = fadeIn;
|
||||
attributes['data-time'] = post.time().toISOString();
|
||||
attributes['data-number'] = post.number();
|
||||
} else {
|
||||
content = PostLoading.component();
|
||||
}
|
||||
|
||||
return m('div.item', attributes, content);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
Store a reference to the component's DOM and begin listening for the
|
||||
window's scroll event.
|
||||
*/
|
||||
onload(element, isInitialized, context) {
|
||||
this.element(element);
|
||||
|
||||
if (isInitialized) { return; }
|
||||
|
||||
context.onunload = this.ondestroy.bind(this);
|
||||
|
||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
||||
// https://github.com/lhorie/mithril.js/issues/637
|
||||
setTimeout(() => this.scrollListener.start());
|
||||
}
|
||||
|
||||
/**
|
||||
Stop listening for the window's scroll event, and cancel outstanding
|
||||
timeouts.
|
||||
*/
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
When the window is scrolled, check if either extreme of the post stream is
|
||||
in the viewport, and if so, trigger loading the next/previous page.
|
||||
*/
|
||||
onscroll(top) {
|
||||
if (this.paused()) return;
|
||||
|
||||
var marginTop = this.getMarginTop();
|
||||
var viewportHeight = $(window).height() - marginTop;
|
||||
var viewportTop = top + marginTop;
|
||||
var loadAheadDistance = viewportHeight;
|
||||
|
||||
if (this.visibleStart > 0) {
|
||||
var $item = this.$('.item[data-index='+this.visibleStart+']');
|
||||
|
||||
if ($item.offset().top > viewportTop - loadAheadDistance) {
|
||||
this.loadPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.visibleEnd < this.count()) {
|
||||
var $item = this.$('.item[data-index='+(this.visibleEnd - 1)+']');
|
||||
|
||||
if ($item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||
this.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
var start = this.visibleEnd;
|
||||
var end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount);
|
||||
|
||||
for (var i = start; i < end; i++) {
|
||||
this.posts.push(null);
|
||||
}
|
||||
|
||||
// If the posts which are two pages back from the page we're currently
|
||||
// loading still haven't loaded, we can assume that the user is scrolling
|
||||
// pretty fast. Thus, we will unload them.
|
||||
var twoPagesAway = start - this.constructor.loadCount * 2;
|
||||
if (twoPagesAway >= 0 && !this.posts[twoPagesAway - this.visibleStart]) {
|
||||
this.posts.splice(0, twoPagesAway + this.constructor.loadCount - this.visibleStart);
|
||||
this.visibleStart = twoPagesAway + this.constructor.loadCount;
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
}
|
||||
|
||||
this.loadPage(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
var end = this.visibleStart;
|
||||
var start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount);
|
||||
|
||||
for (var i = start; i < end; i++) {
|
||||
this.posts.unshift(null);
|
||||
}
|
||||
|
||||
// If the posts which are two pages back from the page we're currently
|
||||
// loading still haven't loaded, we can assume that the user is scrolling
|
||||
// pretty fast. Thus, we will unload them.
|
||||
var twoPagesAway = start + this.constructor.loadCount * 2;
|
||||
if (twoPagesAway <= this.count() && !this.posts[twoPagesAway - this.visibleStart]) {
|
||||
this.posts.splice(twoPagesAway - this.visibleStart);
|
||||
this.visibleEnd = twoPagesAway;
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
}
|
||||
|
||||
this.loadPage(start, end, true);
|
||||
}
|
||||
|
||||
/**
|
||||
Load a page of posts into the stream and redraw.
|
||||
*/
|
||||
loadPage(start, end, backwards) {
|
||||
var redraw = () => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
var anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(this.$('.item[data-index='+anchorIndex+']'), () => m.redraw(true));
|
||||
|
||||
this.unpause();
|
||||
};
|
||||
redraw();
|
||||
|
||||
this.pagesLoading++;
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
redraw();
|
||||
this.pagesLoading--;
|
||||
});
|
||||
}, this.pagesLoading ? 1000 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
Load and inject the specified range of posts into the stream, without
|
||||
clearing it.
|
||||
*/
|
||||
loadRange(start, end) {
|
||||
return app.store.find('posts', this.discussion.postIds().slice(start, end)).then(posts => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
this.posts.splice.apply(this.posts, [start - this.visibleStart, end - start].concat(posts));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Clear the stream and load posts near a certain number. Returns a promise. If
|
||||
the post with the given number is already loaded, the promise will be
|
||||
resolved immediately.
|
||||
*/
|
||||
loadNearNumber(number) {
|
||||
if (this.posts.some(post => post.number() == number)) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
this.clear();
|
||||
|
||||
return app.store.find('posts', {
|
||||
discussions: this.discussion.id(),
|
||||
near: number
|
||||
}).then(this.setup.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
Clear the stream and load posts near a certain index. A page of posts
|
||||
surrounding the given index will be loaded. Returns a promise. If the given
|
||||
index is already loaded, the promise will be resolved immediately.
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
var start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
||||
var end = start + this.constructor.loadCount;
|
||||
|
||||
this.clear(start, end);
|
||||
|
||||
var ids = this.discussion.postIds().slice(start, end);
|
||||
|
||||
return app.store.find('posts', ids).then(this.setup.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
Work out which posts (by number) are currently visible in the viewport, and
|
||||
fire an event with the information.
|
||||
*/
|
||||
calculatePosition() {
|
||||
var marginTop = this.getMarginTop();
|
||||
var $window = $(window);
|
||||
var viewportHeight = $window.height() - marginTop;
|
||||
var scrollTop = $window.scrollTop() + marginTop;
|
||||
var startNumber;
|
||||
var endNumber;
|
||||
|
||||
this.$('.item').each(function() {
|
||||
var $item = $(this);
|
||||
var top = $item.offset().top;
|
||||
var height = $item.outerHeight(true);
|
||||
|
||||
if (top + height > scrollTop) {
|
||||
if (!startNumber) {
|
||||
startNumber = $item.data('number');
|
||||
}
|
||||
|
||||
if (top + height < scrollTop + viewportHeight) {
|
||||
endNumber = $item.data('number');
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (startNumber) {
|
||||
this.trigger('positionChanged', startNumber || 1, endNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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() {
|
||||
return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'));
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to a certain post by number and 'flash' it.
|
||||
*/
|
||||
scrollToNumber(number, noAnimation) {
|
||||
var $item = this.$('.item[data-number='+number+']');
|
||||
|
||||
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to a certain post by index.
|
||||
*/
|
||||
scrollToIndex(index, noAnimation) {
|
||||
var $item = this.$('.item[data-index='+index+']');
|
||||
|
||||
return this.scrollToItem($item, noAnimation, true);
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to the given post.
|
||||
*/
|
||||
scrollToItem($item, noAnimation, force) {
|
||||
var $container = $('html, body').stop(true);
|
||||
|
||||
if ($item.length) {
|
||||
var itemTop = $item.offset().top - this.getMarginTop();
|
||||
var itemBottom = itemTop + $item.height();
|
||||
var scrollTop = $(document).scrollTop();
|
||||
var scrollBottom = scrollTop + $(window).height();
|
||||
|
||||
// If the item is already in the viewport, we may not need to scroll.
|
||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
var scrollTop = $item.is(':first-child') ? 0 : itemTop;
|
||||
|
||||
if (noAnimation) {
|
||||
$container.scrollTop(scrollTop);
|
||||
} else if (scrollTop !== $(document).scrollTop()) {
|
||||
$container.animate({scrollTop: scrollTop}, 'fast');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $container.promise();
|
||||
}
|
||||
|
||||
/**
|
||||
'Flash' the given post, drawing the user's attention to it.
|
||||
*/
|
||||
flashItem($item) {
|
||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||
}
|
||||
|
||||
/**
|
||||
Resume the stream's ability to auto-load posts on scroll.
|
||||
*/
|
||||
unpause() {
|
||||
this.paused(false);
|
||||
this.scrollListener.update(true);
|
||||
this.trigger('unpaused');
|
||||
}
|
||||
}
|
||||
|
||||
PostStream.loadCount = 20;
|
||||
|
||||
export default PostStream;
|
@ -67,7 +67,7 @@ export default class ReplyComposer extends ComposerBody {
|
||||
// If we're currently viewing the discussion which this reply was made
|
||||
// in, then we can add the post to the end of the post stream.
|
||||
if (app.current && app.current.discussion && app.current.discussion().id() === discussion.id()) {
|
||||
app.current.stream().addPostToEnd(post);
|
||||
app.current.stream.pushPost(post);
|
||||
m.route(app.route('discussion.near', {
|
||||
id: discussion.id(),
|
||||
slug: discussion.slug(),
|
||||
|
@ -1,360 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import StreamItem from 'flarum/components/stream-item';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import ScrollListener from 'flarum/utils/scroll-listener';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
export default class StreamContent extends mixin(Component, evented) {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.loaded = () => this.props.stream.loadedCount();
|
||||
this.paused = m.prop(false);
|
||||
this.active = () => this.loaded() && !this.paused();
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
|
||||
this.on('loadingIndex', this.loadingIndex.bind(this));
|
||||
this.on('loadedIndex', this.loadedIndex.bind(this));
|
||||
|
||||
this.on('loadingNumber', this.loadingNumber.bind(this));
|
||||
this.on('loadedNumber', this.loadedNumber.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
view() {
|
||||
var stream = this.props.stream;
|
||||
|
||||
return m('div', {className: 'stream '+(this.props.className || ''), config: this.onload.bind(this)},
|
||||
stream ? stream.content.map(item => StreamItem.component({
|
||||
key: item.start+'-'+item.end,
|
||||
item: item,
|
||||
loadRange: stream.loadRange.bind(stream),
|
||||
ondelete: this.ondelete.bind(this)
|
||||
}))
|
||||
: LoadingIndicator.component());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
onload(element, isInitialized, context) {
|
||||
this.element(element);
|
||||
|
||||
if (isInitialized) { return; }
|
||||
|
||||
context.onunload = this.ondestroy.bind(this);
|
||||
this.scrollListener.start();
|
||||
}
|
||||
|
||||
ondelete(post) {
|
||||
this.props.stream.removePost(post.id());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.positionChangedTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
onscroll(top) {
|
||||
if (!this.active()) { return; }
|
||||
|
||||
var $items = this.$('.item');
|
||||
|
||||
var marginTop = this.getMarginTop();
|
||||
var $window = $(window);
|
||||
var viewportHeight = $window.height() - marginTop;
|
||||
var scrollTop = top + marginTop;
|
||||
var loadAheadDistance = 300;
|
||||
var startNumber;
|
||||
var 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();
|
||||
|
||||
// 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 first = $this.is(':first-child');
|
||||
var last = $this.is(':last-child');
|
||||
var item = $this[0].instance.props.item;
|
||||
if ((first || last) && !item.loading) {
|
||||
item.direction = first ? 'up' : 'down';
|
||||
$this[0].instance.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.
|
||||
clearTimeout(this.positionChangedTimeout);
|
||||
this.positionChangedTimeout = setTimeout(() => this.props.positionChanged(startNumber || 1, endNumber), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
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() {
|
||||
return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'));
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to a certain post by number (or the gap which we think the
|
||||
post is in) and highlight it.
|
||||
*/
|
||||
scrollToNumber(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.)
|
||||
*/
|
||||
scrollToIndex(index, noAnimation) {
|
||||
var $item = this.findNearestToIndex(index);
|
||||
return this.scrollToItem($item, noAnimation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
scrollToItem($item, noAnimation) {
|
||||
var $container = $('html, body').stop(true);
|
||||
if ($item.length) {
|
||||
var itemTop = $item.offset().top - this.getMarginTop();
|
||||
var itemBottom = itemTop + $item.height();
|
||||
var scrollTop = $(document).scrollTop();
|
||||
var scrollBottom = scrollTop + $(window).height();
|
||||
|
||||
// If the item is already in the viewport, just flash it, we don't need to
|
||||
// scroll anywhere.
|
||||
if (itemTop > scrollTop && itemBottom < scrollBottom) {
|
||||
this.flashItem($item);
|
||||
} else {
|
||||
var scrollTop = $item.is(':first-child') ? 0 : itemTop;
|
||||
if (noAnimation) {
|
||||
$container.scrollTop(scrollTop);
|
||||
} else if (scrollTop !== $(document).scrollTop()) {
|
||||
$container.animate({scrollTop: scrollTop}, 'fast', this.flashItem.bind(this, $item));
|
||||
} else {
|
||||
this.flashItem($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $container.promise();
|
||||
}
|
||||
|
||||
flashItem($item) {
|
||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||
}
|
||||
|
||||
/**
|
||||
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(number) {
|
||||
var $nearestItem = $();
|
||||
this.$('.item').each(function() {
|
||||
var $this = $(this);
|
||||
if ($this.data('number') > number) {
|
||||
return false;
|
||||
}
|
||||
$nearestItem = $this;
|
||||
});
|
||||
return $nearestItem;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
findNearestToIndex(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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadingIndex(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.paused(true);
|
||||
this.scrollToIndex(index, noAnimation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadedIndex(index, noAnimation) {
|
||||
m.redraw(true);
|
||||
|
||||
// The post at this index has been loaded. After we scroll to this post,
|
||||
// we want to resume scroll events.
|
||||
this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadingNumber(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.paused(true);
|
||||
if (this.$()) {
|
||||
this.scrollToNumber(number, noAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadedNumber(number, noAnimation) {
|
||||
m.redraw(true);
|
||||
|
||||
// The post with this number has been loaded. After we scroll to this
|
||||
// post, we want to resume scroll events.
|
||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
unpause() {
|
||||
this.paused(false);
|
||||
this.scrollListener.update(true);
|
||||
this.trigger('unpaused');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToNumber(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 promise = this.props.stream.loadNearNumber(number);
|
||||
m.redraw();
|
||||
|
||||
return promise.then(() => this.trigger('loadedNumber', number, noAnimation));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToIndex(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 promise = this.props.stream.loadNearIndex(index, backwards);
|
||||
m.redraw();
|
||||
|
||||
return promise.then(() => this.trigger('loadedIndex', index, noAnimation));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToLast() {
|
||||
var promise = this.goToIndex(this.props.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.
|
||||
var items = this.props.stream.content;
|
||||
if (!items[items.length - 1].post) {
|
||||
promise.then(() => $('html, body').stop(true).scrollTop($('body').height()));
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
@ -1,112 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import classList from 'flarum/utils/class-list';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
|
||||
export default class StreamItem extends Component {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.element = m.prop();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
view() {
|
||||
var component = this;
|
||||
var item = this.props.item;
|
||||
|
||||
var gap = !item.post;
|
||||
var direction = item.direction;
|
||||
var loading = item.loading;
|
||||
var count = item.end - item.start + 1;
|
||||
var classes = { item: true, gap, loading, direction };
|
||||
|
||||
var attributes = {
|
||||
className: classList(classes),
|
||||
config: this.element,
|
||||
'data-start': item.start,
|
||||
'data-end': item.end
|
||||
};
|
||||
if (!gap) {
|
||||
attributes['data-time'] = item.post.time().toISOString();
|
||||
attributes['data-number'] = item.post.number();
|
||||
} else {
|
||||
attributes['config'] = (element) => {
|
||||
this.element(element);
|
||||
element.instance = this;
|
||||
};
|
||||
attributes['onclick'] = this.load.bind(this);
|
||||
attributes['onmouseenter'] = function(e) {
|
||||
if (!item.loading) {
|
||||
var $this = $(this);
|
||||
var up = e.clientY > $this.offset().top - $(document).scrollTop() + $this.outerHeight(true) / 2;
|
||||
$this.removeClass('up down').addClass(item.direction = up ? 'up' : 'down');
|
||||
}
|
||||
m.redraw.strategy('none');
|
||||
};
|
||||
}
|
||||
|
||||
var content;
|
||||
if (gap) {
|
||||
content = m('span', loading ? LoadingIndicator.component() : count+' more post'+(count !== 1 ? 's' : ''));
|
||||
} else {
|
||||
var PostComponent = app.postComponentRegistry[item.post.contentType()];
|
||||
if (PostComponent) {
|
||||
content = PostComponent.component({post: item.post, ondelete: this.props.ondelete});
|
||||
}
|
||||
}
|
||||
|
||||
return m('div', attributes, content);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
load() {
|
||||
var item = this.props.item;
|
||||
|
||||
// If this item is not a gap, or if we're already loading its posts,
|
||||
// then we don't need to do anything.
|
||||
if (item.post || item.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 = item.direction === 'up' ? 'nextAll' : 'prevAll';
|
||||
var anchor = this.$()[siblingFunc]('.item:first');
|
||||
|
||||
// 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.props.loadRange(item.start, item.end, item.direction === 'up').then(function() {
|
||||
// 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.
|
||||
if (anchor.length) {
|
||||
var scrollOffset = anchor.offset().top - $(document).scrollTop();
|
||||
}
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
// 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.
|
||||
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
|
||||
});
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
}
|
@ -2,8 +2,6 @@ import Component from 'flarum/component';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import IndexPage from 'flarum/components/index-page';
|
||||
import DiscussionList from 'flarum/components/discussion-list';
|
||||
import StreamContent from 'flarum/components/stream-content';
|
||||
import StreamScrubber from 'flarum/components/stream-scrubber';
|
||||
import UserCard from 'flarum/components/user-card';
|
||||
import ReplyComposer from 'flarum/components/reply-composer';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
|
@ -10,7 +10,7 @@ export default function(app) {
|
||||
Discussion.prototype.replyAction = function(goToLast, forceRefresh) {
|
||||
if (app.session.user() && this.canReply()) {
|
||||
if (goToLast && app.current.discussion && app.current.discussion().id() === this.id()) {
|
||||
app.current.streamContent.goToLast();
|
||||
app.current.stream.goToLast();
|
||||
}
|
||||
var component = app.composer.component;
|
||||
if (!(component instanceof ReplyComposer) || component.props.discussion !== this || component.props.user !== app.session.user() || forceRefresh) {
|
||||
@ -47,7 +47,7 @@ export default function(app) {
|
||||
if (title && title !== currentTitle) {
|
||||
this.save({title}).then(discussion => {
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
app.current.stream().sync();
|
||||
app.current.stream.sync();
|
||||
}
|
||||
m.redraw();
|
||||
});
|
||||
|
@ -1,170 +0,0 @@
|
||||
export default class PostStream {
|
||||
constructor(discussion) {
|
||||
this.discussion = discussion
|
||||
this.ids = this.discussion.data().links.posts.linkage.map((link) => link.id)
|
||||
|
||||
var item = this.makeItem(0, this.ids.length - 1)
|
||||
item.loading = true
|
||||
this.content = [item]
|
||||
|
||||
this.postLoadCount = 20
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.ids.length;
|
||||
}
|
||||
|
||||
loadedCount() {
|
||||
return this.content.filter((item) => item.post).length;
|
||||
}
|
||||
|
||||
loadRange(start, end, backwards) {
|
||||
// Find the appropriate gap objects in the post stream. When we find
|
||||
// one, we will turn on its loading flag.
|
||||
this.content.forEach(function(item) {
|
||||
if (!item.post && ((item.start >= start && item.start <= end) || (item.end >= start && item.end <= end))) {
|
||||
item.loading = true
|
||||
item.direction = backwards ? 'up' : 'down'
|
||||
}
|
||||
});
|
||||
|
||||
// Get a list of post numbers that we'll want to retrieve. If there are
|
||||
// more post IDs than the number of posts we want to load, then take a
|
||||
// slice of the array in the appropriate direction.
|
||||
var ids = this.ids.slice(start, end + 1);
|
||||
var limit = this.postLoadCount
|
||||
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit)
|
||||
|
||||
return this.loadPosts(ids)
|
||||
}
|
||||
|
||||
loadPosts(ids) {
|
||||
if (!ids.length) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
return app.store.find('posts', ids).then(this.addPosts.bind(this));
|
||||
}
|
||||
|
||||
loadNearNumber(number) {
|
||||
// Find the item in the post stream which is nearest to this number. If
|
||||
// it turns out the be the actual post we're trying to load, then we can
|
||||
// return a resolved promise (i.e. we don't need to make an API
|
||||
// request.) Or, if it's a gap, we'll switch on its loading flag.
|
||||
var item = this.findNearestToNumber(number)
|
||||
if (item) {
|
||||
if (item.post && item.post.number() === number) {
|
||||
return m.deferred().resolve([item.post]).promise;
|
||||
} else if (!item.post) {
|
||||
item.direction = 'down'
|
||||
item.loading = true;
|
||||
}
|
||||
}
|
||||
|
||||
var stream = this
|
||||
return app.store.find('posts', {
|
||||
discussions: this.discussion.id(),
|
||||
near: number,
|
||||
count: this.postLoadCount
|
||||
}).then(this.addPosts.bind(this))
|
||||
}
|
||||
|
||||
loadNearIndex(index, backwards) {
|
||||
// Find the item in the post stream which is nearest to this index. If
|
||||
// it turns out the be the actual post we're trying to load, then we can
|
||||
// return a resolved promise (i.e. we don't need to make an API
|
||||
// request.) Or, if it's a gap, we'll switch on its loading flag.
|
||||
var item = this.findNearestToIndex(index)
|
||||
if (item) {
|
||||
if (item.post) {
|
||||
return m.deferred().resolve([item.post]).promise;
|
||||
}
|
||||
return this.loadRange(Math.max(item.start, index - this.postLoadCount / 2), item.end, backwards);
|
||||
}
|
||||
}
|
||||
|
||||
addPosts(posts) {
|
||||
posts.forEach(this.addPost.bind(this))
|
||||
}
|
||||
|
||||
addPost(post) {
|
||||
var index = this.ids.indexOf(post.id())
|
||||
var content = this.content
|
||||
var makeItem = this.makeItem
|
||||
|
||||
// Here we loop through each item in the post stream, and find the gap
|
||||
// in which this post should be situated. When we find it, we can replace
|
||||
// it with the post, and new gaps either side if appropriate.
|
||||
content.some(function(item, i) {
|
||||
if (item.start <= index && item.end >= index) {
|
||||
var newItems = []
|
||||
if (item.start < index) {
|
||||
newItems.push(makeItem(item.start, index - 1))
|
||||
}
|
||||
newItems.push(makeItem(index, index, post))
|
||||
if (item.end > index) {
|
||||
newItems.push(makeItem(index + 1, item.end))
|
||||
}
|
||||
var args = [i, 1].concat(newItems);
|
||||
[].splice.apply(content, args)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// @todo rename to pushPost
|
||||
addPostToEnd(post) {
|
||||
if (this.ids.indexOf(post.id()) === -1) {
|
||||
var index = this.ids.length;
|
||||
this.ids.push(post.id());
|
||||
this.content.push(this.makeItem(index, index, post));
|
||||
}
|
||||
}
|
||||
|
||||
removePost(id) {
|
||||
this.ids.splice(this.ids.indexOf(id), 1);
|
||||
this.content.some((item, i) => {
|
||||
if (item.post && item.post.id() === id) {
|
||||
this.content.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sync() {
|
||||
var discussion = this.discussion;
|
||||
|
||||
var addedPosts = discussion.addedPosts();
|
||||
addedPosts && addedPosts.forEach(this.addPostToEnd.bind(this));
|
||||
discussion.pushData({links: {addedPosts: null}});
|
||||
|
||||
var removedPosts = discussion.removedPosts();
|
||||
removedPosts && removedPosts.forEach(this.removePost.bind(this));
|
||||
discussion.pushData({removedPosts: null});
|
||||
}
|
||||
|
||||
makeItem(start, end, post) {
|
||||
var item = {start, end}
|
||||
if (post) {
|
||||
item.post = post
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
findNearestTo(index, property) {
|
||||
var nearestItem
|
||||
this.content.some(function(item) {
|
||||
if (property(item) > index) { return true }
|
||||
nearestItem = item
|
||||
})
|
||||
return nearestItem
|
||||
}
|
||||
|
||||
findNearestToNumber(number) {
|
||||
return this.findNearestTo(number, (item) => item.post && item.post.number())
|
||||
}
|
||||
|
||||
findNearestToIndex(index) {
|
||||
return this.findNearestTo(index, (item) => item.start)
|
||||
}
|
||||
}
|
@ -41,7 +41,7 @@ export default class Model {
|
||||
for (var i in data) {
|
||||
if (i === 'links') {
|
||||
oldData[i] = oldData[i] || {};
|
||||
for (var j in newData[i]) {
|
||||
for (var j in currentData[i]) {
|
||||
oldData[i][j] = currentData[i][j];
|
||||
}
|
||||
} else {
|
||||
|
@ -37,6 +37,7 @@ Discussion.prototype.commentsCount = Model.prop('commentsCount');
|
||||
Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => commentsCount - 1);
|
||||
|
||||
Discussion.prototype.posts = Model.many('posts');
|
||||
Discussion.prototype.postIds = function() { return this.data().links.posts.linkage.map((link) => link.id); };
|
||||
Discussion.prototype.relevantPosts = Model.many('relevantPosts');
|
||||
Discussion.prototype.addedPosts = Model.many('addedPosts');
|
||||
Discussion.prototype.removedPosts = Model.prop('removedPosts');
|
||||
|
7
js/lib/utils/anchor-scroll.js
Normal file
7
js/lib/utils/anchor-scroll.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default function anchorScroll(element, callback) {
|
||||
var scrollAnchor = $(element).offset().top - $(window).scrollTop();
|
||||
|
||||
callback();
|
||||
|
||||
$(window).scrollTop($(element).offset().top - scrollAnchor);
|
||||
}
|
@ -73,67 +73,25 @@
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
}
|
||||
.gap {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
border: 2px dashed @fl-body-bg;
|
||||
background: #f2f2f2;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.transition(padding 0.2s);
|
||||
|
||||
&:hover, &.loading, &.active {
|
||||
padding: 50px 0;
|
||||
|
||||
&.up:before, &.down:after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&.loading {
|
||||
.transition(none);
|
||||
}
|
||||
&:before, &:after {
|
||||
font-family: 'FontAwesome';
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
height: 15px;
|
||||
color: #aaa;
|
||||
}
|
||||
&.up:before {
|
||||
content: '\f077';
|
||||
margin-top: -25px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
&.down:after {
|
||||
content: '\f078';
|
||||
margin-bottom: -25px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
&:only-child {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: @fl-primary-color;
|
||||
&:before, &:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
& .loading-indicator {
|
||||
color: #aaa;
|
||||
}
|
||||
@keyframes blink {
|
||||
0% {opacity: 0.5}
|
||||
50% {opacity: 1}
|
||||
100% {opacity: 0.5}
|
||||
}
|
||||
.loading-post {
|
||||
animation: blink 1s linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.fake-text {
|
||||
background: @fl-body-secondary-color;
|
||||
height: 12px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
border-radius: @border-radius-base;
|
||||
|
||||
@media @phone {
|
||||
.gap {
|
||||
margin-left: -15px;
|
||||
margin-right: -15px;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
.post-header & {
|
||||
height: 16px;
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,18 +133,18 @@
|
||||
background: @fl-primary-color;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes pulsate {
|
||||
@keyframes pulsate {
|
||||
0% {transform: scale(1)}
|
||||
50% {transform: scale(1.02)}
|
||||
100% {transform: scale(1)}
|
||||
}
|
||||
.item.pulsate {
|
||||
-webkit-animation: pulsate 1s ease-in-out;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
animation: pulsate 1s ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.item.flash {
|
||||
-webkit-animation: pulsate 0.2s ease-in-out;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
animation: pulsate 0.2s ease-in-out;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
.post-header {
|
||||
margin-bottom: 10px;
|
||||
|
@ -13,11 +13,11 @@
|
||||
.define-body-variables(@fl-dark-mode);
|
||||
.define-body-variables(false) {
|
||||
@fl-body-primary-color: @fl-primary-color;
|
||||
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 95%);
|
||||
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 93%);
|
||||
|
||||
@fl-body-bg: #fff;
|
||||
@fl-body-color: #444;
|
||||
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 68%);
|
||||
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 66%);
|
||||
@fl-body-muted-more-color: #bbb;
|
||||
@fl-shadow-color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user