1
0
mirror of https://github.com/flarum/core.git synced 2025-08-08 09:26:34 +02:00

WIP: Re-work stream, scrubber and state

Mostly, this tries to move common logic up to the DiscussionPage as the
lowest common ancestor of these components sharing certain state.

It's still messy, though. :-/
This commit is contained in:
Franz Liedke
2020-08-01 02:45:23 +02:00
parent 6f6a09d7c4
commit 53582ab999
4 changed files with 244 additions and 215 deletions

View File

@@ -9,6 +9,7 @@ import listItems from '../../common/helpers/listItems';
import DiscussionControls from '../utils/DiscussionControls';
import DiscussionList from './DiscussionList';
import PostStreamState from '../states/PostStreamState';
import ScrollListener from '../../common/utils/ScrollListener';
/**
* The `DiscussionPage` component displays a whole discussion page, including
@@ -32,6 +33,8 @@ export default class DiscussionPage extends Page {
*/
this.near = m.route.param('near') || 0;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.load();
// If the discussion list has been loaded, then we'll enable the pane (and
@@ -113,7 +116,6 @@ export default class DiscussionPage extends Page {
discussion,
stream: this.stream,
targetPost: this.stream.targetPost,
onPositionChange: this.positionChanged.bind(this),
})}
</div>
</div>,
@@ -124,12 +126,18 @@ export default class DiscussionPage extends Page {
);
}
config(...args) {
super.config(...args);
config(isInitialized, context) {
super.config(isInitialized, context);
if (this.discussion) {
app.setTitle(this.discussion.title());
}
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
@@ -207,6 +215,8 @@ export default class DiscussionPage extends Page {
app.current.set('discussion', discussion);
app.current.set('stream', this.stream);
this.scrollListener.start();
}
/**
@@ -274,6 +284,10 @@ export default class DiscussionPage extends Page {
PostStreamScrubber.component({
stream: this.stream,
className: 'App-titleControl',
onNavigate: this.stream.goToIndex.bind(this.stream),
count: this.stream.count(),
paused: this.stream.paused,
...this.scrubberProps(),
}),
-100
);
@@ -281,6 +295,84 @@ export default class DiscussionPage extends Page {
return items;
}
/**
* 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.
*
* @param {number} top
*/
onscroll(top = window.pageYOffset) {
if (this.stream.paused) return;
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
const loadAheadDistance = 300;
if (this.stream.visibleStart > 0) {
const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']');
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
this.stream.loadPrevious();
}
}
if (this.stream.visibleEnd < this.stream.count()) {
const $item = this.$('.PostStream-item[data-index=' + (this.stream.visibleEnd - 1) + ']');
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.stream.loadNext();
}
}
// Throttle calculation of our position (start/end numbers of posts in the
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
// Update numbers for the scrubber if necessary
m.redraw();
}
/**
* Work out which posts (by number) are currently visible in the viewport, and
* fire an event with the information.
*/
calculatePosition(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const $window = $(window);
const viewportHeight = $window.height() - marginTop;
const scrollTop = $window.scrollTop() + marginTop;
const viewportTop = top + marginTop;
let startNumber;
let endNumber;
this.$('.PostStream-item').each(function () {
const $item = $(this);
const top = $item.offset().top;
const height = $item.outerHeight(true);
const visibleTop = Math.max(0, viewportTop - top);
const threeQuartersVisible = visibleTop / height < 0.75;
const coversQuarterOfViewport = (height - visibleTop) / viewportHeight > 0.25;
if (startNumber === undefined && (threeQuartersVisible || coversQuarterOfViewport)) {
startNumber = $item.data('number');
}
if (top + height > scrollTop) {
if (top + height < scrollTop + viewportHeight) {
if ($item.data('number')) {
endNumber = $item.data('number');
}
} else return false;
}
});
if (startNumber) {
this.positionChanged(startNumber || 1, endNumber);
}
}
/**
* When the posts that are visible in the post stream change (i.e. the user
* scrolls up or down), then we update the URL and mark the posts as read.
@@ -307,4 +399,73 @@ export default class DiscussionPage extends Page {
m.redraw();
}
}
scrubberProps(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + 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.
const $items = this.$('.PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0;
let 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 () {
const $this = $(this);
const top = $this.offset().top;
const height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// one. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < viewportTop) {
return true;
}
if (top > viewportTop + viewportHeight) {
return false;
}
// Work out how many pixels of this item are visible inside the viewport.
// Then add the proportion of this item's total height to the index.
const visibleTop = Math.max(0, viewportTop - top);
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop;
if (top <= viewportTop) {
index = parseFloat($this.data('index')) + visibleTop / height;
}
if (visiblePost > 0) {
visible += visiblePost / height;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
const time = $this.data('time');
if (time) period = time;
});
return {
index: index + 1,
visible: visible || 1,
description: period && dayjs(period).format('MMMM YYYY'),
};
}
/**
* 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.
*
* @return {Integer}
*/
getMarginTop() {
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
}
}

View File

@@ -1,5 +1,4 @@
import Component from '../../common/Component';
import ScrollListener from '../../common/utils/ScrollListener';
import PostLoading from './LoadingPost';
import ReplyPlaceholder from './ReplyPlaceholder';
import Button from '../../common/components/Button';
@@ -13,14 +12,11 @@ import Button from '../../common/components/Button';
* - `discussion`
* - `stream`
* - `targetPost`
* - `onPositionChange`
*/
export default class PostStream extends Component {
init() {
this.discussion = this.props.discussion;
this.stream = this.props.stream;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
}
view() {
@@ -103,24 +99,7 @@ export default class PostStream extends Component {
}
config(isInitialized, context) {
this.triggerScroll();
if (isInitialized) return;
// This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
* Start scrolling, if appropriate, to a newly-targeted post.
*/
triggerScroll() {
// Start scrolling, if appropriate, to a newly-targeted post.
if (!this.props.targetPost) return;
const oldTarget = this.prevTarget;
@@ -141,140 +120,6 @@ export default class PostStream extends Component {
this.prevTarget = newTarget;
}
/**
* 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.
*
* @param {Integer} top
*/
onscroll(top = window.pageYOffset) {
if (this.stream.paused) return;
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
const loadAheadDistance = 300;
if (this.stream.visibleStart > 0) {
const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']');
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
this.stream.loadPrevious();
}
}
if (this.stream.visibleEnd < this.stream.count()) {
const $item = this.$('.PostStream-item[data-index=' + (this.stream.visibleEnd - 1) + ']');
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.stream.loadNext();
}
}
// Throttle calculation of our position (start/end numbers of posts in the
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
this.updateScrubber(top);
}
updateScrubber(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + 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.
const $items = this.$('.PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0;
let 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 () {
const $this = $(this);
const top = $this.offset().top;
const height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// one. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < viewportTop) {
return true;
}
if (top > viewportTop + viewportHeight) {
return false;
}
// Work out how many pixels of this item are visible inside the viewport.
// Then add the proportion of this item's total height to the index.
const visibleTop = Math.max(0, viewportTop - top);
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop;
if (top <= viewportTop) {
index = parseFloat($this.data('index')) + visibleTop / height;
}
if (visiblePost > 0) {
visible += visiblePost / height;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
const time = $this.data('time');
if (time) period = time;
});
this.stream.index = index + 1;
this.stream.visible = visible;
if (period) this.stream.description = dayjs(period).format('MMMM YYYY');
}
/**
* Work out which posts (by number) are currently visible in the viewport, and
* fire an event with the information.
*/
calculatePosition(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const $window = $(window);
const viewportHeight = $window.height() - marginTop;
const scrollTop = $window.scrollTop() + marginTop;
const viewportTop = top + marginTop;
let startNumber;
let endNumber;
this.$('.PostStream-item').each(function () {
const $item = $(this);
const top = $item.offset().top;
const height = $item.outerHeight(true);
const visibleTop = Math.max(0, viewportTop - top);
const threeQuartersVisible = visibleTop / height < 0.75;
const coversQuarterOfViewport = (height - visibleTop) / viewportHeight > 0.25;
if (startNumber === undefined && (threeQuartersVisible || coversQuarterOfViewport)) {
startNumber = $item.data('number');
}
if (top + height > scrollTop) {
if (top + height < scrollTop + viewportHeight) {
if ($item.data('number')) {
endNumber = $item.data('number');
}
} else return false;
}
});
if (startNumber) {
this.props.onPositionChange(startNumber || 1, endNumber, startNumber);
}
}
/**
* 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.
@@ -351,15 +196,7 @@ export default class PostStream extends Component {
}
}
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
this.updateScrubber();
const index = $item.data('index');
m.redraw(true);
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
$(window).scrollTop(scroll);
this.calculatePosition();
this.stream.paused = false;
});
return $container.promise();
}
/**

View File

@@ -1,7 +1,8 @@
import Component from '../../common/Component';
import icon from '../../common/helpers/icon';
import formatNumber from '../../common/utils/formatNumber';
import ScrollListener from '../../common/utils/ScrollListener';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import formatNumber from '../../common/utils/formatNumber';
/**
* The `PostStreamScrubber` component displays a scrubber which can be used to
@@ -11,27 +12,40 @@ import ScrollListener from '../../common/utils/ScrollListener';
*
* - `stream`
* - `className`
* - `onNavigate`
* - `count`
* - `paused`
* - `index`
* - `visible`
* - `description`
*/
export default class PostStreamScrubber extends Component {
init() {
this.stream = this.props.stream;
this.handlers = {};
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
this.scrollListener = new ScrollListener(this.renderScrollbar.bind(this, { fromScroll: true, forceHeightChange: true }));
// Create a subtree retainer that will always cache the subtree after the
// initial draw. We render parts of the scrubber using this because we
// modify their DOM directly, and do not want Mithril messing around with
// our changes.
this.subtree = new SubtreeRetainer(() => true);
}
view() {
const count = this.stream.count();
const retain = this.subtree.retain();
const { count, index, visible } = this.props;
const unreadCount = this.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.props.index, unreadCount) / count : 0;
// Index is left blank for performance reasons, it is filled in in updateScubberValues
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
index: <span className="Scrubber-index"></span>,
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(index + visible), count))}</span>,
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
});
const unreadCount = this.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
function styleUnread(element, isInitialized, context) {
const $element = $(element);
const newStyle = {
@@ -47,11 +61,9 @@ export default class PostStreamScrubber extends Component {
context.oldStyle = newStyle;
}
const classNames = ['PostStreamScrubber', 'Dropdown'];
if (this.props.className) classNames.push(this.props.className);
return (
<div className={classNames.join(' ')}>
<div className={'PostStreamScrubber Dropdown ' + (this.disabled() ? 'disabled ' : '') + (this.props.className || '')}>
<button className="Button Dropdown-toggle" data-toggle="dropdown">
{viewing} {icon('fas fa-sort')}
</button>
@@ -68,7 +80,7 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" />
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span className="Scrubber-description">{this.stream.description}</span>
<span className="Scrubber-description">{retain || this.props.description}</span>
</div>
</div>
<div className="Scrubber-after" />
@@ -87,12 +99,23 @@ export default class PostStreamScrubber extends Component {
);
}
/**
* Check whether or not the scrubber should be disabled, i.e. if all of the
* posts are visible in the viewport.
*
* @return {Boolean}
*/
disabled() {
return this.props.visible >= this.props.count;
}
config(isInitialized, context) {
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
if (isInitialized) return;
context.onunload = this.ondestroy.bind(this);
this.scrollListener.start();
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
$(window)
@@ -116,6 +139,7 @@ export default class PostStreamScrubber extends Component {
this.dragging = false;
this.mouseStart = 0;
this.indexStart = 0;
this.dragIndex = null;
this.$('.Scrubber-handle')
.css('cursor', 'move')
@@ -131,8 +155,14 @@ export default class PostStreamScrubber extends Component {
$(document)
.on('mousemove touchmove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this)));
}
setTimeout(() => this.scrollListener.start());
ondestroy() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
/**
@@ -141,16 +171,16 @@ export default class PostStreamScrubber extends Component {
*
* @param {Boolean} animate
*/
updateScrubberValues(options = {}) {
const index = this.stream.index;
const count = this.stream.count();
const visible = this.stream.visible || 1;
renderScrollbar(options = {}) {
const { count, visible, description, paused } = this.props;
const percentPerPost = this.percentPerPost();
const index = this.dragIndex || this.props.index;
const $scrubber = this.$();
$scrubber.find('.Scrubber-index').text(formatNumber(this.stream.sanitizeIndex(Math.max(1, index))));
$scrubber.find('.Scrubber-description').text(this.stream.description);
$scrubber.toggleClass('disabled', this.stream.disabled());
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
$scrubber.find('.Scrubber-description').text(description);
$scrubber.toggleClass('disabled', this.disabled());
const heights = {};
heights.before = Math.max(0, percentPerPost.index * Math.min(index - 1, count - visible));
@@ -158,8 +188,8 @@ export default class PostStreamScrubber extends Component {
heights.after = 100 - heights.before - heights.handle;
// If the stream is paused, don't change height on scroll, as the viewport is being scrolled by the JS
// If a height change animation is already in progress, don't adjust height unless overriden
if ((options.fromScroll && this.stream.paused) || (this.adjustingHeight && !options.forceHeightChange)) return;
// If a height change animation is already in progress, don't adjust height unless overridden
if ((options.fromScroll && paused) || (this.adjustingHeight && !options.forceHeightChange)) return;
const func = options.animate ? 'animate' : 'css';
this.adjustingHeight = true;
@@ -184,23 +214,14 @@ export default class PostStreamScrubber extends Component {
* Go to the first post in the discussion.
*/
goToFirst() {
this.stream.goToFirst();
this.updateScrubberValues({ animate: true, forceHeightChange: true });
this.navigateTo(0);
}
/**
* Go to the last post in the discussion.
*/
goToLast() {
this.stream.goToLast();
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
ondestroy() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
this.navigateTo(this.props.count - 1);
}
onresize() {
@@ -222,8 +243,9 @@ export default class PostStreamScrubber extends Component {
onmousedown(e) {
e.redraw = false;
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
this.indexStart = this.stream.index;
this.indexStart = this.props.index;
this.dragging = true;
this.dragIndex = null;
$('body').css('cursor', 'move');
this.$().toggleClass('dragging', this.dragging);
}
@@ -238,10 +260,10 @@ export default class PostStreamScrubber extends Component {
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
const deltaIndex = deltaPercent / this.percentPerPost().index || 0;
const newIndex = Math.min(this.indexStart + deltaIndex, this.stream.count() - 1);
const newIndex = Math.min(this.indexStart + deltaIndex, this.props.count - 1);
this.stream.index = Math.max(0, newIndex);
this.updateScrubberValues();
this.dragIndex = Math.max(0, newIndex);
this.renderScrollbar();
}
onmouseup() {
@@ -257,8 +279,14 @@ export default class PostStreamScrubber extends Component {
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
const intIndex = Math.floor(this.stream.index);
this.stream.goToIndex(intIndex);
this.navigateTo(this.dragIndex);
this.dragIndex = null;
}
navigateTo(index) {
this.props.onNavigate(Math.floor(index));
this.renderScrollbar({ animate: true });
}
onclick(e) {
@@ -278,9 +306,9 @@ export default class PostStreamScrubber extends Component {
// 3. Now we can convert the percentage into an index, and tell the stream-
// content component to jump to that index.
let offsetIndex = offsetPercent / this.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(this.stream.count() - 1, offsetIndex));
this.stream.goToIndex(Math.floor(offsetIndex));
this.updateScrubberValues({ animate: true, forceHeightChange: true });
offsetIndex = Math.max(0, Math.min(this.props.count - 1, offsetIndex));
this.navigateTo(offsetIndex);
this.$().removeClass('open');
}
@@ -296,8 +324,8 @@ export default class PostStreamScrubber extends Component {
* scrubber.
*/
percentPerPost() {
const count = this.stream.count() || 1;
const visible = this.stream.visible || 1;
const count = this.props.count || 1;
const visible = this.props.visible || 1;
// To stop the handle of the scrollbar from getting too small when there
// are many posts, we define a minimum percentage height for the handle

View File

@@ -100,7 +100,10 @@ class PostStreamState {
// start scrolling to items. Calling redraw early causes issues.
// Since this is only used for external navigation to the post stream, the delay
// before the stream is moved is not an issue.
return this.loadPromise.then(() => m.redraw());
return this.loadPromise.then(() => {
this.paused = false;
m.redraw();
});
}
/**
@@ -121,7 +124,7 @@ class PostStreamState {
m.redraw();
return this.loadPromise;
return this.loadPromise.then(() => (this.paused = false));
}
/**