mirror of
https://github.com/flarum/core.git
synced 2025-10-25 13:46:29 +02:00
Extract Composer state (#2161)
Like previous "state PRs", this moves app-wide logic relating to our "composer" widget to its own "state" class, which can be referenced and called from all parts of the app. This lets us avoid storing component instances, which we cannot do any longer once we update to Mithril v2. This was not as trivial as some of the other state changes, as we tried to separate DOM effects (e.g. animations) from actual state changes (e.g. minimizing or opening the composer). New features: - A new `app.screen()` method returns the current responsive screen mode. This lets us check what breakpoint we're on in JS land without hardcoding / duplicating the actual breakpoints from CSS. - A new `SuperTextarea` util exposes useful methods for directly interacting with and manipulating the text contents of e.g. our post editor. - A new `ConfirmDocumentUnload` wrapper component encapsulates the logic for asking the user for confirmation when trying to close the browser window or navigating to another page. This is used in the composer to prevent accidentally losing unsaved post content. There is still potential for future cleanups, but we finally want to unblock the Mithril update, so these will have to wait: - Composer height change logic is very DOM-based, so should maybe not sit in the state. - I would love to experiment with using composition rather than inheritance for the `ComposerBody` subclasses.
This commit is contained in:
committed by
GitHub
parent
62a2e8463d
commit
5e465f6051
285
js/src/forum/states/ComposerState.js
Normal file
285
js/src/forum/states/ComposerState.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user