mirror of
https://github.com/flarum/core.git
synced 2025-08-03 23:17:43 +02:00
Massive JavaScript cleanup
- Use JSX for templates - Docblock/comment everything - Mostly passes ESLint (still some work to do) - Lots of renaming, refactoring, etc. CSS hasn't been updated yet.
This commit is contained in:
214
js/forum/src/utils/DiscussionControls.js
Normal file
214
js/forum/src/utils/DiscussionControls.js
Normal file
@@ -0,0 +1,214 @@
|
||||
import DiscussionPage from 'flarum/components/DiscussionPage';
|
||||
import ReplyComposer from 'flarum/components/ReplyComposer';
|
||||
import LogInModal from 'flarum/components/LogInModal';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Separator from 'flarum/components/Separator';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `DiscussionControls` utility constructs a list of buttons for a
|
||||
* discussion which perform actions on it.
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* Get a list of controls for a discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
controls(discussion, context) {
|
||||
const items = new ItemList();
|
||||
|
||||
['user', 'moderation', 'destructive'].forEach(section => {
|
||||
const controls = this[section + 'Controls'](discussion, context).toArray();
|
||||
if (controls.length) {
|
||||
items.add(section, controls);
|
||||
items.add(section + 'Separator', Separator.component());
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a discussion pertaining to the current user (e.g. reply,
|
||||
* follow).
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
userControls(discussion, context) {
|
||||
const items = new ItemList();
|
||||
|
||||
// Only add a reply control if this is the discussion's controls dropdown
|
||||
// for the discussion page itself. We don't want it to show up for
|
||||
// discussions in the discussion list, etc.
|
||||
if (context instanceof DiscussionPage) {
|
||||
items.add('reply',
|
||||
!app.session.user || discussion.canReply()
|
||||
? Button.component({
|
||||
icon: 'reply',
|
||||
children: app.session.user ? 'Reply' : 'Log In to Reply',
|
||||
onclick: this.replyAction.bind(discussion, true, false)
|
||||
})
|
||||
: Button.component({
|
||||
icon: 'reply',
|
||||
children: 'Can\'t Reply',
|
||||
className: 'disabled',
|
||||
title: 'You don\'t have permission to reply to this discussion.'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a discussion pertaining to moderation (e.g. rename, lock).
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
moderationControls(discussion) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (discussion.canRename()) {
|
||||
items.add('rename', Button.component({
|
||||
icon: 'pencil',
|
||||
children: 'Rename',
|
||||
onclick: this.renameAction.bind(discussion)
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a discussion which are destructive (e.g. delete).
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
destructiveControls(discussion) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (discussion.canDelete()) {
|
||||
items.add('delete', Button.component({
|
||||
icon: 'times',
|
||||
children: 'Delete',
|
||||
onclick: this.deleteAction.bind(discussion)
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the reply composer for the discussion. A promise will be returned,
|
||||
* which resolves when the composer opens successfully. If the user is not
|
||||
* logged in, they will be prompted and then the reply composer will open (and
|
||||
* the promise will resolve) after they do. If they don't have permission to
|
||||
* reply, the promise will be rejected.
|
||||
*
|
||||
* @param {Boolean} goToLast Whether or not to scroll down to the last post if
|
||||
* the discussion is being viewed.
|
||||
* @param {Boolean} forceRefresh Whether or not to force a reload of the
|
||||
* composer component, even if it is already open for this discussion.
|
||||
* @return {Promise}
|
||||
*/
|
||||
replyAction(goToLast, forceRefresh) {
|
||||
const deferred = m.deferred();
|
||||
|
||||
// Define a function that will check the user's permission to reply, and
|
||||
// either open the reply composer for this discussion and resolve the
|
||||
// promise, or reject it.
|
||||
const reply = () => {
|
||||
if (this.canReply()) {
|
||||
if (goToLast && app.viewingDiscussion(this)) {
|
||||
app.current.stream.goToLast();
|
||||
}
|
||||
|
||||
let component = app.composer.component;
|
||||
if (!app.composingReplyTo(this) || forceRefresh) {
|
||||
component = new ReplyComposer({
|
||||
user: app.session.user,
|
||||
discussion: this
|
||||
});
|
||||
app.composer.load(component);
|
||||
}
|
||||
app.composer.show();
|
||||
|
||||
deferred.resolve(component);
|
||||
} else {
|
||||
deferred.reject();
|
||||
}
|
||||
};
|
||||
|
||||
// If the user is logged in, then we can run that function right away. But
|
||||
// if they're not, we'll prompt them to log in and then run the function
|
||||
// after the discussion has reloaded.
|
||||
if (app.session.user) {
|
||||
reply();
|
||||
} else {
|
||||
app.modal.show(
|
||||
new LogInModal({
|
||||
onlogin: () => app.current.one('loaded', reply)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the discussion after confirming with the user.
|
||||
*/
|
||||
deleteAction() {
|
||||
if (confirm('Are you sure you want to delete this discussion?')) {
|
||||
this.delete();
|
||||
|
||||
// If there is a discussion list in the cache, remove this discussion.
|
||||
if (app.cache.discussionList) {
|
||||
app.cache.discussionList.removeDiscussion(this);
|
||||
}
|
||||
|
||||
// If we're currently viewing the discussion that was deleted, go back
|
||||
// to the previous page.
|
||||
if (app.viewingDiscussion(this)) {
|
||||
app.history.back();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename the discussion.
|
||||
*/
|
||||
renameAction() {
|
||||
const currentTitle = this.title();
|
||||
const title = prompt('Enter a new title for this discussion:', currentTitle);
|
||||
|
||||
// If the title is different to what it was before, then save it. After the
|
||||
// save has completed, update the post stream as there will be a new post
|
||||
// indicating that the discussion was renamed.
|
||||
if (title && title !== currentTitle) {
|
||||
this.save({title}).then(() => {
|
||||
if (app.viewingDiscussion(this)) {
|
||||
app.current.stream.update();
|
||||
}
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
140
js/forum/src/utils/PostControls.js
Normal file
140
js/forum/src/utils/PostControls.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import EditPostComposer from 'flarum/components/EditPostComposer';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Separator from 'flarum/components/Separator';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `PostControls` utility constructs a list of buttons for a post which
|
||||
* perform actions on it.
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* Get a list of controls for a post.
|
||||
*
|
||||
* @param {Post} post
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
controls(post, context) {
|
||||
const items = new ItemList();
|
||||
|
||||
['user', 'moderation', 'destructive'].forEach(section => {
|
||||
const controls = this[section + 'Controls'](post, context).toArray();
|
||||
if (controls.length) {
|
||||
items.add(section, controls);
|
||||
items.add(section + 'Separator', Separator.component());
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a post pertaining to the current user (e.g. report).
|
||||
*
|
||||
* @param {Post} post
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
userControls() {
|
||||
return new ItemList();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a post pertaining to moderation (e.g. edit).
|
||||
*
|
||||
* @param {Post} post
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
moderationControls(post) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (post.contentType() === 'comment' && post.canEdit()) {
|
||||
if (post.isHidden()) {
|
||||
items.add('restore', Button.component({
|
||||
icon: 'reply',
|
||||
children: 'Restore',
|
||||
onclick: this.restoreAction.bind(post)
|
||||
}));
|
||||
} else {
|
||||
items.add('edit', Button.component({
|
||||
icon: 'pencil',
|
||||
children: 'Edit',
|
||||
onclick: this.editAction.bind(post)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a post that are destructive (e.g. delete).
|
||||
*
|
||||
* @param {Post} post
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
destructiveControls(post) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (post.number() !== 1) {
|
||||
if (post.contentType() === 'comment' && !post.isHidden() && post.canEdit()) {
|
||||
items.add('hide', Button.component({
|
||||
icon: 'times',
|
||||
children: 'Delete',
|
||||
onclick: this.hideAction.bind(post)
|
||||
}));
|
||||
} else if ((post.contentType() !== 'comment' || post.isHidden()) && post.canDelete()) {
|
||||
items.add('delete', Button.component({
|
||||
icon: 'times',
|
||||
children: 'Delete Forever',
|
||||
onclick: this.deleteAction.bind(post)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the composer to edit a post.
|
||||
*/
|
||||
editAction() {
|
||||
app.composer.load(new EditPostComposer({ post: this }));
|
||||
app.composer.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide a post.
|
||||
*/
|
||||
hideAction() {
|
||||
this.save({ isHidden: true });
|
||||
this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user });
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore a post.
|
||||
*/
|
||||
restoreAction() {
|
||||
this.save({ isHidden: false });
|
||||
this.pushAttributes({ hideTime: null, hideUser: null });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a post.
|
||||
*/
|
||||
deleteAction() {
|
||||
this.delete();
|
||||
this.discussion().removePost(this.id());
|
||||
}
|
||||
};
|
105
js/forum/src/utils/UserControls.js
Normal file
105
js/forum/src/utils/UserControls.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import Button from 'flarum/components/Button';
|
||||
import Separator from 'flarum/components/Separator';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `UserControls` utility constructs a list of buttons for a user which
|
||||
* perform actions on it.
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* Get a list of controls for a user.
|
||||
*
|
||||
* @param {User} user
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
controls(discussion, context) {
|
||||
const items = new ItemList();
|
||||
|
||||
['user', 'moderation', 'destructive'].forEach(section => {
|
||||
const controls = this[section + 'Controls'](discussion, context).toArray();
|
||||
if (controls.length) {
|
||||
items.add(section, controls);
|
||||
items.add(section + 'Separator', Separator.component());
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a user pertaining to the current user (e.g. poke, follow).
|
||||
*
|
||||
* @param {User} user
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
userControls() {
|
||||
return new ItemList();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a user pertaining to moderation (e.g. suspend, edit).
|
||||
*
|
||||
* @param {User} user
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
moderationControls(user) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (user.canEdit()) {
|
||||
items.add('edit', Button.component({
|
||||
icon: 'pencil',
|
||||
children: 'Edit',
|
||||
onclick: this.editAction.bind(user)
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a user which are destructive (e.g. delete).
|
||||
*
|
||||
* @param {User} user
|
||||
* @param {*} context The parent component under which the controls menu will
|
||||
* be displayed.
|
||||
* @return {ItemList}
|
||||
* @protected
|
||||
*/
|
||||
destructiveControls(user) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (user.canDelete()) {
|
||||
items.add('delete', Button.component({
|
||||
icon: 'times',
|
||||
children: 'Delete',
|
||||
onclick: this.deleteAction.bind(user)
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the user.
|
||||
*/
|
||||
deleteAction() {
|
||||
// TODO
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit the user.
|
||||
*/
|
||||
editAction() {
|
||||
// TODO
|
||||
}
|
||||
};
|
25
js/forum/src/utils/affixSidebar.js
Normal file
25
js/forum/src/utils/affixSidebar.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Setup the sidebar DOM element to be affixed to the top of the viewport
|
||||
* using Bootstrap's affix plugin.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
*/
|
||||
export default function affixSidebar(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const $sidebar = $(element);
|
||||
const $header = $('.global-header');
|
||||
const $footer = $('.global-footer');
|
||||
|
||||
// Don't affix the sidebar if it is taller than the viewport (otherwise
|
||||
// there would be no way to scroll through its content).
|
||||
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
|
||||
|
||||
$sidebar.find('> ul').affix({
|
||||
offset: {
|
||||
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
|
||||
bottom: () => this.bottom = $footer.outerHeight(true)
|
||||
}
|
||||
});
|
||||
}
|
@@ -1,12 +1,53 @@
|
||||
/**
|
||||
* The `Drawer` class controls the page's drawer. The drawer is the area the
|
||||
* slides out from the left on mobile devices; it contains the header and the
|
||||
* footer.
|
||||
*/
|
||||
export default class Drawer {
|
||||
constructor() {
|
||||
// Set up an event handler so that whenever the content area is tapped,
|
||||
// the drawer will close.
|
||||
$('.global-content').click(e => {
|
||||
if (this.isOpen()) {
|
||||
e.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the drawer is currently open.
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
*/
|
||||
isOpen() {
|
||||
return $('body').hasClass('drawer-open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the drawer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
hide() {
|
||||
$('body').removeClass('drawer-open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the drawer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show() {
|
||||
$('body').addClass('drawer-open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the drawer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toggle() {
|
||||
$('body').toggleClass('drawer-open');
|
||||
}
|
||||
|
@@ -1,42 +1,98 @@
|
||||
/**
|
||||
* The `History` class keeps track and manages a stack of routes that the user
|
||||
* has navigated to in their session.
|
||||
*
|
||||
* An item can be pushed to the top of the stack using the `push` method. An
|
||||
* item in the stack has a name and a URL. The name need not be unique; if it is
|
||||
* the same as the item before it, that will be overwritten with the new URL. In
|
||||
* this way, if a user visits a discussion, and then visits another discussion,
|
||||
* popping the history stack will still take them back to the discussion list
|
||||
* rather than the previous discussion.
|
||||
*/
|
||||
export default class History {
|
||||
constructor() {
|
||||
/**
|
||||
* The stack of routes that have been navigated to.
|
||||
*
|
||||
* @type {Array}
|
||||
* @protected
|
||||
*/
|
||||
this.stack = [];
|
||||
|
||||
// Push the homepage as the first route, so that the user will always be
|
||||
// able to click on the 'back' button to go home, regardless of which page
|
||||
// they started on.
|
||||
this.push('index', '/');
|
||||
}
|
||||
|
||||
top() {
|
||||
/**
|
||||
* Get the item on the top of the stack.
|
||||
*
|
||||
* @return {Object}
|
||||
* @protected
|
||||
*/
|
||||
getTop() {
|
||||
return this.stack[this.stack.length - 1];
|
||||
}
|
||||
|
||||
push(name, url) {
|
||||
var url = url || m.route();
|
||||
|
||||
// maybe? prevents browser back button from breaking history
|
||||
var secondTop = this.stack[this.stack.length - 2];
|
||||
/**
|
||||
* Push an item to the top of the stack.
|
||||
*
|
||||
* @param {String} name The name of the route.
|
||||
* @param {String} [url] The URL of the route. The current URL will be used if
|
||||
* not provided.
|
||||
* @public
|
||||
*/
|
||||
push(name, url = m.route()) {
|
||||
// If we're pushing an item with the same name as second-to-top item in the
|
||||
// stack, we will assume that the user has clicked the 'back' button in
|
||||
// their browser. In this case, we don't want to push a new item, so we will
|
||||
// pop off the top item, and then the second-to-top item will be overwritten
|
||||
// below.
|
||||
const secondTop = this.stack[this.stack.length - 2];
|
||||
if (secondTop && secondTop.name === name) {
|
||||
this.stack.pop();
|
||||
}
|
||||
|
||||
var top = this.top();
|
||||
// If we're pushing an item with the same name as the top item in the stack,
|
||||
// then we'll overwrite it with the new URL.
|
||||
const top = this.getTop();
|
||||
if (top && top.name === name) {
|
||||
top.url = url;
|
||||
} else {
|
||||
this.stack.push({name: name, url: url});
|
||||
this.stack.push({name, url});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the history stack is able to be popped.
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
*/
|
||||
canGoBack() {
|
||||
return this.stack.length > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to the previous route in the history stack.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
back() {
|
||||
this.stack.pop();
|
||||
var top = this.top();
|
||||
m.route(top.url);
|
||||
|
||||
m.route(this.getTop().url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the first route in the history stack.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
home() {
|
||||
this.stack.splice(1);
|
||||
m.route('/');
|
||||
|
||||
m.route(this.stack[0].url);
|
||||
}
|
||||
}
|
||||
|
@@ -1,46 +1,125 @@
|
||||
/**
|
||||
* The `Pane` class manages the page's discussion list sidepane. The pane is a
|
||||
* part of the content view (DiscussionPage component), but its visibility is
|
||||
* determined by CSS classes applied to the outer page element. This class
|
||||
* manages the application of those CSS classes.
|
||||
*/
|
||||
export default class Pane {
|
||||
constructor(element) {
|
||||
/**
|
||||
* The localStorage key to store the pane's pinned state with.
|
||||
*
|
||||
* @type {String}
|
||||
* @protected
|
||||
*/
|
||||
this.pinnedKey = 'panePinned';
|
||||
|
||||
/**
|
||||
* The page element.
|
||||
*
|
||||
* @type {jQuery}
|
||||
* @protected
|
||||
*/
|
||||
this.$element = $(element);
|
||||
|
||||
/**
|
||||
* Whether or not the pane is currently pinned.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @protected
|
||||
*/
|
||||
this.pinned = localStorage.getItem(this.pinnedKey) === 'true';
|
||||
|
||||
/**
|
||||
* Whether or not the pane is currently exists.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @protected
|
||||
*/
|
||||
this.active = false;
|
||||
|
||||
/**
|
||||
* Whether or not the pane is currently showing, or is hidden off the edge
|
||||
* of the screen.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @protected
|
||||
*/
|
||||
this.showing = false;
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the pane.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
enable() {
|
||||
this.active = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the pane.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
disable() {
|
||||
this.active = false;
|
||||
this.showing = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the pane.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show() {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.showing = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the pane.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
hide() {
|
||||
this.showing = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a timeout to hide the pane, which can be cancelled by showing the
|
||||
* pane.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
onmouseleave() {
|
||||
this.hideTimeout = setTimeout(this.hide.bind(this), 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether or not the pane is pinned.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
togglePinned() {
|
||||
localStorage.setItem(this.pinnedKey, (this.pinned = !this.pinned) ? 'true' : 'false');
|
||||
this.pinned = !this.pinned;
|
||||
|
||||
localStorage.setItem(this.pinnedKey, this.pinned ? 'true' : 'false');
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the appropriate CSS classes to the page element.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
render() {
|
||||
this.$element
|
||||
.toggleClass('pane-pinned', this.pinned)
|
||||
|
@@ -1,41 +1,68 @@
|
||||
/**
|
||||
* The `slidable` utility adds touch gestures to an element so that it can be
|
||||
* slid away to reveal controls underneath, and then released to activate those
|
||||
* controls.
|
||||
*
|
||||
* It relies on the element having children with particular CSS classes.
|
||||
* TODO: document
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @return {Object}
|
||||
* @property {function} reset Revert the slider to its original position. This
|
||||
* should be called, for example, when a controls dropdown is closed.
|
||||
*/
|
||||
export default function slidable(element) {
|
||||
var $slidable = $(element);
|
||||
const $element = $(element);
|
||||
const threshold = 50;
|
||||
|
||||
var startX;
|
||||
var startY;
|
||||
var couldBeSliding = false;
|
||||
var isSliding = false;
|
||||
var threshold = 50;
|
||||
var pos = 0;
|
||||
let $underneathLeft;
|
||||
let $underneathRight;
|
||||
|
||||
var underneathLeft;
|
||||
var underneathRight;
|
||||
let startX;
|
||||
let startY;
|
||||
let couldBeSliding = false;
|
||||
let isSliding = false;
|
||||
let pos = 0;
|
||||
|
||||
var animatePos = function(pos, options) {
|
||||
options = options || {};
|
||||
/**
|
||||
* Animate the slider to a new position.
|
||||
*
|
||||
* @param {Integer} newPos
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
const animatePos = (newPos, options = {}) => {
|
||||
// Since we can't animate the transform property with jQuery, we'll use a
|
||||
// bit of a workaround. We set up the animation with a step function that
|
||||
// will set the transform property, but then we animate an unused property
|
||||
// (background-position-x) with jQuery.
|
||||
options.duration = options.duration || 'fast';
|
||||
options.step = function(pos) {
|
||||
$(this).css('transform', 'translate('+pos+'px, 0)');
|
||||
options.step = function(x) {
|
||||
$(this).css('transform', 'translate(' + x + 'px, 0)');
|
||||
};
|
||||
|
||||
$slidable.find('.slidable-slider').animate({'background-position-x': pos}, options);
|
||||
$element.find('.slidable-slider').animate({'background-position-x': newPos}, options);
|
||||
};
|
||||
|
||||
var reset = function() {
|
||||
/**
|
||||
* Revert the slider to its original position.
|
||||
*/
|
||||
const reset = () => {
|
||||
animatePos(0, {
|
||||
complete: function() {
|
||||
$slidable.removeClass('sliding');
|
||||
underneathLeft.hide();
|
||||
underneathRight.hide();
|
||||
$element.removeClass('sliding');
|
||||
$underneathLeft.hide();
|
||||
$underneathRight.hide();
|
||||
isSliding = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$slidable.find('.slidable-slider')
|
||||
$element.find('.slidable-slider')
|
||||
.on('touchstart', function(e) {
|
||||
underneathLeft = $slidable.find('.slidable-underneath-left:not(.disabled)');
|
||||
underneathRight = $slidable.find('.slidable-underneath-right:not(.disabled)');
|
||||
// Update the references to the elements underneath the slider, provided
|
||||
// they're not disabled.
|
||||
$underneathLeft = $element.find('.slidable-underneath-left:not(.disabled)');
|
||||
$underneathRight = $element.find('.slidable-underneath-right:not(.disabled)');
|
||||
|
||||
startX = e.originalEvent.targetTouches[0].clientX;
|
||||
startY = e.originalEvent.targetTouches[0].clientY;
|
||||
@@ -44,9 +71,13 @@ export default function slidable(element) {
|
||||
})
|
||||
|
||||
.on('touchmove', function(e) {
|
||||
var newX = e.originalEvent.targetTouches[0].clientX;
|
||||
var newY = e.originalEvent.targetTouches[0].clientY;
|
||||
const newX = e.originalEvent.targetTouches[0].clientX;
|
||||
const newY = e.originalEvent.targetTouches[0].clientY;
|
||||
|
||||
// Once the user moves their touch in a direction that's more up/down than
|
||||
// left/right, we'll assume they're scrolling the page. But if they do
|
||||
// move in a horizontal direction at first, then we'll lock their touch
|
||||
// into the slider.
|
||||
if (couldBeSliding && Math.abs(newX - startX) > Math.abs(newY - startY)) {
|
||||
isSliding = true;
|
||||
}
|
||||
@@ -55,45 +86,59 @@ export default function slidable(element) {
|
||||
if (isSliding) {
|
||||
pos = newX - startX;
|
||||
|
||||
if (underneathLeft.length) {
|
||||
if (pos > 0 && underneathLeft.hasClass('elastic')) {
|
||||
pos -= pos * 0.5;
|
||||
// If there are controls underneath the either side, then we'll show/hide
|
||||
// them depending on the slider's position. We also make the controls
|
||||
// icon get a bit bigger the further they slide.
|
||||
const toggle = ($underneath, active) => {
|
||||
if ($underneath.length) {
|
||||
if (active && $underneath.hasClass('elastic')) {
|
||||
pos -= pos * 0.5;
|
||||
}
|
||||
$underneath.toggle(active);
|
||||
|
||||
const scale = Math.max(0, Math.min(1, (Math.abs(pos) - 25) / threshold));
|
||||
$underneath.find('.icon').css('transform', 'scale(' + scale + ')');
|
||||
} else {
|
||||
pos = Math.min(0, pos);
|
||||
}
|
||||
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);
|
||||
}
|
||||
toggle($underneathLeft, pos > 0);
|
||||
toggle($underneathRight, pos < 0);
|
||||
|
||||
$(this).css('transform', 'translate('+pos+'px, 0)');
|
||||
$(this).css('background-position-x', pos+'px');
|
||||
$(this).css('transform', 'translate(' + pos + 'px, 0)');
|
||||
$(this).css('background-position-x', pos + 'px');
|
||||
|
||||
$slidable.toggleClass('sliding', !!pos);
|
||||
$element.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());
|
||||
.on('touchend', function() {
|
||||
// If the user releases the touch and the slider is past the threshold
|
||||
// position on either side, then we will activate the control for that
|
||||
// side. We will also animate the slider's position all the way to the
|
||||
// other side, or back to its original position, depending on whether or
|
||||
// not the side is 'elastic'.
|
||||
const activate = $underneath => {
|
||||
$underneath.click();
|
||||
|
||||
if ($underneath.hasClass('elastic')) {
|
||||
reset();
|
||||
} else {
|
||||
animatePos((pos > 0 ? 1 : -1) * $element.width());
|
||||
}
|
||||
};
|
||||
|
||||
if ($underneathRight.length && pos < -threshold) {
|
||||
activate($underneathRight);
|
||||
} else if ($underneathLeft.length && pos > threshold) {
|
||||
activate($underneathLeft);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
|
||||
couldBeSliding = false;
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user