From 972bd24c7a86cb67db4f66506bd30330c9d1491b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 24 Jun 2015 17:56:39 +0930 Subject: [PATCH] Discussion list refactor, gestures Also make base Component class automatically assign this.element :) --- .../src/components/discussion-list-item.js | 128 ++++++++++++++++++ .../forum/src/components/discussion-list.js | 101 ++------------ .../forum/src/components/discussion-page.js | 4 +- .../js/forum/src/components/index-page.js | 6 - framework/core/js/forum/src/utils/slidable.js | 102 ++++++++++++++ framework/core/js/lib/component.js | 21 +-- .../core/js/lib/components/dropdown-button.js | 2 +- framework/core/less/forum/index.less | 108 +++++++++++---- 8 files changed, 328 insertions(+), 144 deletions(-) create mode 100644 framework/core/js/forum/src/components/discussion-list-item.js create mode 100644 framework/core/js/forum/src/utils/slidable.js diff --git a/framework/core/js/forum/src/components/discussion-list-item.js b/framework/core/js/forum/src/components/discussion-list-item.js new file mode 100644 index 000000000..78d4bdc5c --- /dev/null +++ b/framework/core/js/forum/src/components/discussion-list-item.js @@ -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(); + }); + } + } +}; diff --git a/framework/core/js/forum/src/components/discussion-list.js b/framework/core/js/forum/src/components/discussion-list.js index d6ba446f3..aef43d3ad 100644 --- a/framework/core/js/forum/src/components/discussion-list.js +++ b/framework/core/js/forum/src/components/discussion-list.js @@ -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; - } } diff --git a/framework/core/js/forum/src/components/discussion-page.js b/framework/core/js/forum/src/components/discussion-page.js index 30f3be46a..845df884e 100644 --- a/framework/core/js/forum/src/components/discussion-page.js +++ b/framework/core/js/forum/src/components/discussion-page.js @@ -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(); diff --git a/framework/core/js/forum/src/components/index-page.js b/framework/core/js/forum/src/components/index-page.js index fb9c03e58..feb44b914 100644 --- a/framework/core/js/forum/src/components/index-page.js +++ b/framework/core/js/forum/src/components/index-page.js @@ -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 diff --git a/framework/core/js/forum/src/utils/slidable.js b/framework/core/js/forum/src/utils/slidable.js new file mode 100644 index 000000000..362d206c5 --- /dev/null +++ b/framework/core/js/forum/src/utils/slidable.js @@ -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}; +}; diff --git a/framework/core/js/lib/component.js b/framework/core/js/lib/component.js index 8a2d75048..741f55ef4 100644 --- a/framework/core/js/lib/component.js +++ b/framework/core/js/lib/component.js @@ -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) { - vdom.attrs.config = function() { - var args = [].slice.apply(arguments); - if (!args[1]) { - component.onload.apply(component, args); - } - component.config.apply(component, args); + var oldConfig = vdom.attrs.config; + vdom.attrs.config = function() { + var args = [].slice.apply(arguments); + component.element(args[0]); + if (oldConfig) { + oldConfig.apply(component, args); } } return vdom; diff --git a/framework/core/js/lib/components/dropdown-button.js b/framework/core/js/lib/components/dropdown-button.js index 94afe36e4..7aacba82d 100644 --- a/framework/core/js/lib/components/dropdown-button.js +++ b/framework/core/js/lib/components/dropdown-button.js @@ -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') ]), diff --git a/framework/core/less/forum/index.less b/framework/core/less/forum/index.less index 652c6992c..98cc135cc 100644 --- a/framework/core/less/forum/index.less +++ b/framework/core/less/forum/index.less @@ -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;