mirror of
https://github.com/flarum/framework.git
synced 2024-12-12 06:03:39 +08:00
Discussion list refactor, gestures
Also make base Component class automatically assign this.element :)
This commit is contained in:
parent
748abd9b0b
commit
972bd24c7a
128
framework/core/js/forum/src/components/discussion-list-item.js
Normal file
128
framework/core/js/forum/src/components/discussion-list-item.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/utils/human-time';
|
||||
import classList from 'flarum/utils/class-list';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import abbreviateNumber from 'flarum/utils/abbreviate-number';
|
||||
import DropdownButton from 'flarum/components/dropdown-button';
|
||||
import TerminalPost from 'flarum/components/terminal-post';
|
||||
import PostPreview from 'flarum/components/post-preview';
|
||||
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
|
||||
import slidable from 'flarum/utils/slidable';
|
||||
|
||||
export default class DiscussionListItem extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.discussion.freshness,
|
||||
() => app.session.user() && app.session.user().readTime()
|
||||
);
|
||||
}
|
||||
|
||||
view() {
|
||||
var discussion = this.props.discussion;
|
||||
|
||||
var startUser = discussion.startUser();
|
||||
var isUnread = discussion.isUnread();
|
||||
var displayUnread = this.props.countType !== 'replies' && isUnread;
|
||||
var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
|
||||
var relevantPosts = this.props.q ? discussion.relevantPosts() : '';
|
||||
var controls = discussion.controls(this).toArray();
|
||||
var isActive = m.route.param('id') === discussion.id();
|
||||
|
||||
return this.subtree.retain() || m('div.discussion-list-item', {config: this.onload.bind(this)}, [
|
||||
controls.length ? DropdownButton.component({
|
||||
items: controls,
|
||||
className: 'contextual-controls',
|
||||
buttonClass: 'btn btn-default btn-icon btn-sm btn-naked slidable-underneath slidable-underneath-right',
|
||||
menuClass: 'pull-right'
|
||||
}) : '',
|
||||
|
||||
m('a.slidable-underneath.slidable-underneath-left.elastic', {
|
||||
className: discussion.isUnread() ? '' : 'disabled',
|
||||
onclick: this.markAsRead.bind(this)
|
||||
}, icon('check icon')),
|
||||
|
||||
m('div.slidable-slider.discussion-summary', {
|
||||
className: classList({
|
||||
unread: isUnread,
|
||||
active: isActive
|
||||
})
|
||||
}, [
|
||||
|
||||
m((startUser ? 'a' : 'span')+'.author', {
|
||||
href: startUser ? app.route.user(startUser) : undefined,
|
||||
config: function(element, isInitialized, context) {
|
||||
$(element).tooltip({ placement: 'right' });
|
||||
m.route.apply(this, arguments);
|
||||
},
|
||||
title: 'Started by '+(startUser ? startUser.username() : '[deleted]')+' '+humanTime(discussion.startTime())
|
||||
}, [
|
||||
avatar(startUser, {title: ''})
|
||||
]),
|
||||
|
||||
m('ul.badges', listItems(discussion.badges().toArray())),
|
||||
|
||||
m('a.main', {href: app.route.discussion(discussion, jumpTo), config: m.route}, [
|
||||
m('h3.title', highlight(discussion.title(), this.props.q)),
|
||||
m('ul.info', listItems(this.infoItems().toArray()))
|
||||
]),
|
||||
|
||||
m('span.count', {onclick: this.markAsRead.bind(this)}, [
|
||||
abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()),
|
||||
m('span.label', displayUnread ? 'unread' : 'replies')
|
||||
]),
|
||||
|
||||
(relevantPosts && relevantPosts.length)
|
||||
? m('div.relevant-posts', relevantPosts.map(post => PostPreview.component({post, highlight: this.props.q})))
|
||||
: ''
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
markAsRead() {
|
||||
var discussion = this.props.discussion;
|
||||
|
||||
if (discussion.isUnread()) {
|
||||
discussion.save({ readNumber: discussion.lastPostNumber() });
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Build an item list of info for a discussion listing. By default this is
|
||||
just the first/last post indicator.
|
||||
|
||||
@return {ItemList}
|
||||
*/
|
||||
infoItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('terminalPost',
|
||||
TerminalPost.component({
|
||||
discussion: this.props.discussion,
|
||||
lastPost: this.props.terminalPostType !== 'start'
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onload(element, isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if (window.ontouchstart !== 'undefined') {
|
||||
this.$().addClass('slidable');
|
||||
|
||||
var slidableInstance = slidable(element);
|
||||
|
||||
this.$('.contextual-controls').on('hidden.bs.dropdown', function() {
|
||||
slidableInstance.reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,16 +1,7 @@
|
|||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import humanTime from 'flarum/utils/human-time';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import abbreviateNumber from 'flarum/utils/abbreviate-number';
|
||||
import DiscussionListItem from 'flarum/components/discussion-list-item';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import DropdownButton from 'flarum/components/dropdown-button';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import TerminalPost from 'flarum/components/terminal-post';
|
||||
import PostPreview from 'flarum/components/post-preview';
|
||||
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
|
||||
|
||||
export default class DiscussionList extends Component {
|
||||
constructor(props) {
|
||||
|
@ -19,7 +10,6 @@ export default class DiscussionList extends Component {
|
|||
this.loading = m.prop(true);
|
||||
this.moreResults = m.prop(false);
|
||||
this.discussions = m.prop([]);
|
||||
this.subtrees = [];
|
||||
|
||||
this.refresh();
|
||||
|
||||
|
@ -38,10 +28,6 @@ export default class DiscussionList extends Component {
|
|||
return params;
|
||||
}
|
||||
|
||||
willBeRedrawn() {
|
||||
this.subtrees.map(subtree => subtree.invalidate());
|
||||
}
|
||||
|
||||
sortMap() {
|
||||
var map = {};
|
||||
if (this.props.params.q) {
|
||||
|
@ -96,32 +82,16 @@ export default class DiscussionList extends Component {
|
|||
this.loadResults(this.discussions().length).then((results) => this.parseResults(results));
|
||||
}
|
||||
|
||||
initSubtree(discussion) {
|
||||
this.subtrees[discussion.id()] = new SubtreeRetainer(
|
||||
() => discussion.freshness,
|
||||
() => app.session.user() && app.session.user().readTime()
|
||||
);
|
||||
}
|
||||
|
||||
parseResults(results) {
|
||||
m.startComputation();
|
||||
this.loading(false);
|
||||
|
||||
results.forEach(this.initSubtree.bind(this));
|
||||
|
||||
[].push.apply(this.discussions(), results);
|
||||
this.moreResults(!!results.payload.links.next);
|
||||
m.endComputation();
|
||||
return results;
|
||||
}
|
||||
|
||||
markAsRead(discussion) {
|
||||
if (discussion.isUnread()) {
|
||||
discussion.save({ readNumber: discussion.lastPostNumber() });
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
removeDiscussion(discussion) {
|
||||
var index = this.discussions().indexOf(discussion);
|
||||
if (index !== -1) {
|
||||
|
@ -131,57 +101,21 @@ export default class DiscussionList extends Component {
|
|||
|
||||
addDiscussion(discussion) {
|
||||
this.discussions().unshift(discussion);
|
||||
this.initSubtree(discussion);
|
||||
}
|
||||
|
||||
view() {
|
||||
return m('div.discussion-list', [
|
||||
m('ul', [
|
||||
this.discussions().map(discussion => {
|
||||
var startUser = discussion.startUser();
|
||||
var isUnread = discussion.isUnread();
|
||||
var displayUnread = this.countType() !== 'replies' && isUnread;
|
||||
var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
|
||||
var relevantPosts = this.props.params.q ? discussion.relevantPosts() : '';
|
||||
|
||||
var controls = discussion.controls(this).toArray();
|
||||
|
||||
var active = m.route.param('id') === discussion.id();
|
||||
|
||||
var subtree = this.subtrees[discussion.id()];
|
||||
return m('li.discussion-summary'+(isUnread ? '.unread' : '')+(active ? '.active' : ''), {
|
||||
return m('li', {
|
||||
key: discussion.id(),
|
||||
'data-id': discussion.id()
|
||||
}, (subtree && subtree.retain()) || m('div', [
|
||||
controls.length ? DropdownButton.component({
|
||||
items: controls,
|
||||
className: 'contextual-controls',
|
||||
buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
|
||||
menuClass: 'pull-right'
|
||||
}) : '',
|
||||
m((startUser ? 'a' : 'span')+'.author', {
|
||||
href: startUser ? app.route('user', { username: startUser.username() }) : undefined,
|
||||
config: function(element, isInitialized, context) {
|
||||
$(element).tooltip({ placement: 'right' })
|
||||
m.route.apply(this, arguments)
|
||||
},
|
||||
title: 'Started by '+(startUser ? startUser.username() : '[deleted]')+' '+humanTime(discussion.startTime())
|
||||
}, [
|
||||
avatar(startUser, {title: ''})
|
||||
]),
|
||||
m('ul.badges', listItems(discussion.badges().toArray())),
|
||||
m('a.main', {href: app.route('discussion.near', {id: discussion.id(), slug: discussion.slug(), near: jumpTo}), config: m.route}, [
|
||||
m('h3.title', highlight(discussion.title(), this.props.params.q)),
|
||||
m('ul.info', listItems(this.infoItems(discussion).toArray()))
|
||||
]),
|
||||
m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [
|
||||
abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()),
|
||||
m('span.label', displayUnread ? 'unread' : 'replies')
|
||||
]),
|
||||
(relevantPosts && relevantPosts.length)
|
||||
? m('div.relevant-posts', relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q})))
|
||||
: ''
|
||||
]))
|
||||
}, DiscussionListItem.component({
|
||||
discussion,
|
||||
q: this.props.params.q,
|
||||
countType: this.countType(),
|
||||
terminalPostType: this.terminalPostType()
|
||||
}));
|
||||
})
|
||||
]),
|
||||
this.loading()
|
||||
|
@ -193,23 +127,4 @@ export default class DiscussionList extends Component {
|
|||
})) : '')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
Build an item list of info for a discussion listing. By default this is
|
||||
just the first/last post indicator.
|
||||
|
||||
@return {ItemList}
|
||||
*/
|
||||
infoItems(discussion) {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('terminalPost',
|
||||
TerminalPost.component({
|
||||
discussion,
|
||||
lastPost: this.terminalPostType() !== 'start'
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,9 +24,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
|||
this.refresh();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
if (!(app.current instanceof DiscussionPage)) {
|
||||
app.cache.discussionList.subtrees.map(subtree => subtree.invalidate());
|
||||
} else {
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter event is triggered so it doesn't hide
|
||||
}
|
||||
app.pane.enable();
|
||||
|
|
|
@ -33,12 +33,6 @@ export default class IndexPage extends Component {
|
|||
var params = this.params();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
// The discussion list component is stored in the app's cache so that it
|
||||
// can persist across interfaces. Since we will soon be redrawing the
|
||||
// discussion list from scratch, we need to invalidate the component's
|
||||
// subtree cache to ensure that it re-constructs the view.
|
||||
app.cache.discussionList.willBeRedrawn();
|
||||
|
||||
// Compare the requested parameters (sort, search query) to the ones that
|
||||
// are currently present in the cached discussion list. If they differ, we
|
||||
// will clear the cache and set up a new discussion list component with
|
||||
|
|
102
framework/core/js/forum/src/utils/slidable.js
Normal file
102
framework/core/js/forum/src/utils/slidable.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
export default function slidable(element) {
|
||||
var $slidable = $(element);
|
||||
|
||||
var startX;
|
||||
var startY;
|
||||
var couldBeSliding = false;
|
||||
var isSliding = false;
|
||||
var threshold = 50;
|
||||
var pos = 0;
|
||||
|
||||
var underneathLeft;
|
||||
var underneathRight;
|
||||
|
||||
var animatePos = function(pos, options) {
|
||||
options = options || {};
|
||||
options.duration = options.duration || 'fast';
|
||||
options.step = function(pos) {
|
||||
$(this).css('transform', 'translate('+pos+'px, 0)');
|
||||
};
|
||||
|
||||
$slidable.find('.slidable-slider').animate({'background-position-x': pos}, options);
|
||||
};
|
||||
|
||||
var reset = function() {
|
||||
animatePos(0, {
|
||||
complete: function() {
|
||||
$slidable.removeClass('sliding');
|
||||
underneathLeft.hide();
|
||||
underneathRight.hide();
|
||||
isSliding = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$slidable.find('.slidable-slider')
|
||||
.on('touchstart', function(e) {
|
||||
underneathLeft = $slidable.find('.slidable-underneath-left:not(.disabled)');
|
||||
underneathRight = $slidable.find('.slidable-underneath-right:not(.disabled)');
|
||||
|
||||
startX = e.originalEvent.targetTouches[0].clientX;
|
||||
startY = e.originalEvent.targetTouches[0].clientY;
|
||||
|
||||
couldBeSliding = true;
|
||||
console.log('GO')
|
||||
})
|
||||
|
||||
.on('touchmove', function(e) {
|
||||
var newX = e.originalEvent.targetTouches[0].clientX;
|
||||
var newY = e.originalEvent.targetTouches[0].clientY;
|
||||
|
||||
if (couldBeSliding && Math.abs(newX - startX) > Math.abs(newY - startY)) {
|
||||
isSliding = true;
|
||||
}
|
||||
couldBeSliding = false;
|
||||
|
||||
if (isSliding) {
|
||||
pos = newX - startX;
|
||||
|
||||
if (underneathLeft.length) {
|
||||
if (pos > 0 && underneathLeft.hasClass('elastic')) {
|
||||
pos -= pos * 0.5;
|
||||
}
|
||||
underneathLeft.toggle(pos > 0);
|
||||
underneathLeft.find('.icon').css('transform', 'scale('+Math.max(0, Math.min(1, (Math.abs(pos) - 25) / threshold))+')');
|
||||
} else {
|
||||
pos = Math.min(0, pos);
|
||||
}
|
||||
|
||||
if (underneathRight.length) {
|
||||
if (pos < 0 && underneathRight.hasClass('elastic')) {
|
||||
pos -= pos * 0.5;
|
||||
}
|
||||
underneathRight.toggle(pos < 0);
|
||||
underneathRight.find('.icon').css('transform', 'scale('+Math.max(0, Math.min(1, (Math.abs(pos) - 25) / threshold))+')');
|
||||
} else {
|
||||
pos = Math.max(0, pos);
|
||||
}
|
||||
|
||||
$(this).css('transform', 'translate('+pos+'px, 0)');
|
||||
$(this).css('background-position-x', pos+'px');
|
||||
|
||||
$slidable.toggleClass('sliding', !!pos);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
})
|
||||
|
||||
.on('touchend', function(e) {
|
||||
if (underneathRight.length && pos < -threshold) {
|
||||
underneathRight.click();
|
||||
underneathRight.hasClass('elastic') ? reset() : animatePos(-$slidable.width());
|
||||
} else if (underneathLeft.length && pos > threshold) {
|
||||
underneathLeft.click();
|
||||
underneathLeft.hasClass('elastic') ? reset() : animatePos(-$slidable.width());
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
couldBeSliding = false;
|
||||
});
|
||||
|
||||
return {reset};
|
||||
};
|
|
@ -18,14 +18,6 @@ export default class Component {
|
|||
return selector ? $(this.element()).find(selector) : $(this.element());
|
||||
}
|
||||
|
||||
onload(element) {
|
||||
this.element(element);
|
||||
}
|
||||
|
||||
config() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
|
@ -38,13 +30,12 @@ export default class Component {
|
|||
component.props = props;
|
||||
var vdom = component.view();
|
||||
vdom.attrs = vdom.attrs || {};
|
||||
if (!vdom.attrs.config) {
|
||||
var oldConfig = vdom.attrs.config;
|
||||
vdom.attrs.config = function() {
|
||||
var args = [].slice.apply(arguments);
|
||||
if (!args[1]) {
|
||||
component.onload.apply(component, args);
|
||||
}
|
||||
component.config.apply(component, args);
|
||||
component.element(args[0]);
|
||||
if (oldConfig) {
|
||||
oldConfig.apply(component, args);
|
||||
}
|
||||
}
|
||||
return vdom;
|
||||
|
|
|
@ -10,7 +10,7 @@ export default class DropdownButton extends Component {
|
|||
'data-toggle': 'dropdown',
|
||||
onclick: this.props.buttonClick
|
||||
}, this.props.buttonContent || [
|
||||
icon((this.props.icon || 'ellipsis-v')+' icon-glyph'),
|
||||
icon((this.props.icon || 'ellipsis-v')+' icon-glyph icon'),
|
||||
m('span.label', this.props.label || 'Controls'),
|
||||
icon('caret-down icon-caret')
|
||||
]),
|
||||
|
|
|
@ -83,16 +83,18 @@
|
|||
& .hero, & .index-nav, & .index-toolbar {
|
||||
display: none;
|
||||
}
|
||||
& .discussion-list > ul > li {
|
||||
& .discussion-list-item {
|
||||
margin: 0;
|
||||
padding-left: 57px + 15px;
|
||||
padding-right: 65px + 15px;
|
||||
padding: 0;
|
||||
|
||||
&.active {
|
||||
background: @fl-body-control-bg;
|
||||
}
|
||||
}
|
||||
& .discussion-summary {
|
||||
padding-left: 57px + 15px;
|
||||
padding-right: 65px + 15px;
|
||||
|
||||
& .title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
@ -148,19 +150,6 @@
|
|||
@media @phone {
|
||||
.discussion-list {
|
||||
margin: 0 -15px;
|
||||
|
||||
& > ul > li {
|
||||
& .contextual-controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @tablet, @desktop, @desktop-hd {
|
||||
.discussion-list > ul > li {
|
||||
margin-right: -25px;
|
||||
padding-right: 65px + 25px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -171,15 +160,6 @@
|
|||
color: @fl-body-muted-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
& .contextual-controls {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 13px;
|
||||
}
|
||||
&:hover .contextual-controls, & .contextual-controls.open {
|
||||
visibility: visible;
|
||||
}
|
||||
& .author {
|
||||
float: left;
|
||||
margin-top: 16px;
|
||||
|
@ -308,12 +288,16 @@
|
|||
color: @fl-body-control-color;
|
||||
border-radius: @border-radius-base;
|
||||
font-size: 12px;
|
||||
padding: 1px 6px;
|
||||
padding: 2px 6px;
|
||||
|
||||
.unread& {
|
||||
background: @fl-body-primary-color;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
|
||||
&:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
& .label {
|
||||
|
@ -323,7 +307,79 @@
|
|||
}
|
||||
}
|
||||
|
||||
.slidable {
|
||||
position: relative;
|
||||
|
||||
& .contextual-controls {
|
||||
display: block;
|
||||
position: static;
|
||||
}
|
||||
|
||||
& .slidable-underneath {
|
||||
display: none;
|
||||
background: @fl-secondary-color !important;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
z-index: 0;
|
||||
color: #fff !important;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
.box-shadow(none);
|
||||
padding: 20px 0;
|
||||
text-align: right;
|
||||
|
||||
&.slidable-underneath-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
& .icon {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
& .slidable-slider {
|
||||
.transition(~"box-shadow 0.2s, border-radius 0.2s");
|
||||
|
||||
.sliding& {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
z-index: 2;
|
||||
border-radius: 2px;
|
||||
.box-shadow(0 2px 6px @fl-shadow-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @tablet, @desktop, @desktop-hd {
|
||||
.slidable-underneath {
|
||||
display: none;
|
||||
}
|
||||
.discussion-list-item {
|
||||
position: relative;
|
||||
margin-right: -25px;
|
||||
padding-right: 25px;
|
||||
|
||||
& .contextual-controls {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 8px;
|
||||
z-index: 1;
|
||||
|
||||
& .dropdown-toggle {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover .contextual-controls, & .contextual-controls.open {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
.discussion-summary {
|
||||
padding-left: 57px;
|
||||
padding-right: 65px;
|
||||
|
|
Loading…
Reference in New Issue
Block a user