1
0
mirror of https://github.com/flarum/core.git synced 2025-07-12 12:26:23 +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:
Alexander Skvortsov
2020-07-24 18:17:25 -04:00
committed by GitHub
parent 62a2e8463d
commit 5e465f6051
18 changed files with 631 additions and 397 deletions

View File

@ -230,6 +230,16 @@ export default class Application {
return null;
}
/**
* Determine the current screen mode, based on our media queries.
*
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
*/
screen() {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue('--flarum-screen');
}
/**
* Set the <title> of the page.
*

View File

@ -0,0 +1,37 @@
import Component from '../Component';
/**
* The `ConfirmDocumentUnload` component can be used to register a global
* event handler that prevents closing the browser window/tab based on the
* return value of a given callback prop.
*
* ### Props
*
* - `when` - a callback returning true when the browser should prompt for
* confirmation before closing the window/tab
*
* ### Children
*
* NOTE: Only the first child will be rendered. (Use this component to wrap
* another component / DOM element.)
*
*/
export default class ConfirmDocumentUnload extends Component {
config(isInitialized, context) {
if (isInitialized) return;
const handler = () => this.props.when() || undefined;
$(window).on('beforeunload', handler);
context.onunload = () => {
$(window).off('beforeunload', handler);
};
}
view() {
// To avoid having to render another wrapping <div> here, we assume that
// this component is only wrapped around a single element / component.
return this.props.children[0];
}
}

View File

@ -0,0 +1,109 @@
/**
* A textarea wrapper with powerful helpers for text manipulation.
*
* This wraps a <textarea> DOM element and allows directly manipulating its text
* contents and cursor positions.
*
* I apologize for the pretentious name. :)
*/
export default class SuperTextarea {
/**
* @param {HTMLTextAreaElement} textarea
*/
constructor(textarea) {
this.el = textarea;
this.$ = $(textarea);
}
/**
* Set the value of the text editor.
*
* @param {String} value
*/
setValue(value) {
this.$.val(value).trigger('input');
}
/**
* Focus the textarea and place the cursor at the given index.
*
* @param {number} position
*/
moveCursorTo(position) {
this.setSelectionRange(position, position);
}
/**
* Get the selected range of the textarea.
*
* @return {Array}
*/
getSelectionRange() {
return [this.el.selectionStart, this.el.selectionEnd];
}
/**
* Insert content into the textarea at the position of the cursor.
*
* @param {String} text
*/
insertAtCursor(text) {
this.insertAt(this.el.selectionStart, text);
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
/**
* Insert content into the textarea at the given position.
*
* @param {number} pos
* @param {String} text
*/
insertAt(pos, text) {
this.insertBetween(pos, pos, text);
}
/**
* Insert content into the textarea between the given positions.
*
* If the start and end positions are different, any text between them will be
* overwritten.
*
* @param start
* @param end
* @param text
*/
insertBetween(start, end, text) {
const value = this.el.value;
const before = value.slice(0, start);
const after = value.slice(end);
this.setValue(`${before}${text}${after}`);
// Move the textarea cursor to the end of the content we just inserted.
this.moveCursorTo(start + text.length);
}
/**
* Replace existing content from the start to the current cursor position.
*
* @param start
* @param text
*/
replaceBeforeCursor(start, text) {
this.insertBetween(start, this.el.selectionStart, text);
}
/**
* Set the selected range of the textarea.
*
* @param {number} start
* @param {number} end
* @private
*/
setSelectionRange(start, end) {
this.el.setSelectionRange(start, end);
this.$.focus();
}
}

View File

@ -1,6 +1,5 @@
import History from './utils/History';
import Pane from './utils/Pane';
import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal';
import HeaderPrimary from './components/HeaderPrimary';
@ -16,6 +15,7 @@ import Navigation from '../common/components/Navigation';
import NotificationListState from './states/NotificationListState';
import GlobalSearchState from './states/GlobalSearchState';
import DiscussionListState from './states/DiscussionListState';
import ComposerState from './states/ComposerState';
export default class ForumApplication extends Application {
/**
@ -73,6 +73,11 @@ export default class ForumApplication extends Application {
*/
search = new GlobalSearchState();
/*
* An object which controls the state of the composer.
*/
composer = new ComposerState();
constructor() {
super();
@ -114,9 +119,9 @@ export default class ForumApplication extends Application {
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
this.pane = new Pane(document.getElementById('app'));
this.composer = m.mount(document.getElementById('composer'), Composer.component());
m.route.mode = 'pathname';
super.mount(this.forum.attribute('basePath'));
@ -138,21 +143,6 @@ export default class ForumApplication extends Application {
});
}
/**
* Check whether or not the user is currently composing a reply to a
* discussion.
*
* @param {Discussion} discussion
* @return {Boolean}
*/
composingReplyTo(discussion) {
return (
this.composer.component instanceof ReplyComposer &&
this.composer.component.props.discussion === discussion &&
this.composer.position !== Composer.PositionEnum.HIDDEN
);
}
/**
* Check whether or not the user is currently viewing a discussion.
*

View File

@ -77,7 +77,7 @@ export default class CommentPost extends Post {
}
isEditing() {
return app.composer.component instanceof EditPostComposer && app.composer.component.props.post === this.props.post;
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
}
attrs() {
@ -105,7 +105,7 @@ export default class CommentPost extends Post {
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.component.content();
const content = app.composer.fields.content();
if (preview === content) return;

View File

@ -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) {
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;

View File

@ -1,5 +1,6 @@
import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
import TextEditor from './TextEditor';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
@ -12,6 +13,7 @@ import ItemList from '../../common/utils/ItemList';
*
* ### Props
*
* - `composer`
* - `originalContent`
* - `submitLabel`
* - `placeholder`
@ -23,6 +25,8 @@ import ItemList from '../../common/utils/ItemList';
*/
export default class ComposerBody extends Component {
init() {
this.composer = this.props.composer;
/**
* Whether or not the component is loading.
*
@ -30,60 +34,57 @@ export default class ComposerBody extends Component {
*/
this.loading = false;
/**
* The content of the text editor.
*
* @type {Function}
*/
this.content = m.prop(this.props.originalContent);
// Let the composer state know to ask for confirmation under certain
// circumstances, if the body supports / requires it and has a corresponding
// confirmation question to ask.
if (this.props.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
}
this.composer.fields.content(this.props.originalContent || '');
/**
* The text editor component instance.
*
* @type {TextEditor}
* @deprecated BC layer, remove in Beta 15.
*/
this.editor = new TextEditor({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
onchange: this.content,
onsubmit: this.onsubmit.bind(this),
value: this.content(),
});
this.content = this.composer.fields.content;
this.editor = this.composer;
}
view() {
// If the component is loading, we should disable the text editor.
this.editor.props.disabled = this.loading;
return (
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">{this.editor.render()}</div>
<div className="ComposerBody-editor">
{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(),
})}
</div>
</div>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
</div>
</ConfirmDocumentUnload>
);
}
/**
* 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;
}
/**

View File

@ -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));

View File

@ -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();

View File

@ -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));
}
}

View File

@ -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();

View File

@ -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));
}
}

View File

@ -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 (
<article className="Post CommentPost editing">
<header className="Post-header">
@ -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;

View File

@ -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}
/>
<ul className="TextEditor-controls Composer-footer">
@ -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);
}
}

View 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;

View File

@ -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();
}

View File

@ -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;
},

View File

@ -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,