mirror of
https://github.com/flarum/core.git
synced 2025-07-29 20:50:28 +02:00
Massive JavaScript cleanup
- Use JSX for templates - Docblock/comment everything - Mostly passes ESLint (still some work to do) - Lots of renaming, refactoring, etc. CSS hasn't been updated yet.
This commit is contained in:
463
js/forum/src/components/PostStreamScrubber.js
Normal file
463
js/forum/src/components/PostStreamScrubber.js
Normal file
@@ -0,0 +1,463 @@
|
||||
import Component from 'flarum/Component';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import ScrollListener from 'flarum/utils/ScrollListener';
|
||||
import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
|
||||
import computed from 'flarum/utils/computed';
|
||||
import formatNumber from 'flarum/utils/formatNumber';
|
||||
|
||||
/**
|
||||
* The `PostStreamScrubber` component displays a scrubber which can be used to
|
||||
* navigate/scrub through a post stream.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `stream`
|
||||
* - `className`
|
||||
*/
|
||||
export default class PostStreamScrubber extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.handlers = {};
|
||||
|
||||
/**
|
||||
* The index of the post that is currently at the top of the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.index = 0;
|
||||
|
||||
/**
|
||||
* The number of posts that are currently visible in the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.visible = 1;
|
||||
|
||||
/**
|
||||
* The description to render on the scrubber.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.description = '';
|
||||
|
||||
/**
|
||||
* The integer index of the last item that is visible in the viewport. This
|
||||
* is displayed on the scrubber (i.e. X of 100 posts).
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
this.visibleIndex = computed('index', 'visible', 'count', function(index, visible, count) {
|
||||
return Math.min(count, Math.ceil(Math.max(0, index) + visible));
|
||||
});
|
||||
|
||||
// When the post stream begins loading posts at a certain index, we want our
|
||||
// scrubber scrollbar to jump to that position.
|
||||
this.props.stream.on('unpaused', this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this));
|
||||
|
||||
// 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));
|
||||
|
||||
// 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 retain = this.subtree.retain();
|
||||
const unreadCount = this.props.stream.discussion.unreadCount();
|
||||
const unreadPercent = Math.min(this.count() - this.index, unreadCount) / this.count();
|
||||
|
||||
const viewing = [
|
||||
<span className="index">{retain || formatNumber(this.visibleIndex())}</span>,
|
||||
' of ',
|
||||
<span className="count">{formatNumber(this.count())}</span>,
|
||||
' posts '
|
||||
];
|
||||
|
||||
function styleUnread(element, isInitialized, context) {
|
||||
const $element = $(element);
|
||||
const newStyle = {
|
||||
top: (100 - unreadPercent * 100) + '%',
|
||||
height: (unreadPercent * 100) + '%'
|
||||
};
|
||||
|
||||
if (context.oldStyle) {
|
||||
$element.stop(true).css(context.oldStyle).animate(newStyle);
|
||||
} else {
|
||||
$element.css(newStyle);
|
||||
}
|
||||
|
||||
context.oldStyle = newStyle;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'post-stream-scrubber dropdown ' + (this.disabled() ? 'disabled ' : '') + (this.props.className || '')}>
|
||||
<a href="javascript:;" className="btn btn-default dropdown-toggle" data-toggle="dropdown">
|
||||
{viewing} {icon('sort')}
|
||||
</a>
|
||||
|
||||
<div className="dropdown-menu">
|
||||
<div className="scrubber">
|
||||
<a href="javascript:;" className="scrubber-first" onclick={this.goToFirst.bind(this)}>
|
||||
{icon('angle-double-up')} Original Post
|
||||
</a>
|
||||
|
||||
<div className="scrubber-scrollbar">
|
||||
<div className="scrubber-before"/>
|
||||
<div className="scrubber-handle">
|
||||
<div className="scrubber-bar"/>
|
||||
<div className="scrubber-info">
|
||||
<strong>{viewing}</strong>
|
||||
<span class="description">{retain || this.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrubber-after"/>
|
||||
|
||||
<div className="scrubber-unread" config={styleUnread}>
|
||||
{formatNumber(unreadCount)} unread
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="javascript:;" className="scrubber-last" onclick={this.goToLast.bind(this)}>
|
||||
{icon('angle-double-down')} Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the first post in the discussion.
|
||||
*/
|
||||
goToFirst() {
|
||||
this.props.stream.goToFirst();
|
||||
this.index = 0;
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the last post in the discussion.
|
||||
*/
|
||||
goToLast() {
|
||||
this.props.stream.goToLast();
|
||||
this.index = this.props.stream.count();
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of posts in the discussion.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
count() {
|
||||
return this.props.stream.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the stream is unpaused, update the scrubber to reflect its position.
|
||||
*/
|
||||
streamWasUnpaused() {
|
||||
this.update(window.pageYOffset);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.visible >= this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the page is scrolled, update the scrollbar to reflect the visible
|
||||
* posts.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
if (stream.paused || !stream.$()) return;
|
||||
|
||||
this.update(top);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the index/visible/description properties according to the window's
|
||||
* current scroll position.
|
||||
*
|
||||
* @param {Integer} scrollTop
|
||||
*/
|
||||
update(scrollTop) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
const marginTop = stream.getMarginTop();
|
||||
const viewportTop = scrollTop + marginTop;
|
||||
const viewportHeight = $(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.
|
||||
const $items = stream.$('> .post-stream-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
|
||||
// post. If it's below the bottom of the viewport, break out of the
|
||||
// loop.
|
||||
if (top + height < viewportTop) {
|
||||
visible = (top + height - viewportTop) / height;
|
||||
index = parseFloat($this.data('index')) + 1 - visible;
|
||||
return true;
|
||||
}
|
||||
if (top > viewportTop + viewportHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the bottom half of this item is visible at the top of the
|
||||
// viewport, then set the start of the visible proportion as our index.
|
||||
if (top <= viewportTop && top + height > viewportTop) {
|
||||
visible = (top + height - viewportTop) / height;
|
||||
index = parseFloat($this.data('index')) + 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 >= viewportTop + viewportHeight) {
|
||||
visible += (viewportTop + viewportHeight - 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.
|
||||
const time = $this.data('time');
|
||||
if (time) period = time;
|
||||
});
|
||||
|
||||
this.index = index;
|
||||
this.visible = visible;
|
||||
this.description = period ? moment(period).format('MMMM YYYY') : '';
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
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).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
|
||||
|
||||
// When any part of the whole scrollbar is clicked, we want to jump to
|
||||
// that position.
|
||||
this.$('.scrubber-scrollbar')
|
||||
.bind('click', this.onclick.bind(this))
|
||||
|
||||
// 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.$('.scrubber-handle')
|
||||
.css('cursor', 'move')
|
||||
.bind('mousedown touchstart', this.onmousedown.bind(this))
|
||||
|
||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||
.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
|
||||
// some event handlers. These handlers will move the scrollbar/stream-
|
||||
// content as appropriate.
|
||||
$(document)
|
||||
.on('mousemove touchmove', this.handlers.onmousemove = this.onmousemove.bind(this))
|
||||
.on('mouseup touchend', this.handlers.onmouseup = this.onmouseup.bind(this));
|
||||
}
|
||||
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
|
||||
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
|
||||
|
||||
$(window)
|
||||
.off('resize', this.handlers.onresize);
|
||||
|
||||
$(document)
|
||||
.off('mousemove touchmove', this.handlers.onmousemove)
|
||||
.off('mouseup touchend', this.handlers.onmouseup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the scrollbar's position to reflect the current values of the
|
||||
* index/visible properties.
|
||||
*
|
||||
* @param {Boolean} animate
|
||||
*/
|
||||
renderScrollbar(animate) {
|
||||
const percentPerPost = this.percentPerPost();
|
||||
const index = this.index;
|
||||
const count = this.count();
|
||||
const visible = this.visible || 1;
|
||||
|
||||
const $scrubber = this.$();
|
||||
$scrubber.find('.index').text(formatNumber(this.visibleIndex()));
|
||||
$scrubber.find('.description').text(this.description);
|
||||
$scrubber.toggleClass('disabled', this.disabled());
|
||||
|
||||
const heights = {};
|
||||
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
|
||||
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
||||
heights.after = 100 - heights.before - heights.handle;
|
||||
|
||||
const func = animate ? 'animate' : 'css';
|
||||
for (const part in heights) {
|
||||
const $part = $scrubber.find(`.scrubber-${part}`);
|
||||
$part.stop(true, true)[func]({height: heights[part] + '%'}, 'fast');
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the percentage of the height of the scrubber that should be allocated
|
||||
* to each post.
|
||||
*
|
||||
* @return {Object}
|
||||
* @property {Number} index The percent per post for posts on either side of
|
||||
* the visible part of the scrubber.
|
||||
* @property {Number} visible The percent per post for the visible part of the
|
||||
* scrubber.
|
||||
*/
|
||||
percentPerPost() {
|
||||
const count = this.count() || 1;
|
||||
const visible = this.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
|
||||
// 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.
|
||||
const minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
|
||||
const percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
|
||||
const percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
|
||||
|
||||
return {
|
||||
index: percentPerPost,
|
||||
visible: percentPerVisiblePost
|
||||
};
|
||||
}
|
||||
|
||||
onresize() {
|
||||
this.scrollListener.update(true);
|
||||
|
||||
// Adjust the height of the scrollbar so that it fills the height of
|
||||
// the sidebar and doesn't overlap the footer.
|
||||
const scrubber = this.$();
|
||||
const scrollbar = this.$('.scrubber-scrollbar');
|
||||
|
||||
scrollbar.css('max-height', $(window).height() -
|
||||
scrubber.offset().top + $(window).scrollTop() -
|
||||
parseInt($('.global-page').css('padding-bottom'), 10) -
|
||||
(scrubber.outerHeight() - scrollbar.outerHeight()));
|
||||
}
|
||||
|
||||
onmousedown(e) {
|
||||
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(e) {
|
||||
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.
|
||||
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;
|
||||
const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
|
||||
|
||||
this.index = Math.max(0, newIndex);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
onmouseup() {
|
||||
if (!this.dragging) return;
|
||||
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.dragging = false;
|
||||
$('body').css('cursor', '');
|
||||
|
||||
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.
|
||||
const intIndex = Math.floor(this.index);
|
||||
this.props.stream.goToIndex(intIndex);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
onclick(e) {
|
||||
// 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.
|
||||
const $scrollbar = this.$('.scrubber-scrollbar');
|
||||
const offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
|
||||
let offsetPercent = offsetPixels / $scrollbar.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($scrollbar.find('.scrubber-handle')[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.
|
||||
let 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);
|
||||
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user