Discussion list refactor, gestures

Also make base Component class automatically assign this.element :)
This commit is contained in:
Toby Zerner 2015-06-24 17:56:39 +09:30
parent 748abd9b0b
commit 972bd24c7a
8 changed files with 328 additions and 144 deletions

View 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();
});
}
}
};

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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

View 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};
};

View File

@ -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;

View File

@ -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')
]),

View File

@ -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;