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:
committed by
GitHub
parent
62a2e8463d
commit
5e465f6051
@ -230,6 +230,16 @@ export default class Application {
|
|||||||
return null;
|
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.
|
* Set the <title> of the page.
|
||||||
*
|
*
|
||||||
|
37
js/src/common/components/ConfirmDocumentUnload.js
Normal file
37
js/src/common/components/ConfirmDocumentUnload.js
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
109
js/src/common/utils/SuperTextarea.js
Normal file
109
js/src/common/utils/SuperTextarea.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import History from './utils/History';
|
import History from './utils/History';
|
||||||
import Pane from './utils/Pane';
|
import Pane from './utils/Pane';
|
||||||
import ReplyComposer from './components/ReplyComposer';
|
|
||||||
import DiscussionPage from './components/DiscussionPage';
|
import DiscussionPage from './components/DiscussionPage';
|
||||||
import SignUpModal from './components/SignUpModal';
|
import SignUpModal from './components/SignUpModal';
|
||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
@ -16,6 +15,7 @@ import Navigation from '../common/components/Navigation';
|
|||||||
import NotificationListState from './states/NotificationListState';
|
import NotificationListState from './states/NotificationListState';
|
||||||
import GlobalSearchState from './states/GlobalSearchState';
|
import GlobalSearchState from './states/GlobalSearchState';
|
||||||
import DiscussionListState from './states/DiscussionListState';
|
import DiscussionListState from './states/DiscussionListState';
|
||||||
|
import ComposerState from './states/ComposerState';
|
||||||
|
|
||||||
export default class ForumApplication extends Application {
|
export default class ForumApplication extends Application {
|
||||||
/**
|
/**
|
||||||
@ -73,6 +73,11 @@ export default class ForumApplication extends Application {
|
|||||||
*/
|
*/
|
||||||
search = new GlobalSearchState();
|
search = new GlobalSearchState();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An object which controls the state of the composer.
|
||||||
|
*/
|
||||||
|
composer = new ComposerState();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -114,9 +119,9 @@ export default class ForumApplication extends Application {
|
|||||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.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.pane = new Pane(document.getElementById('app'));
|
||||||
this.composer = m.mount(document.getElementById('composer'), Composer.component());
|
|
||||||
|
|
||||||
m.route.mode = 'pathname';
|
m.route.mode = 'pathname';
|
||||||
super.mount(this.forum.attribute('basePath'));
|
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.
|
* Check whether or not the user is currently viewing a discussion.
|
||||||
*
|
*
|
||||||
|
@ -77,7 +77,7 @@ export default class CommentPost extends Post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isEditing() {
|
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() {
|
attrs() {
|
||||||
@ -105,7 +105,7 @@ export default class CommentPost extends Post {
|
|||||||
// body with a preview.
|
// body with a preview.
|
||||||
let preview;
|
let preview;
|
||||||
const updatePreview = () => {
|
const updatePreview = () => {
|
||||||
const content = app.composer.component.content();
|
const content = app.composer.fields.content();
|
||||||
|
|
||||||
if (preview === content) return;
|
if (preview === content) return;
|
||||||
|
|
||||||
|
@ -3,28 +3,21 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
import ComposerButton from './ComposerButton';
|
import ComposerButton from './ComposerButton';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import classList from '../../common/utils/classList';
|
import classList from '../../common/utils/classList';
|
||||||
|
import ComposerState from '../states/ComposerState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Composer` component displays the composer. It can be loaded with a
|
* 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
|
* content component with `load` and then its position/state can be altered with
|
||||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||||
*/
|
*/
|
||||||
class Composer extends Component {
|
export default class Composer extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
/**
|
||||||
* The composer's current position.
|
* The composer's "state".
|
||||||
*
|
*
|
||||||
* @type {Composer.PositionEnum}
|
* @type {ComposerState}
|
||||||
*/
|
*/
|
||||||
this.position = Composer.PositionEnum.HIDDEN;
|
this.state = this.props.state;
|
||||||
|
|
||||||
/**
|
|
||||||
* The composer's intended height, which can be modified by the user
|
|
||||||
* (by dragging the composer handle).
|
|
||||||
*
|
|
||||||
* @type {Integer}
|
|
||||||
*/
|
|
||||||
this.height = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the composer currently has focus.
|
* Whether or not the composer currently has focus.
|
||||||
@ -32,39 +25,45 @@ class Composer extends Component {
|
|||||||
* @type {Boolean}
|
* @type {Boolean}
|
||||||
*/
|
*/
|
||||||
this.active = false;
|
this.active = false;
|
||||||
|
|
||||||
|
// Store the initial position so that we can trigger animations correctly.
|
||||||
|
this.prevPosition = this.state.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
|
const body = this.state.body;
|
||||||
const classes = {
|
const classes = {
|
||||||
normal: this.position === Composer.PositionEnum.NORMAL,
|
normal: this.state.position === ComposerState.Position.NORMAL,
|
||||||
minimized: this.position === Composer.PositionEnum.MINIMIZED,
|
minimized: this.state.position === ComposerState.Position.MINIMIZED,
|
||||||
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN,
|
fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
|
||||||
active: this.active,
|
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
|
// Set up a handler so that clicks on the content will show the composer.
|
||||||
// it shouldn't let the user interact with it. Set up a handler so that if
|
const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
|
||||||
// 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'Composer ' + classList(classes)}>
|
<div className={'Composer ' + classList(classes)}>
|
||||||
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
||||||
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||||
<div className="Composer-content" onclick={showIfMinimized}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
config(isInitialized, context) {
|
||||||
|
if (this.state.position === this.prevPosition) {
|
||||||
// Set the height of the Composer element and its contents on each redraw,
|
// 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.
|
// so that they do not lose it if their DOM elements are recreated.
|
||||||
this.updateHeight();
|
this.updateHeight();
|
||||||
|
} else {
|
||||||
|
this.animatePositionChange();
|
||||||
|
|
||||||
|
this.prevPosition = this.state.position;
|
||||||
|
}
|
||||||
|
|
||||||
if (isInitialized) return;
|
if (isInitialized) return;
|
||||||
|
|
||||||
@ -73,7 +72,7 @@ class Composer extends Component {
|
|||||||
context.retain = true;
|
context.retain = true;
|
||||||
|
|
||||||
this.initializeHeight();
|
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
|
// 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.
|
// 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.
|
// When the escape key is pressed on any inputs, close the composer.
|
||||||
this.$().on('keydown', ':input', 'esc', () => this.close());
|
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 = {};
|
const handlers = {};
|
||||||
|
|
||||||
$(window)
|
$(window)
|
||||||
@ -166,13 +158,20 @@ class Composer extends Component {
|
|||||||
$('body').css('cursor', '');
|
$('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
|
* 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
|
* setting the height of the composer's root element, and adjusting the height
|
||||||
* of any flexible elements inside the composer's body.
|
* of any flexible elements inside the composer's body.
|
||||||
*/
|
*/
|
||||||
updateHeight() {
|
updateHeight() {
|
||||||
const height = this.computedHeight();
|
const height = this.state.computedHeight();
|
||||||
const $flexible = this.$('.Composer-flexible');
|
const $flexible = this.$('.Composer-flexible');
|
||||||
|
|
||||||
this.$().height(height);
|
this.$().height(height);
|
||||||
@ -193,109 +192,59 @@ class Composer extends Component {
|
|||||||
*/
|
*/
|
||||||
updateBodyPadding() {
|
updateBodyPadding() {
|
||||||
const visible =
|
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 });
|
$('#content').css({ paddingBottom });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether or not the Composer is covering the screen.
|
* Trigger the right animation depending on the desired new position.
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
isFullScreen() {
|
animatePositionChange() {
|
||||||
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
|
// When exiting full-screen mode: focus content
|
||||||
|
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
|
||||||
|
this.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
switch (this.state.position) {
|
||||||
* Confirm with the user that they want to close the composer and lose their
|
case ComposerState.Position.HIDDEN:
|
||||||
* content.
|
return this.hide();
|
||||||
*
|
case ComposerState.Position.MINIMIZED:
|
||||||
* @return {Boolean} Whether or not the exit was cancelled.
|
return this.minimize();
|
||||||
*/
|
case ComposerState.Position.FULLSCREEN:
|
||||||
preventExit() {
|
return this.focus();
|
||||||
if (this.component) {
|
case ComposerState.Position.NORMAL:
|
||||||
const preventExit = this.component.preventExit();
|
return this.show();
|
||||||
|
|
||||||
if (preventExit) {
|
|
||||||
return !confirm(preventExit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a content component into the composer.
|
* Animate the Composer into the new position by changing the height.
|
||||||
*
|
|
||||||
* @param {Component} component
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
load(component) {
|
animateHeightChange() {
|
||||||
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;
|
|
||||||
const $composer = this.$().stop(true);
|
const $composer = this.$().stop(true);
|
||||||
const oldHeight = $composer.outerHeight();
|
const oldHeight = $composer.outerHeight();
|
||||||
const scrollTop = $(window).scrollTop();
|
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();
|
$composer.show();
|
||||||
this.updateHeight();
|
this.updateHeight();
|
||||||
|
|
||||||
const newHeight = $composer.outerHeight();
|
const newHeight = $composer.outerHeight();
|
||||||
|
|
||||||
if (oldPosition === Composer.PositionEnum.HIDDEN) {
|
if (this.prevPosition === ComposerState.Position.HIDDEN) {
|
||||||
$composer.css({ bottom: -newHeight, height: newHeight });
|
$composer.css({ bottom: -newHeight, height: newHeight });
|
||||||
} else {
|
} else {
|
||||||
$composer.css({ height: oldHeight });
|
$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();
|
this.updateBodyPadding();
|
||||||
$(window).scrollTop(scrollTop);
|
$(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() {
|
show() {
|
||||||
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
|
this.animateHeightChange().then(() => this.focus());
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.animateToPosition(Composer.PositionEnum.NORMAL);
|
if (app.screen() === 'phone') {
|
||||||
|
|
||||||
if (this.isFullScreen()) {
|
|
||||||
this.$().css('top', $(window).scrollTop());
|
this.$().css('top', $(window).scrollTop());
|
||||||
this.showBackdrop();
|
this.showBackdrop();
|
||||||
this.component.focus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the composer.
|
* Animate closing the composer.
|
||||||
*
|
*
|
||||||
* @public
|
* @private
|
||||||
*/
|
*/
|
||||||
hide() {
|
hide() {
|
||||||
const $composer = this.$();
|
const $composer = this.$();
|
||||||
|
|
||||||
// Animate the composer sliding down off the bottom edge of the viewport.
|
// Animate the composer sliding down off the bottom edge of the viewport.
|
||||||
// Only when the animation is completed, update the Composer state flag and
|
// Only when the animation is completed, update other elements on the page.
|
||||||
// other elements on the page.
|
|
||||||
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
|
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
|
||||||
this.position = Composer.PositionEnum.HIDDEN;
|
|
||||||
this.clear();
|
|
||||||
m.redraw();
|
|
||||||
|
|
||||||
$composer.hide();
|
$composer.hide();
|
||||||
this.hideBackdrop();
|
this.hideBackdrop();
|
||||||
this.updateBodyPadding();
|
this.updateBodyPadding();
|
||||||
@ -354,60 +293,17 @@ class Composer extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm with the user so they don't lose their content, then close the
|
* Shrink the composer until only its title is visible.
|
||||||
* composer.
|
|
||||||
*
|
*
|
||||||
* @public
|
* @private
|
||||||
*/
|
|
||||||
close() {
|
|
||||||
if (!this.preventExit()) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimize the composer. Has no effect if the composer is hidden.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
minimize() {
|
minimize() {
|
||||||
if (this.position === Composer.PositionEnum.HIDDEN) return;
|
this.animateHeightChange();
|
||||||
|
|
||||||
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
|
|
||||||
|
|
||||||
this.$().css('top', 'auto');
|
this.$().css('top', 'auto');
|
||||||
this.hideBackdrop();
|
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.
|
* Build an item list for the composer's controls.
|
||||||
*
|
*
|
||||||
@ -416,23 +312,23 @@ class Composer extends Component {
|
|||||||
controlItems() {
|
controlItems() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
|
|
||||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
if (this.state.position === ComposerState.Position.FULLSCREEN) {
|
||||||
items.add(
|
items.add(
|
||||||
'exitFullScreen',
|
'exitFullScreen',
|
||||||
ComposerButton.component({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-compress',
|
icon: 'fas fa-compress',
|
||||||
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
||||||
onclick: this.exitFullScreen.bind(this),
|
onclick: this.state.exitFullScreen.bind(this.state),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (this.position !== Composer.PositionEnum.MINIMIZED) {
|
if (this.state.position !== ComposerState.Position.MINIMIZED) {
|
||||||
items.add(
|
items.add(
|
||||||
'minimize',
|
'minimize',
|
||||||
ComposerButton.component({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-minus minimize',
|
icon: 'fas fa-minus minimize',
|
||||||
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
||||||
onclick: this.minimize.bind(this),
|
onclick: this.state.minimize.bind(this.state),
|
||||||
itemClassName: 'App-backControl',
|
itemClassName: 'App-backControl',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -442,7 +338,7 @@ class Composer extends Component {
|
|||||||
ComposerButton.component({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-expand',
|
icon: 'fas fa-expand',
|
||||||
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
|
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({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-times',
|
icon: 'fas fa-times',
|
||||||
title: app.translator.trans('core.forum.composer.close_tooltip'),
|
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.
|
* Initialize default Composer height.
|
||||||
*/
|
*/
|
||||||
initializeHeight() {
|
initializeHeight() {
|
||||||
this.height = localStorage.getItem('composerHeight');
|
this.state.height = localStorage.getItem('composerHeight');
|
||||||
|
|
||||||
if (!this.height) {
|
if (!this.state.height) {
|
||||||
this.height = this.defaultHeight();
|
this.state.height = this.defaultHeight();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,60 +375,14 @@ class Composer extends Component {
|
|||||||
return this.$().height();
|
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.
|
* Save a new Composer height and update the DOM.
|
||||||
* @param {Integer} height
|
* @param {Integer} height
|
||||||
*/
|
*/
|
||||||
changeHeight(height) {
|
changeHeight(height) {
|
||||||
this.height = height;
|
this.state.height = height;
|
||||||
this.updateHeight();
|
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;
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
|
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
|
||||||
import TextEditor from './TextEditor';
|
import TextEditor from './TextEditor';
|
||||||
import avatar from '../../common/helpers/avatar';
|
import avatar from '../../common/helpers/avatar';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
@ -12,6 +13,7 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
*
|
*
|
||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
|
* - `composer`
|
||||||
* - `originalContent`
|
* - `originalContent`
|
||||||
* - `submitLabel`
|
* - `submitLabel`
|
||||||
* - `placeholder`
|
* - `placeholder`
|
||||||
@ -23,6 +25,8 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
*/
|
*/
|
||||||
export default class ComposerBody extends Component {
|
export default class ComposerBody extends Component {
|
||||||
init() {
|
init() {
|
||||||
|
this.composer = this.props.composer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the component is loading.
|
* Whether or not the component is loading.
|
||||||
*
|
*
|
||||||
@ -30,60 +34,57 @@ export default class ComposerBody extends Component {
|
|||||||
*/
|
*/
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
/**
|
// Let the composer state know to ask for confirmation under certain
|
||||||
* The content of the text editor.
|
// circumstances, if the body supports / requires it and has a corresponding
|
||||||
*
|
// confirmation question to ask.
|
||||||
* @type {Function}
|
if (this.props.confirmExit) {
|
||||||
*/
|
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
|
||||||
this.content = m.prop(this.props.originalContent);
|
}
|
||||||
|
|
||||||
|
this.composer.fields.content(this.props.originalContent || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The text editor component instance.
|
* @deprecated BC layer, remove in Beta 15.
|
||||||
*
|
|
||||||
* @type {TextEditor}
|
|
||||||
*/
|
*/
|
||||||
this.editor = new TextEditor({
|
this.content = this.composer.fields.content;
|
||||||
submitLabel: this.props.submitLabel,
|
this.editor = this.composer;
|
||||||
placeholder: this.props.placeholder,
|
|
||||||
onchange: this.content,
|
|
||||||
onsubmit: this.onsubmit.bind(this),
|
|
||||||
value: this.content(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
// If the component is loading, we should disable the text editor.
|
|
||||||
this.editor.props.disabled = this.loading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
|
||||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
<div className={'ComposerBody ' + (this.props.className || '')}>
|
||||||
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
||||||
<div className="ComposerBody-content">
|
<div className="ComposerBody-content">
|
||||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
<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>
|
</div>
|
||||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||||
</div>
|
</div>
|
||||||
|
</ConfirmDocumentUnload>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw focus to the text editor.
|
* Check if there is any unsaved data.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* @return {String}
|
* @return {String}
|
||||||
*/
|
*/
|
||||||
preventExit() {
|
hasChanges() {
|
||||||
const content = this.content();
|
const content = this.composer.fields.content();
|
||||||
|
|
||||||
return content && content !== this.props.originalContent && this.props.confirmExit;
|
return content && content !== this.props.originalContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,12 +16,14 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
init() {
|
init() {
|
||||||
super.init();
|
super.init();
|
||||||
|
|
||||||
|
this.composer.fields.title = this.composer.fields.title || m.prop('');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the title input.
|
* The value of the title input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.title = m.prop('');
|
this.title = this.composer.fields.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
static initProps(props) {
|
static initProps(props) {
|
||||||
@ -66,14 +68,14 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
if (e.which === 13) {
|
if (e.which === 13) {
|
||||||
// Return
|
// Return
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.editor.setSelectionRange(0, 0);
|
this.composer.editor.moveCursorTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
m.redraw.strategy('none');
|
m.redraw.strategy('none');
|
||||||
}
|
}
|
||||||
|
|
||||||
preventExit() {
|
hasChanges() {
|
||||||
return (this.title() || this.content()) && this.props.confirmExit;
|
return this.title() || this.composer.fields.content();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -84,7 +86,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
title: this.title(),
|
title: this.title(),
|
||||||
content: this.content(),
|
content: this.composer.fields.content(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,7 +99,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
.createRecord('discussions')
|
.createRecord('discussions')
|
||||||
.save(data)
|
.save(data)
|
||||||
.then((discussion) => {
|
.then((discussion) => {
|
||||||
app.composer.hide();
|
this.composer.hide();
|
||||||
app.discussions.refresh();
|
app.discussions.refresh();
|
||||||
m.route(app.route.discussion(discussion));
|
m.route(app.route.discussion(discussion));
|
||||||
}, this.loaded.bind(this));
|
}, this.loaded.bind(this));
|
||||||
|
@ -79,7 +79,7 @@ export default class DiscussionPage extends Page {
|
|||||||
// we'll just close it.
|
// we'll just close it.
|
||||||
app.pane.disable();
|
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();
|
app.composer.hide();
|
||||||
} else {
|
} else {
|
||||||
app.composer.minimize();
|
app.composer.minimize();
|
||||||
|
@ -20,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
|
|||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class EditPostComposer extends ComposerBody {
|
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) {
|
static initProps(props) {
|
||||||
super.initProps(props);
|
super.initProps(props);
|
||||||
|
|
||||||
@ -64,6 +54,15 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
return items;
|
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.
|
* Get the data to submit to the server when the post is saved.
|
||||||
*
|
*
|
||||||
@ -71,7 +70,7 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
*/
|
*/
|
||||||
data() {
|
data() {
|
||||||
return {
|
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
|
// If we're currently viewing the discussion which this edit was made
|
||||||
// in, then we can scroll to the post.
|
// in, then we can scroll to the post.
|
||||||
if (app.viewingDiscussion(discussion)) {
|
if (app.viewingDiscussion(discussion)) {
|
||||||
app.current.stream.goToNumber(post.number());
|
app.current.get('stream').goToNumber(post.number());
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, we'll create an alert message to inform the user that
|
// Otherwise, we'll create an alert message to inform the user that
|
||||||
// their edit has been made, containing a button which will
|
// 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));
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -273,12 +273,10 @@ export default class IndexPage extends Page {
|
|||||||
const deferred = m.deferred();
|
const deferred = m.deferred();
|
||||||
|
|
||||||
if (app.session.user) {
|
if (app.session.user) {
|
||||||
const component = new DiscussionComposer({ user: app.session.user });
|
app.composer.load(DiscussionComposer, { user: app.session.user });
|
||||||
|
|
||||||
app.composer.load(component);
|
|
||||||
app.composer.show();
|
app.composer.show();
|
||||||
|
|
||||||
deferred.resolve(component);
|
deferred.resolve(app.composer);
|
||||||
} else {
|
} else {
|
||||||
deferred.reject();
|
deferred.reject();
|
||||||
|
|
||||||
|
@ -20,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
|
|||||||
* - `discussion`
|
* - `discussion`
|
||||||
*/
|
*/
|
||||||
export default class ReplyComposer extends ComposerBody {
|
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) {
|
static initProps(props) {
|
||||||
super.initProps(props);
|
super.initProps(props);
|
||||||
|
|
||||||
@ -61,6 +51,15 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
return items;
|
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.
|
* Get the data to submit to the server when the reply is saved.
|
||||||
*
|
*
|
||||||
@ -68,7 +67,7 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
*/
|
*/
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
content: this.content(),
|
content: this.composer.fields.content(),
|
||||||
relationships: { discussion: this.props.discussion },
|
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));
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
|||||||
*/
|
*/
|
||||||
export default class ReplyPlaceholder extends Component {
|
export default class ReplyPlaceholder extends Component {
|
||||||
view() {
|
view() {
|
||||||
if (app.composingReplyTo(this.props.discussion)) {
|
if (app.composer.composingReplyTo(this.props.discussion)) {
|
||||||
return (
|
return (
|
||||||
<article className="Post CommentPost editing">
|
<article className="Post CommentPost editing">
|
||||||
<header className="Post-header">
|
<header className="Post-header">
|
||||||
@ -53,9 +53,9 @@ export default class ReplyPlaceholder extends Component {
|
|||||||
const updateInterval = setInterval(() => {
|
const updateInterval = setInterval(() => {
|
||||||
// Since we're polling, the composer may have been closed in the meantime,
|
// Since we're polling, the composer may have been closed in the meantime,
|
||||||
// so we bail in that case.
|
// 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;
|
if (preview === content) return;
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import SuperTextarea from '../../common/utils/SuperTextarea';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
|
||||||
@ -9,10 +10,12 @@ import Button from '../../common/components/Button';
|
|||||||
*
|
*
|
||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
|
* - `composer`
|
||||||
* - `submitLabel`
|
* - `submitLabel`
|
||||||
* - `value`
|
* - `value`
|
||||||
* - `placeholder`
|
* - `placeholder`
|
||||||
* - `disabled`
|
* - `disabled`
|
||||||
|
* - `preview`
|
||||||
*/
|
*/
|
||||||
export default class TextEditor extends Component {
|
export default class TextEditor extends Component {
|
||||||
init() {
|
init() {
|
||||||
@ -21,7 +24,7 @@ export default class TextEditor extends Component {
|
|||||||
*
|
*
|
||||||
* @type {String}
|
* @type {String}
|
||||||
*/
|
*/
|
||||||
this.value = m.prop(this.props.value || '');
|
this.value = this.props.value || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@ -33,7 +36,7 @@ export default class TextEditor extends Component {
|
|||||||
oninput={m.withAttr('value', this.oninput.bind(this))}
|
oninput={m.withAttr('value', this.oninput.bind(this))}
|
||||||
placeholder={this.props.placeholder || ''}
|
placeholder={this.props.placeholder || ''}
|
||||||
disabled={!!this.props.disabled}
|
disabled={!!this.props.disabled}
|
||||||
value={this.value()}
|
value={this.value}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ul className="TextEditor-controls Composer-footer">
|
<ul className="TextEditor-controls Composer-footer">
|
||||||
@ -47,7 +50,7 @@ export default class TextEditor extends Component {
|
|||||||
/**
|
/**
|
||||||
* Configure the textarea element.
|
* Configure the textarea element.
|
||||||
*
|
*
|
||||||
* @param {DOMElement} element
|
* @param {HTMLTextAreaElement} element
|
||||||
* @param {Boolean} isInitialized
|
* @param {Boolean} isInitialized
|
||||||
*/
|
*/
|
||||||
configTextarea(element, isInitialized) {
|
configTextarea(element, isInitialized) {
|
||||||
@ -60,6 +63,8 @@ export default class TextEditor extends Component {
|
|||||||
|
|
||||||
$(element).bind('keydown', 'meta+return', handler);
|
$(element).bind('keydown', 'meta+return', handler);
|
||||||
$(element).bind('keydown', 'ctrl+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();
|
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.
|
* Handle input into the textarea.
|
||||||
*
|
*
|
||||||
* @param {String} value
|
* @param {String} value
|
||||||
*/
|
*/
|
||||||
oninput(value) {
|
oninput(value) {
|
||||||
this.value(value);
|
this.value = value;
|
||||||
|
|
||||||
this.props.onchange(this.value());
|
this.props.onchange(this.value);
|
||||||
|
|
||||||
m.redraw.strategy('none');
|
m.redraw.strategy('none');
|
||||||
}
|
}
|
||||||
@ -181,6 +128,6 @@ export default class TextEditor extends Component {
|
|||||||
* Handle the submit button being clicked.
|
* Handle the submit button being clicked.
|
||||||
*/
|
*/
|
||||||
onsubmit() {
|
onsubmit() {
|
||||||
this.props.onsubmit(this.value());
|
this.props.onsubmit(this.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
@ -167,13 +167,11 @@ export default {
|
|||||||
|
|
||||||
if (app.session.user) {
|
if (app.session.user) {
|
||||||
if (this.canReply()) {
|
if (this.canReply()) {
|
||||||
let component = app.composer.component;
|
if (!app.composer.composingReplyTo(this) || forceRefresh) {
|
||||||
if (!app.composingReplyTo(this) || forceRefresh) {
|
app.composer.load(ReplyComposer, {
|
||||||
component = new ReplyComposer({
|
|
||||||
user: app.session.user,
|
user: app.session.user,
|
||||||
discussion: this,
|
discussion: this,
|
||||||
});
|
});
|
||||||
app.composer.load(component);
|
|
||||||
}
|
}
|
||||||
app.composer.show();
|
app.composer.show();
|
||||||
|
|
||||||
@ -181,7 +179,7 @@ export default {
|
|||||||
app.current.get('stream').goToNumber('reply');
|
app.current.get('stream').goToNumber('reply');
|
||||||
}
|
}
|
||||||
|
|
||||||
deferred.resolve(component);
|
deferred.resolve(app.composer);
|
||||||
} else {
|
} else {
|
||||||
deferred.reject();
|
deferred.reject();
|
||||||
}
|
}
|
||||||
|
@ -130,12 +130,10 @@ export default {
|
|||||||
editAction() {
|
editAction() {
|
||||||
const deferred = m.deferred();
|
const deferred = m.deferred();
|
||||||
|
|
||||||
const component = new EditPostComposer({ post: this });
|
app.composer.load(EditPostComposer, { post: this });
|
||||||
|
|
||||||
app.composer.load(component);
|
|
||||||
app.composer.show();
|
app.composer.show();
|
||||||
|
|
||||||
deferred.resolve(component);
|
deferred.resolve(app.composer);
|
||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
},
|
},
|
||||||
|
@ -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,
|
&:before,
|
||||||
|
Reference in New Issue
Block a user