- {avatar(this.props.user, { className: 'ComposerBody-avatar' })}
-
-
{listItems(this.headerItems().toArray())}
-
{this.editor.render()}
+
+
+ {avatar(this.props.user, { className: 'ComposerBody-avatar' })}
+
+
{listItems(this.headerItems().toArray())}
+
+ {TextEditor.component({
+ submitLabel: this.props.submitLabel,
+ placeholder: this.props.placeholder,
+ disabled: this.loading || this.props.disabled,
+ composer: this.composer,
+ preview: this.jumpToPreview && this.jumpToPreview.bind(this),
+ onchange: this.composer.fields.content,
+ onsubmit: this.onsubmit.bind(this),
+ value: this.composer.fields.content(),
+ })}
+
+
+ {LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
- {LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
-
+
);
}
/**
- * Draw focus to the text editor.
- */
- focus() {
- this.$(':input:enabled:visible:first').focus();
- }
-
- /**
- * Check if there is any unsaved data – if there is, return a confirmation
- * message to prompt the user with.
+ * Check if there is any unsaved data.
*
* @return {String}
*/
- preventExit() {
- const content = this.content();
+ hasChanges() {
+ const content = this.composer.fields.content();
- return content && content !== this.props.originalContent && this.props.confirmExit;
+ return content && content !== this.props.originalContent;
}
/**
diff --git a/js/src/forum/components/DiscussionComposer.js b/js/src/forum/components/DiscussionComposer.js
index f8123d625..57cda5767 100644
--- a/js/src/forum/components/DiscussionComposer.js
+++ b/js/src/forum/components/DiscussionComposer.js
@@ -16,12 +16,14 @@ export default class DiscussionComposer extends ComposerBody {
init() {
super.init();
+ this.composer.fields.title = this.composer.fields.title || m.prop('');
+
/**
* The value of the title input.
*
* @type {Function}
*/
- this.title = m.prop('');
+ this.title = this.composer.fields.title;
}
static initProps(props) {
@@ -66,14 +68,14 @@ export default class DiscussionComposer extends ComposerBody {
if (e.which === 13) {
// Return
e.preventDefault();
- this.editor.setSelectionRange(0, 0);
+ this.composer.editor.moveCursorTo(0);
}
m.redraw.strategy('none');
}
- preventExit() {
- return (this.title() || this.content()) && this.props.confirmExit;
+ hasChanges() {
+ return this.title() || this.composer.fields.content();
}
/**
@@ -84,7 +86,7 @@ export default class DiscussionComposer extends ComposerBody {
data() {
return {
title: this.title(),
- content: this.content(),
+ content: this.composer.fields.content(),
};
}
@@ -97,7 +99,7 @@ export default class DiscussionComposer extends ComposerBody {
.createRecord('discussions')
.save(data)
.then((discussion) => {
- app.composer.hide();
+ this.composer.hide();
app.discussions.refresh();
m.route(app.route.discussion(discussion));
}, this.loaded.bind(this));
diff --git a/js/src/forum/components/DiscussionPage.js b/js/src/forum/components/DiscussionPage.js
index 27cecc520..1d1a6678d 100644
--- a/js/src/forum/components/DiscussionPage.js
+++ b/js/src/forum/components/DiscussionPage.js
@@ -79,7 +79,7 @@ export default class DiscussionPage extends Page {
// we'll just close it.
app.pane.disable();
- if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
+ if (app.composer.composingReplyTo(this.discussion) && !app.composer.fields.content()) {
app.composer.hide();
} else {
app.composer.minimize();
diff --git a/js/src/forum/components/EditPostComposer.js b/js/src/forum/components/EditPostComposer.js
index 258e8ecc3..a49d133f0 100644
--- a/js/src/forum/components/EditPostComposer.js
+++ b/js/src/forum/components/EditPostComposer.js
@@ -20,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
* - `post`
*/
export default class EditPostComposer extends ComposerBody {
- init() {
- super.init();
-
- this.editor.props.preview = (e) => {
- minimizeComposerIfFullScreen(e);
-
- m.route(app.route.post(this.props.post));
- };
- }
-
static initProps(props) {
super.initProps(props);
@@ -64,6 +54,15 @@ export default class EditPostComposer extends ComposerBody {
return items;
}
+ /**
+ * Jump to the preview when triggered by the text editor.
+ */
+ jumpToPreview(e) {
+ minimizeComposerIfFullScreen(e);
+
+ m.route(app.route.post(this.props.post));
+ }
+
/**
* Get the data to submit to the server when the post is saved.
*
@@ -71,7 +70,7 @@ export default class EditPostComposer extends ComposerBody {
*/
data() {
return {
- content: this.content(),
+ content: this.composer.fields.content(),
};
}
@@ -86,7 +85,7 @@ export default class EditPostComposer extends ComposerBody {
// If we're currently viewing the discussion which this edit was made
// in, then we can scroll to the post.
if (app.viewingDiscussion(discussion)) {
- app.current.stream.goToNumber(post.number());
+ app.current.get('stream').goToNumber(post.number());
} else {
// Otherwise, we'll create an alert message to inform the user that
// their edit has been made, containing a button which will
@@ -107,7 +106,7 @@ export default class EditPostComposer extends ComposerBody {
});
}
- app.composer.hide();
+ this.composer.hide();
}, this.loaded.bind(this));
}
}
diff --git a/js/src/forum/components/IndexPage.js b/js/src/forum/components/IndexPage.js
index 5f48640ff..f1bc41cb5 100644
--- a/js/src/forum/components/IndexPage.js
+++ b/js/src/forum/components/IndexPage.js
@@ -273,12 +273,10 @@ export default class IndexPage extends Page {
const deferred = m.deferred();
if (app.session.user) {
- const component = new DiscussionComposer({ user: app.session.user });
-
- app.composer.load(component);
+ app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
- deferred.resolve(component);
+ deferred.resolve(app.composer);
} else {
deferred.reject();
diff --git a/js/src/forum/components/ReplyComposer.js b/js/src/forum/components/ReplyComposer.js
index af942d186..65c8b5f7e 100644
--- a/js/src/forum/components/ReplyComposer.js
+++ b/js/src/forum/components/ReplyComposer.js
@@ -20,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
* - `discussion`
*/
export default class ReplyComposer extends ComposerBody {
- init() {
- super.init();
-
- this.editor.props.preview = (e) => {
- minimizeComposerIfFullScreen(e);
-
- m.route(app.route.discussion(this.props.discussion, 'reply'));
- };
- }
-
static initProps(props) {
super.initProps(props);
@@ -61,6 +51,15 @@ export default class ReplyComposer extends ComposerBody {
return items;
}
+ /**
+ * Jump to the preview when triggered by the text editor.
+ */
+ jumpToPreview(e) {
+ minimizeComposerIfFullScreen(e);
+
+ m.route(app.route.discussion(this.props.discussion, 'reply'));
+ }
+
/**
* Get the data to submit to the server when the reply is saved.
*
@@ -68,7 +67,7 @@ export default class ReplyComposer extends ComposerBody {
*/
data() {
return {
- content: this.content(),
+ content: this.composer.fields.content(),
relationships: { discussion: this.props.discussion },
};
}
@@ -110,7 +109,7 @@ export default class ReplyComposer extends ComposerBody {
});
}
- app.composer.hide();
+ this.composer.hide();
}, this.loaded.bind(this));
}
}
diff --git a/js/src/forum/components/ReplyPlaceholder.js b/js/src/forum/components/ReplyPlaceholder.js
index fba927d00..7a7970403 100644
--- a/js/src/forum/components/ReplyPlaceholder.js
+++ b/js/src/forum/components/ReplyPlaceholder.js
@@ -15,7 +15,7 @@ import DiscussionControls from '../utils/DiscussionControls';
*/
export default class ReplyPlaceholder extends Component {
view() {
- if (app.composingReplyTo(this.props.discussion)) {
+ if (app.composer.composingReplyTo(this.props.discussion)) {
return (
@@ -53,9 +53,9 @@ export default class ReplyPlaceholder extends Component {
const updateInterval = setInterval(() => {
// Since we're polling, the composer may have been closed in the meantime,
// so we bail in that case.
- if (!app.composer.component) return;
+ if (!app.composer.isVisible()) return;
- const content = app.composer.component.content();
+ const content = app.composer.fields.content();
if (preview === content) return;
diff --git a/js/src/forum/components/TextEditor.js b/js/src/forum/components/TextEditor.js
index 1f3e971de..a3c054472 100644
--- a/js/src/forum/components/TextEditor.js
+++ b/js/src/forum/components/TextEditor.js
@@ -1,5 +1,6 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
+import SuperTextarea from '../../common/utils/SuperTextarea';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
@@ -9,10 +10,12 @@ import Button from '../../common/components/Button';
*
* ### Props
*
+ * - `composer`
* - `submitLabel`
* - `value`
* - `placeholder`
* - `disabled`
+ * - `preview`
*/
export default class TextEditor extends Component {
init() {
@@ -21,7 +24,7 @@ export default class TextEditor extends Component {
*
* @type {String}
*/
- this.value = m.prop(this.props.value || '');
+ this.value = this.props.value || '';
}
view() {
@@ -33,7 +36,7 @@ export default class TextEditor extends Component {
oninput={m.withAttr('value', this.oninput.bind(this))}
placeholder={this.props.placeholder || ''}
disabled={!!this.props.disabled}
- value={this.value()}
+ value={this.value}
/>
@@ -47,7 +50,7 @@ export default class TextEditor extends Component {
/**
* Configure the textarea element.
*
- * @param {DOMElement} element
+ * @param {HTMLTextAreaElement} element
* @param {Boolean} isInitialized
*/
configTextarea(element, isInitialized) {
@@ -60,6 +63,8 @@ export default class TextEditor extends Component {
$(element).bind('keydown', 'meta+return', handler);
$(element).bind('keydown', 'ctrl+return', handler);
+
+ this.props.composer.editor = new SuperTextarea(element);
}
/**
@@ -106,73 +111,15 @@ export default class TextEditor extends Component {
return new ItemList();
}
- /**
- * Set the value of the text editor.
- *
- * @param {String} value
- */
- setValue(value) {
- this.$('textarea').val(value).trigger('input');
- }
-
- /**
- * Set the selected range of the textarea.
- *
- * @param {Integer} start
- * @param {Integer} end
- */
- setSelectionRange(start, end) {
- const $textarea = this.$('textarea');
-
- if (!$textarea.length) return;
-
- $textarea[0].setSelectionRange(start, end);
- $textarea.focus();
- }
-
- /**
- * Get the selected range of the textarea.
- *
- * @return {Array}
- */
- getSelectionRange() {
- const $textarea = this.$('textarea');
-
- if (!$textarea.length) return [0, 0];
-
- return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
- }
-
- /**
- * Insert content into the textarea at the position of the cursor.
- *
- * @param {String} insert
- */
- insertAtCursor(insert) {
- const textarea = this.$('textarea')[0];
- const value = this.value();
- const index = textarea ? textarea.selectionStart : value.length;
-
- this.setValue(value.slice(0, index) + insert + value.slice(index));
-
- // Move the textarea cursor to the end of the content we just inserted.
- if (textarea) {
- const pos = index + insert.length;
- this.setSelectionRange(pos, pos);
- }
-
- textarea.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
- }
-
/**
* Handle input into the textarea.
*
* @param {String} value
*/
oninput(value) {
- this.value(value);
+ this.value = value;
- this.props.onchange(this.value());
+ this.props.onchange(this.value);
m.redraw.strategy('none');
}
@@ -181,6 +128,6 @@ export default class TextEditor extends Component {
* Handle the submit button being clicked.
*/
onsubmit() {
- this.props.onsubmit(this.value());
+ this.props.onsubmit(this.value);
}
}
diff --git a/js/src/forum/states/ComposerState.js b/js/src/forum/states/ComposerState.js
new file mode 100644
index 000000000..d8cae0157
--- /dev/null
+++ b/js/src/forum/states/ComposerState.js
@@ -0,0 +1,285 @@
+import subclassOf from '../../common/utils/subclassOf';
+import ReplyComposer from '../components/ReplyComposer';
+
+class ComposerState {
+ constructor() {
+ /**
+ * The composer's current position.
+ *
+ * @type {ComposerState.Position}
+ */
+ this.position = ComposerState.Position.HIDDEN;
+
+ /**
+ * The composer's intended height, which can be modified by the user
+ * (by dragging the composer handle).
+ *
+ * @type {Integer}
+ */
+ this.height = null;
+
+ /**
+ * The dynamic component being shown inside the composer.
+ *
+ * @type {Object}
+ */
+ this.body = { attrs: {} };
+
+ /**
+ * A reference to the text editor that allows text manipulation.
+ *
+ * @type {SuperTextArea|null}
+ */
+ this.editor = null;
+
+ this.clear();
+
+ /**
+ * @deprecated BC layer, remove in Beta 15.
+ */
+ this.component = this;
+ }
+
+ /**
+ * Load a content component into the composer.
+ *
+ * @param {ComposerBody} componentClass
+ * @public
+ */
+ load(componentClass, attrs) {
+ const body = { componentClass, attrs };
+
+ if (this.preventExit()) return;
+
+ // If we load a similar component into the composer, then Mithril will be
+ // able to diff the old/new contents and some DOM-related state from the
+ // old composer will remain. To prevent this from happening, we clear the
+ // component and force a redraw, so that the new component will be working
+ // on a blank slate.
+ if (this.isVisible()) {
+ this.clear();
+ m.redraw(true);
+ }
+
+ this.body = body;
+ }
+
+ /**
+ * Clear the composer's content component.
+ */
+ clear() {
+ this.position = ComposerState.Position.HIDDEN;
+ this.body = { attrs: {} };
+ this.editor = null;
+ this.onExit = null;
+
+ this.fields = {
+ content: m.prop(''),
+ };
+
+ /**
+ * @deprecated BC layer, remove in Beta 15.
+ */
+ this.content = this.fields.content;
+ this.value = this.fields.content;
+ }
+
+ /**
+ * Show the composer.
+ *
+ * @public
+ */
+ show() {
+ if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
+
+ this.position = ComposerState.Position.NORMAL;
+ m.redraw();
+ }
+
+ /**
+ * Close the composer.
+ *
+ * @public
+ */
+ hide() {
+ this.clear();
+ m.redraw();
+ }
+
+ /**
+ * Confirm with the user so they don't lose their content, then close the
+ * composer.
+ *
+ * @public
+ */
+ close() {
+ if (this.preventExit()) return;
+
+ this.hide();
+ }
+
+ /**
+ * Minimize the composer. Has no effect if the composer is hidden.
+ *
+ * @public
+ */
+ minimize() {
+ if (!this.isVisible()) return;
+
+ this.position = ComposerState.Position.MINIMIZED;
+ m.redraw();
+ }
+
+ /**
+ * Take the composer into fullscreen mode. Has no effect if the composer is
+ * hidden.
+ *
+ * @public
+ */
+ fullScreen() {
+ if (!this.isVisible()) return;
+
+ this.position = ComposerState.Position.FULLSCREEN;
+ m.redraw();
+ }
+
+ /**
+ * Exit fullscreen mode.
+ *
+ * @public
+ */
+ exitFullScreen() {
+ if (this.position !== ComposerState.Position.FULLSCREEN) return;
+
+ this.position = ComposerState.Position.NORMAL;
+ m.redraw();
+ }
+
+ /**
+ * Determine whether the body matches the given component class and data.
+ *
+ * @param {object} type The component class to check against. Subclasses are
+ * accepted as well.
+ * @param {object} data
+ * @return {boolean}
+ */
+ bodyMatches(type, data = {}) {
+ // Fail early when the body is of a different type
+ if (!subclassOf(this.body.componentClass, type)) return false;
+
+ // Now that the type is known to be correct, we loop through the provided
+ // data to see whether it matches the data in the attributes for the body.
+ return Object.keys(data).every((key) => this.body.attrs[key] === data[key]);
+ }
+
+ /**
+ * Determine whether or not the Composer is visible.
+ *
+ * True when the composer is displayed on the screen and has a body component.
+ * It could be open in "normal" or full-screen mode, or even minimized.
+ *
+ * @returns {boolean}
+ */
+ isVisible() {
+ return this.position !== ComposerState.Position.HIDDEN;
+ }
+
+ /**
+ * Determine whether or not the Composer is covering the screen.
+ *
+ * This will be true if the Composer is in full-screen mode on desktop,
+ * or if we are on a mobile device, where we always consider the composer as full-screen..
+ *
+ * @return {Boolean}
+ * @public
+ */
+ isFullScreen() {
+ return this.position === ComposerState.Position.FULLSCREEN || app.screen() === 'phone';
+ }
+
+ /**
+ * Check whether or not the user is currently composing a reply to a
+ * discussion.
+ *
+ * @param {Discussion} discussion
+ * @return {Boolean}
+ */
+ composingReplyTo(discussion) {
+ return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion });
+ }
+
+ /**
+ * Confirm with the user that they want to close the composer and lose their
+ * content.
+ *
+ * @return {Boolean} Whether or not the exit was cancelled.
+ */
+ preventExit() {
+ if (!this.isVisible()) return;
+ if (!this.onExit) return;
+
+ if (this.onExit.callback()) {
+ return !confirm(this.onExit.message);
+ }
+ }
+
+ /**
+ * Configure when / what to ask the user before closing the composer.
+ *
+ * The provided callback will be used to determine whether asking for
+ * confirmation is necessary. If the callback returns true at the time of
+ * closing, the provided text will be shown in a standard confirmation dialog.
+ *
+ * @param {Function} callback
+ * @param {String} message
+ */
+ preventClosingWhen(callback, message) {
+ this.onExit = { callback, message };
+ }
+
+ /**
+ * Minimum height of the Composer.
+ * @returns {Integer}
+ */
+ minimumHeight() {
+ return 200;
+ }
+
+ /**
+ * Maxmimum height of the Composer.
+ * @returns {Integer}
+ */
+ maximumHeight() {
+ return $(window).height() - $('#header').outerHeight();
+ }
+
+ /**
+ * Computed the composer's current height, based on the intended height, and
+ * the composer's current state. This will be applied to the composer's
+ * content's DOM element.
+ * @returns {Integer|String}
+ */
+ computedHeight() {
+ // If the composer is minimized, then we don't want to set a height; we'll
+ // let the CSS decide how high it is. If it's fullscreen, then we need to
+ // make it as high as the window.
+ if (this.position === ComposerState.Position.MINIMIZED) {
+ return '';
+ } else if (this.position === ComposerState.Position.FULLSCREEN) {
+ return $(window).height();
+ }
+
+ // Otherwise, if it's normal or hidden, then we use the intended height.
+ // We don't let the composer get too small or too big, though.
+ return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
+ }
+}
+
+ComposerState.Position = {
+ HIDDEN: 'hidden',
+ NORMAL: 'normal',
+ MINIMIZED: 'minimized',
+ FULLSCREEN: 'fullScreen',
+};
+
+export default ComposerState;
diff --git a/js/src/forum/utils/DiscussionControls.js b/js/src/forum/utils/DiscussionControls.js
index 45d5f94d6..7a49dcab8 100644
--- a/js/src/forum/utils/DiscussionControls.js
+++ b/js/src/forum/utils/DiscussionControls.js
@@ -167,13 +167,11 @@ export default {
if (app.session.user) {
if (this.canReply()) {
- let component = app.composer.component;
- if (!app.composingReplyTo(this) || forceRefresh) {
- component = new ReplyComposer({
+ if (!app.composer.composingReplyTo(this) || forceRefresh) {
+ app.composer.load(ReplyComposer, {
user: app.session.user,
discussion: this,
});
- app.composer.load(component);
}
app.composer.show();
@@ -181,7 +179,7 @@ export default {
app.current.get('stream').goToNumber('reply');
}
- deferred.resolve(component);
+ deferred.resolve(app.composer);
} else {
deferred.reject();
}
diff --git a/js/src/forum/utils/PostControls.js b/js/src/forum/utils/PostControls.js
index 4c36ed4bc..c90b097c7 100644
--- a/js/src/forum/utils/PostControls.js
+++ b/js/src/forum/utils/PostControls.js
@@ -130,12 +130,10 @@ export default {
editAction() {
const deferred = m.deferred();
- const component = new EditPostComposer({ post: this });
-
- app.composer.load(component);
+ app.composer.load(EditPostComposer, { post: this });
app.composer.show();
- deferred.resolve(component);
+ deferred.resolve(app.composer);
return deferred.promise;
},
diff --git a/less/common/scaffolding.less b/less/common/scaffolding.less
index 87ae50792..5f4a9b562 100644
--- a/less/common/scaffolding.less
+++ b/less/common/scaffolding.less
@@ -1,3 +1,14 @@
+// Store the current responsive screen mode in a CSS variable, to make it
+// available to the JS code.
+:root {
+ --flarum-screen: none;
+
+ @media @phone { --flarum-screen: phone }
+ @media @tablet { --flarum-screen: tablet }
+ @media @desktop { --flarum-screen: desktop }
+ @media @desktop-hd { --flarum-screen: desktop-hd }
+}
+
* {
&,
&:before,