mirror of
https://github.com/flarum/core.git
synced 2025-10-24 13:16:08 +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
@@ -3,28 +3,21 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import ComposerButton from './ComposerButton';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import classList from '../../common/utils/classList';
|
||||
import ComposerState from '../states/ComposerState';
|
||||
|
||||
/**
|
||||
* The `Composer` component displays the composer. It can be loaded with a
|
||||
* content component with `load` and then its position/state can be altered with
|
||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||
*/
|
||||
class Composer extends Component {
|
||||
export default class Composer extends Component {
|
||||
init() {
|
||||
/**
|
||||
* The composer's current position.
|
||||
* The composer's "state".
|
||||
*
|
||||
* @type {Composer.PositionEnum}
|
||||
* @type {ComposerState}
|
||||
*/
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
|
||||
/**
|
||||
* The composer's intended height, which can be modified by the user
|
||||
* (by dragging the composer handle).
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.height = null;
|
||||
this.state = this.props.state;
|
||||
|
||||
/**
|
||||
* Whether or not the composer currently has focus.
|
||||
@@ -32,39 +25,45 @@ class Composer extends Component {
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.active = false;
|
||||
|
||||
// Store the initial position so that we can trigger animations correctly.
|
||||
this.prevPosition = this.state.position;
|
||||
}
|
||||
|
||||
view() {
|
||||
const body = this.state.body;
|
||||
const classes = {
|
||||
normal: this.position === Composer.PositionEnum.NORMAL,
|
||||
minimized: this.position === Composer.PositionEnum.MINIMIZED,
|
||||
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN,
|
||||
normal: this.state.position === ComposerState.Position.NORMAL,
|
||||
minimized: this.state.position === ComposerState.Position.MINIMIZED,
|
||||
fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
|
||||
active: this.active,
|
||||
visible: this.state.isVisible(),
|
||||
};
|
||||
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
|
||||
|
||||
// If the composer is minimized, tell the composer's content component that
|
||||
// it shouldn't let the user interact with it. Set up a handler so that if
|
||||
// the content IS clicked, the composer will be shown.
|
||||
if (this.component) this.component.props.disabled = classes.minimized;
|
||||
|
||||
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
|
||||
// Set up a handler so that clicks on the content will show the composer.
|
||||
const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
|
||||
|
||||
return (
|
||||
<div className={'Composer ' + classList(classes)}>
|
||||
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
||||
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||
<div className="Composer-content" onclick={showIfMinimized}>
|
||||
{this.component ? this.component.render() : ''}
|
||||
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Set the height of the Composer element and its contents on each redraw,
|
||||
// so that they do not lose it if their DOM elements are recreated.
|
||||
this.updateHeight();
|
||||
if (this.state.position === this.prevPosition) {
|
||||
// Set the height of the Composer element and its contents on each redraw,
|
||||
// so that they do not lose it if their DOM elements are recreated.
|
||||
this.updateHeight();
|
||||
} else {
|
||||
this.animatePositionChange();
|
||||
|
||||
this.prevPosition = this.state.position;
|
||||
}
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
@@ -73,7 +72,7 @@ class Composer extends Component {
|
||||
context.retain = true;
|
||||
|
||||
this.initializeHeight();
|
||||
this.$().hide().css('bottom', -this.computedHeight());
|
||||
this.$().hide().css('bottom', -this.state.computedHeight());
|
||||
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
@@ -85,13 +84,6 @@ class Composer extends Component {
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.close());
|
||||
|
||||
// Don't let the user leave the page without first giving the composer's
|
||||
// component a chance to scream at the user to make sure they don't
|
||||
// unintentionally lose any contnet.
|
||||
window.onbeforeunload = () => {
|
||||
return (this.component && this.component.preventExit()) || undefined;
|
||||
};
|
||||
|
||||
const handlers = {};
|
||||
|
||||
$(window)
|
||||
@@ -166,13 +158,20 @@ class Composer extends Component {
|
||||
$('body').css('cursor', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw focus to the first focusable content element (the text editor).
|
||||
*/
|
||||
focus() {
|
||||
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the DOM to reflect the composer's current height. This involves
|
||||
* setting the height of the composer's root element, and adjusting the height
|
||||
* of any flexible elements inside the composer's body.
|
||||
*/
|
||||
updateHeight() {
|
||||
const height = this.computedHeight();
|
||||
const height = this.state.computedHeight();
|
||||
const $flexible = this.$('.Composer-flexible');
|
||||
|
||||
this.$().height(height);
|
||||
@@ -193,109 +192,59 @@ class Composer extends Component {
|
||||
*/
|
||||
updateBodyPadding() {
|
||||
const visible =
|
||||
this.position !== Composer.PositionEnum.HIDDEN && this.position !== Composer.PositionEnum.MINIMIZED && this.$().css('position') !== 'absolute';
|
||||
this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';
|
||||
|
||||
const paddingBottom = visible ? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
||||
const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
||||
|
||||
$('#content').css({ paddingBottom });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the Composer is positioned absolutely as on mobile devices.
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
* Trigger the right animation depending on the desired new position.
|
||||
*/
|
||||
isFullScreen() {
|
||||
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
|
||||
}
|
||||
animatePositionChange() {
|
||||
// When exiting full-screen mode: focus content
|
||||
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.component) {
|
||||
const preventExit = this.component.preventExit();
|
||||
|
||||
if (preventExit) {
|
||||
return !confirm(preventExit);
|
||||
}
|
||||
switch (this.state.position) {
|
||||
case ComposerState.Position.HIDDEN:
|
||||
return this.hide();
|
||||
case ComposerState.Position.MINIMIZED:
|
||||
return this.minimize();
|
||||
case ComposerState.Position.FULLSCREEN:
|
||||
return this.focus();
|
||||
case ComposerState.Position.NORMAL:
|
||||
return this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {Component} component
|
||||
* @public
|
||||
* Animate the Composer into the new position by changing the height.
|
||||
*/
|
||||
load(component) {
|
||||
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.component) {
|
||||
this.clear();
|
||||
m.redraw(true);
|
||||
}
|
||||
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the composer's content component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the Composer into the given position.
|
||||
*
|
||||
* @param {Composer.PositionEnum} position
|
||||
*/
|
||||
animateToPosition(position) {
|
||||
// Before we redraw the composer to its new state, we need to save the
|
||||
// current height of the composer, as well as the page's scroll position, so
|
||||
// that we can smoothly transition from the old to the new state.
|
||||
const oldPosition = this.position;
|
||||
animateHeightChange() {
|
||||
const $composer = this.$().stop(true);
|
||||
const oldHeight = $composer.outerHeight();
|
||||
const scrollTop = $(window).scrollTop();
|
||||
|
||||
this.position = position;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
// Now that we've redrawn and the composer's DOM has been updated, we want
|
||||
// to update the composer's height. Once we've done that, we'll capture the
|
||||
// real value to use as the end point for our animation later on.
|
||||
$composer.show();
|
||||
this.updateHeight();
|
||||
|
||||
const newHeight = $composer.outerHeight();
|
||||
|
||||
if (oldPosition === Composer.PositionEnum.HIDDEN) {
|
||||
if (this.prevPosition === ComposerState.Position.HIDDEN) {
|
||||
$composer.css({ bottom: -newHeight, height: newHeight });
|
||||
} else {
|
||||
$composer.css({ height: oldHeight });
|
||||
}
|
||||
|
||||
$composer.animate({ bottom: 0, height: newHeight }, 'fast', () => this.component.focus());
|
||||
const animation = $composer.animate({ bottom: 0, height: newHeight }, 'fast').promise();
|
||||
|
||||
this.updateBodyPadding();
|
||||
$(window).scrollTop(scrollTop);
|
||||
return animation;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,40 +262,30 @@ class Composer extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the composer.
|
||||
* Animate the composer sliding up from the bottom to take its normal height.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
show() {
|
||||
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return;
|
||||
}
|
||||
this.animateHeightChange().then(() => this.focus());
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.NORMAL);
|
||||
|
||||
if (this.isFullScreen()) {
|
||||
if (app.screen() === 'phone') {
|
||||
this.$().css('top', $(window).scrollTop());
|
||||
this.showBackdrop();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the composer.
|
||||
* Animate closing the composer.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
hide() {
|
||||
const $composer = this.$();
|
||||
|
||||
// Animate the composer sliding down off the bottom edge of the viewport.
|
||||
// Only when the animation is completed, update the Composer state flag and
|
||||
// other elements on the page.
|
||||
// Only when the animation is completed, update other elements on the page.
|
||||
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
this.clear();
|
||||
m.redraw();
|
||||
|
||||
$composer.hide();
|
||||
this.hideBackdrop();
|
||||
this.updateBodyPadding();
|
||||
@@ -354,60 +293,17 @@ class Composer extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user so they don't lose their content, then close the
|
||||
* composer.
|
||||
* Shrink the composer until only its title is visible.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.preventExit()) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize the composer. Has no effect if the composer is hidden.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
minimize() {
|
||||
if (this.position === Composer.PositionEnum.HIDDEN) return;
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
|
||||
this.animateHeightChange();
|
||||
|
||||
this.$().css('top', 'auto');
|
||||
this.hideBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the composer into fullscreen mode. Has no effect if the composer is
|
||||
* hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
fullScreen() {
|
||||
if (this.position !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position = Composer.PositionEnum.FULLSCREEN;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit fullscreen mode.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
exitFullScreen() {
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
this.position = Composer.PositionEnum.NORMAL;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the composer's controls.
|
||||
*
|
||||
@@ -416,23 +312,23 @@ class Composer extends Component {
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
if (this.state.position === ComposerState.Position.FULLSCREEN) {
|
||||
items.add(
|
||||
'exitFullScreen',
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-compress',
|
||||
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
||||
onclick: this.exitFullScreen.bind(this),
|
||||
onclick: this.state.exitFullScreen.bind(this.state),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
if (this.position !== Composer.PositionEnum.MINIMIZED) {
|
||||
if (this.state.position !== ComposerState.Position.MINIMIZED) {
|
||||
items.add(
|
||||
'minimize',
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-minus minimize',
|
||||
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
||||
onclick: this.minimize.bind(this),
|
||||
onclick: this.state.minimize.bind(this.state),
|
||||
itemClassName: 'App-backControl',
|
||||
})
|
||||
);
|
||||
@@ -442,7 +338,7 @@ class Composer extends Component {
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-expand',
|
||||
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
|
||||
onclick: this.fullScreen.bind(this),
|
||||
onclick: this.state.fullScreen.bind(this.state),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -452,7 +348,7 @@ class Composer extends Component {
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-times',
|
||||
title: app.translator.trans('core.forum.composer.close_tooltip'),
|
||||
onclick: this.close.bind(this),
|
||||
onclick: this.state.close.bind(this.state),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -464,10 +360,10 @@ class Composer extends Component {
|
||||
* Initialize default Composer height.
|
||||
*/
|
||||
initializeHeight() {
|
||||
this.height = localStorage.getItem('composerHeight');
|
||||
this.state.height = localStorage.getItem('composerHeight');
|
||||
|
||||
if (!this.height) {
|
||||
this.height = this.defaultHeight();
|
||||
if (!this.state.height) {
|
||||
this.state.height = this.defaultHeight();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,60 +375,14 @@ class Composer extends Component {
|
||||
return this.$().height();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 === Composer.PositionEnum.MINIMIZED) {
|
||||
return '';
|
||||
} else if (this.position === Composer.PositionEnum.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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new Composer height and update the DOM.
|
||||
* @param {Integer} height
|
||||
*/
|
||||
changeHeight(height) {
|
||||
this.height = height;
|
||||
this.state.height = height;
|
||||
this.updateHeight();
|
||||
|
||||
localStorage.setItem('composerHeight', this.height);
|
||||
localStorage.setItem('composerHeight', this.state.height);
|
||||
}
|
||||
}
|
||||
|
||||
Composer.PositionEnum = {
|
||||
HIDDEN: 'hidden',
|
||||
NORMAL: 'normal',
|
||||
MINIMIZED: 'minimized',
|
||||
FULLSCREEN: 'fullScreen',
|
||||
};
|
||||
|
||||
export default Composer;
|
||||
|
||||
Reference in New Issue
Block a user