mirror of
https://github.com/flarum/core.git
synced 2025-08-04 23:47:32 +02:00
forum: add PostStreamScrubber, refactor things to move away from Component#render
Post components don't seem to be redrawing for some reason when in the PostStream - this doesn't seem to be caused by the subtree retainer, none of the lifecycle hooks are called when Mithril redraws, as far as I can tell
This commit is contained in:
16
js/dist/admin.js
vendored
16
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
16
js/dist/forum.js
vendored
16
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -47,7 +47,7 @@ export default class Model {
|
|||||||
* Get the model's ID.
|
* Get the model's ID.
|
||||||
* @final
|
* @final
|
||||||
*/
|
*/
|
||||||
id(): string | number {
|
id(): string {
|
||||||
return this.data.id;
|
return this.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -107,7 +107,7 @@ export default class Store {
|
|||||||
* @param type The resource type.
|
* @param type The resource type.
|
||||||
* @param id The resource ID.
|
* @param id The resource ID.
|
||||||
*/
|
*/
|
||||||
getById<T extends Model = Model>(type: string, id: number): T {
|
getById<T extends Model = Model>(type: string, id: number | string): T {
|
||||||
return this.data[type] && (this.data[type][id] as T);
|
return this.data[type] && (this.data[type][id] as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ export default class Translator {
|
|||||||
* A map of translation keys to their translated values.
|
* A map of translation keys to their translated values.
|
||||||
*/
|
*/
|
||||||
translations: Translations = {};
|
translations: Translations = {};
|
||||||
locale = null;
|
locale?: string;
|
||||||
|
|
||||||
addTranslations(translations) {
|
addTranslations(translations) {
|
||||||
Object.assign(this.translations, translations);
|
Object.assign(this.translations, translations);
|
||||||
|
@@ -87,7 +87,7 @@ export default class Discussion extends Model {
|
|||||||
/**
|
/**
|
||||||
* Get a list of all of the post IDs in this discussion.
|
* Get a list of all of the post IDs in this discussion.
|
||||||
*/
|
*/
|
||||||
postIds(): number[] {
|
postIds(): string[] {
|
||||||
const posts = this.data.relationships.posts;
|
const posts = this.data.relationships.posts;
|
||||||
|
|
||||||
return posts ? posts.data.map(link => link.id) : [];
|
return posts ? posts.data.map(link => link.id) : [];
|
||||||
|
@@ -6,6 +6,7 @@ import PostUser from './PostUser';
|
|||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import { Vnode } from 'mithril';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `CommentPost` component displays a standard `comment`-typed post. This
|
* The `CommentPost` component displays a standard `comment`-typed post. This
|
||||||
@@ -19,17 +20,18 @@ export default class CommentPost extends Post {
|
|||||||
*/
|
*/
|
||||||
revealContent: boolean = false;
|
revealContent: boolean = false;
|
||||||
|
|
||||||
postUser: PostUser;
|
postUser: Vnode<{}, PostUser>;
|
||||||
|
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
|
|
||||||
// Create an instance of the component that displays the post's author so
|
// Create an instance of the component that displays the post's author so
|
||||||
// that we can force the post to rerender when the user card is shown.
|
// that we can force the post to rerender when the user card is shown.
|
||||||
this.postUser = PostUser.component({ post: this.props.post });
|
this.postUser = <PostUser post={this.props.post} />;
|
||||||
|
|
||||||
this.subtree.check(
|
this.subtree.check(
|
||||||
() => this.postUser.cardVisible,
|
() => this.postUser.state?.cardVisible,
|
||||||
|
() => this.revealContent,
|
||||||
() => this.isEditing()
|
() => this.isEditing()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -2,12 +2,15 @@ import Page from './Page';
|
|||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import DiscussionHero from './DiscussionHero';
|
import DiscussionHero from './DiscussionHero';
|
||||||
import PostStream from './PostStream';
|
import PostStream from './PostStream';
|
||||||
// import PostStreamScrubber from './PostStreamScrubber';
|
import PostStreamScrubber from './PostStreamScrubber';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
import SplitDropdown from '../../common/components/SplitDropdown';
|
import SplitDropdown from '../../common/components/SplitDropdown';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import DiscussionControls from '../utils/DiscussionControls';
|
import DiscussionControls from '../utils/DiscussionControls';
|
||||||
import Discussion from '../../common/models/Discussion';
|
import Discussion from '../../common/models/Discussion';
|
||||||
|
import Post from '../../common/models/Post';
|
||||||
|
|
||||||
|
import { Vnode } from 'mithril';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `DiscussionPage` component displays a whole discussion page, including
|
* The `DiscussionPage` component displays a whole discussion page, including
|
||||||
@@ -24,7 +27,7 @@ export default class DiscussionPage extends Page {
|
|||||||
*/
|
*/
|
||||||
near?: number;
|
near?: number;
|
||||||
|
|
||||||
stream: PostStream;
|
stream?: Vnode<{}, PostStream>;
|
||||||
|
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
@@ -60,7 +63,7 @@ export default class DiscussionPage extends Page {
|
|||||||
const near = m.route.param('near') || '1';
|
const near = m.route.param('near') || '1';
|
||||||
|
|
||||||
if (near !== String(this.near)) {
|
if (near !== String(this.near)) {
|
||||||
// this.stream.goToNumber(near);
|
this.stream?.state.goToNumber(near);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.near = null;
|
this.near = null;
|
||||||
@@ -104,7 +107,7 @@ export default class DiscussionPage extends Page {
|
|||||||
<nav className="DiscussionPage-nav">
|
<nav className="DiscussionPage-nav">
|
||||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="DiscussionPage-stream">{this.stream.render()}</div>
|
<div className="DiscussionPage-stream">{this.stream}</div>
|
||||||
</div>,
|
</div>,
|
||||||
]
|
]
|
||||||
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
||||||
@@ -170,7 +173,7 @@ export default class DiscussionPage extends Page {
|
|||||||
// extensions. We need to distinguish the two so we don't end up displaying
|
// extensions. We need to distinguish the two so we don't end up displaying
|
||||||
// the wrong posts. We do so by filtering out the posts that don't have
|
// the wrong posts. We do so by filtering out the posts that don't have
|
||||||
// the 'discussion' relationship linked, then sorting and splicing.
|
// the 'discussion' relationship linked, then sorting and splicing.
|
||||||
let includedPosts = [];
|
let includedPosts: Post[] = [];
|
||||||
if (discussion.payload && discussion.payload.included) {
|
if (discussion.payload && discussion.payload.included) {
|
||||||
const discussionId = discussion.id();
|
const discussionId = discussion.id();
|
||||||
|
|
||||||
@@ -189,12 +192,21 @@ export default class DiscussionPage extends Page {
|
|||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
|
const positionChanged = this.positionChanged.bind(this);
|
||||||
|
|
||||||
// Set up the post stream for this discussion, along with the first page of
|
// Set up the post stream for this discussion, along with the first page of
|
||||||
// posts we want to display. Tell the stream to scroll down and highlight
|
// posts we want to display. Tell the stream to scroll down and highlight
|
||||||
// the specific post that was routed to.
|
// the specific post that was routed to.
|
||||||
this.stream = new PostStream({ discussion, includedPosts });
|
this.stream = (
|
||||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
<PostStream
|
||||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
discussion={discussion}
|
||||||
|
includedPosts={includedPosts}
|
||||||
|
oncreate={function(this: PostStream) {
|
||||||
|
this.on('positionChanged', positionChanged);
|
||||||
|
this.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,14 +259,7 @@ export default class DiscussionPage extends Page {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// items.add(
|
items.add('scrubber', <PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
|
||||||
// 'scrubber',
|
|
||||||
// PostStreamScrubber.component({
|
|
||||||
// stream: this.stream,
|
|
||||||
// className: 'App-titleControl',
|
|
||||||
// }),
|
|
||||||
// -100
|
|
||||||
// );
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
@@ -17,16 +17,11 @@ export default class Post<T extends PostProp = PostProp> extends Component<T> {
|
|||||||
loading = false;
|
loading = false;
|
||||||
controlsOpen = false;
|
controlsOpen = false;
|
||||||
|
|
||||||
subtree: SubtreeRetainer;
|
|
||||||
|
|
||||||
oninit(vnode) {
|
|
||||||
super.oninit(vnode);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up a subtree retainer so that the post will not be redrawn
|
* Set up a subtree retainer so that the post will not be redrawn
|
||||||
* unless new data comes in.
|
* unless new data comes in.
|
||||||
*/
|
*/
|
||||||
this.subtree = new SubtreeRetainer(
|
subtree = new SubtreeRetainer(
|
||||||
() => this.props.post.freshness,
|
() => this.props.post.freshness,
|
||||||
() => {
|
() => {
|
||||||
const user = this.props.post.user();
|
const user = this.props.post.user();
|
||||||
@@ -34,7 +29,6 @@ export default class Post<T extends PostProp = PostProp> extends Component<T> {
|
|||||||
},
|
},
|
||||||
() => this.controlsOpen
|
() => this.controlsOpen
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const controls = PostControls.controls(this.props.post, this).toArray();
|
const controls = PostControls.controls(this.props.post, this).toArray();
|
||||||
@@ -71,7 +65,6 @@ export default class Post<T extends PostProp = PostProp> extends Component<T> {
|
|||||||
<ul>{listItems(this.footerItems().toArray())}</ul>
|
<ul>{listItems(this.footerItems().toArray())}</ul>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,7 +94,7 @@ export default class Post<T extends PostProp = PostProp> extends Component<T> {
|
|||||||
/**
|
/**
|
||||||
* Get the post's content.
|
* Get the post's content.
|
||||||
*/
|
*/
|
||||||
content() {
|
content(): any[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -8,6 +8,7 @@ import Discussion from '../../common/models/Discussion';
|
|||||||
import Post from '../../common/models/Post';
|
import Post from '../../common/models/Post';
|
||||||
import Evented from '../../common/utils/evented';
|
import Evented from '../../common/utils/evented';
|
||||||
import { DiscussionProp } from '../../common/concerns/ComponentProps';
|
import { DiscussionProp } from '../../common/concerns/ComponentProps';
|
||||||
|
import { Attributes } from 'mithril';
|
||||||
|
|
||||||
export interface PostStreamProps extends DiscussionProp {
|
export interface PostStreamProps extends DiscussionProp {
|
||||||
includedPosts: Post[];
|
includedPosts: Post[];
|
||||||
@@ -40,10 +41,10 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
loadPageTimeouts = {};
|
loadPageTimeouts = {};
|
||||||
pagesLoading = 0;
|
pagesLoading = 0;
|
||||||
|
|
||||||
calculatePositionTimeout: number;
|
calculatePositionTimeout: number = 0;
|
||||||
visibleStart: number;
|
visibleStart: number = 0;
|
||||||
visibleEnd: number;
|
visibleEnd: number = 0;
|
||||||
viewingEnd: boolean;
|
viewingEnd: boolean = true;
|
||||||
|
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
@@ -66,7 +67,7 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
* the last post and scroll the reply preview into view.
|
* the last post and scroll the reply preview into view.
|
||||||
* @param noAnimation
|
* @param noAnimation
|
||||||
*/
|
*/
|
||||||
goToNumber(number: number | 'reply', noAnimation?: boolean): Promise<void> {
|
goToNumber(number: string | number | 'reply', noAnimation?: boolean): Promise<void> {
|
||||||
// If we want to go to the reply preview, then we will go to the end of the
|
// If we want to go to the reply preview, then we will go to the end of the
|
||||||
// discussion and then scroll to the very bottom of the page.
|
// discussion and then scroll to the very bottom of the page.
|
||||||
if (number === 'reply') {
|
if (number === 'reply') {
|
||||||
@@ -177,7 +178,7 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
/**
|
/**
|
||||||
* Get the visible page of posts.
|
* Get the visible page of posts.
|
||||||
*/
|
*/
|
||||||
posts(): Post[] {
|
posts(): (Post | null)[] {
|
||||||
return this.discussion
|
return this.discussion
|
||||||
.postIds()
|
.postIds()
|
||||||
.slice(this.visibleStart, this.visibleEnd)
|
.slice(this.visibleStart, this.visibleEnd)
|
||||||
@@ -190,11 +191,11 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
|
|
||||||
view() {
|
view() {
|
||||||
function fadeIn(vnode) {
|
function fadeIn(vnode) {
|
||||||
if (!vnode.attrs.fadedIn)
|
if (!vnode.state.fadedIn)
|
||||||
$(vnode.dom)
|
$(vnode.dom)
|
||||||
.hide()
|
.hide()
|
||||||
.fadeIn();
|
.fadeIn();
|
||||||
vnode.attrs.fadedIn = true;
|
vnode.state.fadedIn = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastTime;
|
let lastTime;
|
||||||
@@ -207,12 +208,12 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
|
|
||||||
const items = posts.map((post, i) => {
|
const items = posts.map((post, i) => {
|
||||||
let content;
|
let content;
|
||||||
const attrs = { 'data-index': this.visibleStart + i };
|
const attrs: Attributes = { 'data-index': this.visibleStart + i };
|
||||||
|
|
||||||
if (post) {
|
if (post) {
|
||||||
const time = post.createdAt();
|
const time = post.createdAt();
|
||||||
const PostComponent = app.postComponents[post.contentType()];
|
const PostComponent = app.postComponents[post.contentType()];
|
||||||
content = PostComponent ? PostComponent.component({ post }) : '';
|
content = PostComponent ? <PostComponent post={post} /> : '';
|
||||||
|
|
||||||
attrs.key = 'post' + post.id();
|
attrs.key = 'post' + post.id();
|
||||||
attrs.oncreate = fadeIn;
|
attrs.oncreate = fadeIn;
|
||||||
@@ -241,7 +242,7 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
} else {
|
} else {
|
||||||
attrs.key = 'post' + postIds[this.visibleStart + i];
|
attrs.key = 'post' + postIds[this.visibleStart + i];
|
||||||
|
|
||||||
content = PostLoading.component();
|
content = <PostLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -277,9 +278,6 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
oncreate(vnode) {
|
oncreate(vnode) {
|
||||||
super.oncreate(vnode);
|
super.oncreate(vnode);
|
||||||
|
|
||||||
// // This is wrapped in setTimeout due to the following Mithril issue:
|
|
||||||
// // https://github.com/lhorie/mithril.js/issues/637
|
|
||||||
// setTimeout(() => this.scrollListener.start());
|
|
||||||
this.scrollListener.start();
|
this.scrollListener.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,9 +400,9 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
* Load and inject the specified range of posts into the stream, without
|
* Load and inject the specified range of posts into the stream, without
|
||||||
* clearing it.
|
* clearing it.
|
||||||
*/
|
*/
|
||||||
loadRange(start: number, end?: number): Promise<void> {
|
loadRange(start?: number, end?: number): Promise<Post[]> {
|
||||||
const loadIds = [];
|
const loadIds: string[] = [];
|
||||||
const loaded = [];
|
const loaded: Post[] = [];
|
||||||
|
|
||||||
this.discussion
|
this.discussion
|
||||||
.postIds()
|
.postIds()
|
||||||
@@ -427,7 +425,7 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
* If the post with the given number is already loaded, the promise will be
|
* If the post with the given number is already loaded, the promise will be
|
||||||
* resolved immediately.
|
* resolved immediately.
|
||||||
*/
|
*/
|
||||||
loadNearNumber(number: number): Promise<void> {
|
loadNearNumber(number: string | number): Promise<void> {
|
||||||
if (this.posts().some(post => post && Number(post.number()) === Number(number))) {
|
if (this.posts().some(post => post && Number(post.number()) === Number(number))) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -472,8 +470,8 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
let startNumber;
|
let startNumber;
|
||||||
let endNumber;
|
let endNumber;
|
||||||
|
|
||||||
this.$('.PostStream-item').each(function() {
|
this.$('.PostStream-item').each((index, item: Element) => {
|
||||||
const $item = $(this);
|
const $item = $(item);
|
||||||
const top = $item.offset().top;
|
const top = $item.offset().top;
|
||||||
const height = $item.outerHeight(true);
|
const height = $item.outerHeight(true);
|
||||||
|
|
||||||
@@ -488,6 +486,8 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
}
|
}
|
||||||
} else return false;
|
} else return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (startNumber) {
|
if (startNumber) {
|
||||||
@@ -506,7 +506,7 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
/**
|
/**
|
||||||
* Scroll down to a certain post by number and 'flash' it.
|
* Scroll down to a certain post by number and 'flash' it.
|
||||||
*/
|
*/
|
||||||
scrollToNumber(number: number, noAnimation?: boolean): Promise<void> {
|
scrollToNumber(number: string | number, noAnimation?: boolean): Promise<void> {
|
||||||
const $item = this.$(`.PostStream-item[data-number="${number}"]`);
|
const $item = this.$(`.PostStream-item[data-number="${number}"]`);
|
||||||
|
|
||||||
return this.scrollToItem($item, noAnimation).then(() => this.flashItem($item));
|
return this.scrollToItem($item, noAnimation).then(() => this.flashItem($item));
|
||||||
@@ -549,14 +549,15 @@ class PostStream<T extends PostStreamProps = PostStreamProps> extends Component<
|
|||||||
// If we're scrolling to the bottom of an item, then we'll make sure the
|
// If we're scrolling to the bottom of an item, then we'll make sure the
|
||||||
// bottom will line up with the top of the composer.
|
// bottom will line up with the top of the composer.
|
||||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||||
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
|
// TODO const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
|
||||||
|
const top = bottom ? itemBottom - $(window).height() : $item.is(':first-child') ? 0 : itemTop;
|
||||||
|
|
||||||
return new Promise<void>(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
if (noAnimation) {
|
if (noAnimation) {
|
||||||
$container.scrollTop(top);
|
$container.scrollTop(top);
|
||||||
resolve();
|
resolve();
|
||||||
} else if (top !== scrollTop) {
|
} else if (top !== scrollTop) {
|
||||||
$container.animate({ scrollTop: top }, 'fast', 'linear', () => resolve());
|
$container.animatedScrollTop(top, 'fast', resolve);
|
||||||
} else {
|
} else {
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
|
448
js/src/forum/components/PostStreamScrubber.tsx
Normal file
448
js/src/forum/components/PostStreamScrubber.tsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import Component, { ComponentProps } from '../../common/Component';
|
||||||
|
import icon from '../../common/helpers/icon';
|
||||||
|
import ScrollListener from '../../common/utils/ScrollListener';
|
||||||
|
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
|
||||||
|
import formatNumber from '../../common/utils/formatNumber';
|
||||||
|
import PostStream from './PostStream';
|
||||||
|
import { EventHandler } from '../../common/utils/Evented';
|
||||||
|
import { Vnode } from 'mithril';
|
||||||
|
|
||||||
|
export interface PostStreamScrubberProps extends ComponentProps {
|
||||||
|
stream: Vnode<{}, PostStream>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `PostStreamScrubber` component displays a scrubber which can be used to
|
||||||
|
* navigate/scrub through a post stream.
|
||||||
|
*/
|
||||||
|
export default class PostStreamScrubber<T extends PostStreamScrubberProps = PostStreamScrubberProps> extends Component<T> {
|
||||||
|
handlers: { [key: string]: EventHandler } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The index of the post that is currently at the top of the viewport.
|
||||||
|
*/
|
||||||
|
index: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of posts that are currently visible in the viewport.
|
||||||
|
*/
|
||||||
|
visible: number = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description to render on the scrubber.
|
||||||
|
*/
|
||||||
|
description: string = '';
|
||||||
|
|
||||||
|
// Define a handler to update the state of the scrollbar to reflect the
|
||||||
|
// current scroll position of the page.
|
||||||
|
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.
|
||||||
|
subtree = new SubtreeRetainer(() => true);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
dragging = false;
|
||||||
|
mouseStart = 0;
|
||||||
|
indexStart = 0;
|
||||||
|
|
||||||
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
const count = this.count();
|
||||||
|
const unreadCount = this.props.stream.state?.discussion.unreadCount() || 0;
|
||||||
|
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
|
||||||
|
|
||||||
|
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
||||||
|
index: (
|
||||||
|
<span className="Scrubber-index" onbeforeupdate={() => this.subtree.update()}>
|
||||||
|
{formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
||||||
|
});
|
||||||
|
|
||||||
|
function styleUnread(vnode) {
|
||||||
|
const $element = $(vnode.dom);
|
||||||
|
const newStyle = {
|
||||||
|
top: 100 - unreadPercent * 100 + '%',
|
||||||
|
height: unreadPercent * 100 + '%',
|
||||||
|
display: unreadCount == 0 && 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (vnode.state.oldStyle) {
|
||||||
|
$element.css(vnode.state.oldStyle).animate(newStyle);
|
||||||
|
} else {
|
||||||
|
$element.css(newStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
vnode.state.oldStyle = newStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'PostStreamScrubber Dropdown ' + (this.disabled() ? 'disabled ' : '') + (this.props.className || '')}>
|
||||||
|
<button className="Button Dropdown-toggle" data-toggle="dropdown">
|
||||||
|
{viewing} {icon('fas fa-sort')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="Dropdown-menu dropdown-menu">
|
||||||
|
<div className="Scrubber">
|
||||||
|
<a className="Scrubber-first" onclick={this.goToFirst.bind(this)}>
|
||||||
|
{icon('fas fa-angle-double-up')} {app.translator.trans('core.forum.post_scrubber.original_post_link')}
|
||||||
|
</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 className="Scrubber-description" onbeforeupdate={() => this.subtree.update()}>
|
||||||
|
{this.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="Scrubber-after" />
|
||||||
|
|
||||||
|
<div className="Scrubber-unread" oncreate={styleUnread}>
|
||||||
|
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a className="Scrubber-last" onclick={this.goToLast.bind(this)}>
|
||||||
|
{icon('fas fa-angle-double-down')} {app.translator.trans('core.forum.post_scrubber.now_link')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to the first post in the discussion.
|
||||||
|
*/
|
||||||
|
goToFirst() {
|
||||||
|
this.props.stream.state?.goToFirst();
|
||||||
|
this.index = 0;
|
||||||
|
this.renderScrollbar(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to the last post in the discussion.
|
||||||
|
*/
|
||||||
|
goToLast() {
|
||||||
|
this.props.stream.state?.goToLast();
|
||||||
|
this.index = this.count();
|
||||||
|
this.renderScrollbar(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of posts in the discussion.
|
||||||
|
*/
|
||||||
|
count(): number {
|
||||||
|
return this.props.stream.state?.count() || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
disabled(): boolean {
|
||||||
|
return this.visible >= this.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the page is scrolled, update the scrollbar to reflect the visible
|
||||||
|
* posts.
|
||||||
|
*/
|
||||||
|
onscroll(top: number) {
|
||||||
|
const stream = this.props.stream.state;
|
||||||
|
|
||||||
|
if (!stream || stream.paused || !stream.$()) return;
|
||||||
|
|
||||||
|
this.update(top);
|
||||||
|
this.renderScrollbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the index/visible/description properties according to the window's
|
||||||
|
* current scroll position.
|
||||||
|
*/
|
||||||
|
update(scrollTop?: number) {
|
||||||
|
const stream = this.props.stream.state;
|
||||||
|
|
||||||
|
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.$('.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.index = index;
|
||||||
|
this.visible = visible;
|
||||||
|
this.description = period ? dayjs(period).format('MMMM YYYY') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onremove(vnode) {
|
||||||
|
super.onremove(vnode);
|
||||||
|
|
||||||
|
this.ondestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
// When the post stream begins loading posts at a certain index, we want our
|
||||||
|
// scrubber scrollbar to jump to that position.
|
||||||
|
this.props.stream.state.on('unpaused', (this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this)));
|
||||||
|
this.props.stream.state.on('update', () => this.update());
|
||||||
|
|
||||||
|
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')
|
||||||
|
.on('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' })
|
||||||
|
.on('dragstart mousedown touchstart', e => e.preventDefault());
|
||||||
|
|
||||||
|
this.$('.Scrubber-handle')
|
||||||
|
.css('cursor', 'move')
|
||||||
|
.on('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.state.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.
|
||||||
|
*/
|
||||||
|
renderScrollbar(animate?: boolean) {
|
||||||
|
const percentPerPost = this.percentPerPost();
|
||||||
|
const index = this.index;
|
||||||
|
const count = this.count();
|
||||||
|
const visible = this.visible || 1;
|
||||||
|
|
||||||
|
const $scrubber = this.$();
|
||||||
|
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
|
||||||
|
$scrubber.find('.Scrubber-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[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.
|
||||||
|
*
|
||||||
|
* @property index The percent per post for posts on either side of
|
||||||
|
* the visible part of the scrubber.
|
||||||
|
* @property visible The percent per post for the visible part of the
|
||||||
|
* scrubber.
|
||||||
|
*/
|
||||||
|
percentPerPost(): { index: number; visible: number } {
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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($('#app').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.state.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 || 0;
|
||||||
|
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.state?.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.state?.goToIndex(Math.floor(offsetIndex));
|
||||||
|
this.index = offsetIndex;
|
||||||
|
this.renderScrollbar(true);
|
||||||
|
|
||||||
|
this.$().removeClass('open');
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user