diff --git a/js/src/common/Application.js b/js/src/common/Application.js index ebd22be10..9f8a2ff1d 100644 --- a/js/src/common/Application.js +++ b/js/src/common/Application.js @@ -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 of the page. * diff --git a/js/src/common/components/ConfirmDocumentUnload.js b/js/src/common/components/ConfirmDocumentUnload.js new file mode 100644 index 000000000..d6e224ff9 --- /dev/null +++ b/js/src/common/components/ConfirmDocumentUnload.js @@ -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]; + } +} diff --git a/js/src/common/utils/SuperTextarea.js b/js/src/common/utils/SuperTextarea.js new file mode 100644 index 000000000..6ff5cfb23 --- /dev/null +++ b/js/src/common/utils/SuperTextarea.js @@ -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(); + } +} diff --git a/js/src/forum/ForumApplication.js b/js/src/forum/ForumApplication.js index b034d350e..ade92185c 100644 --- a/js/src/forum/ForumApplication.js +++ b/js/src/forum/ForumApplication.js @@ -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. * diff --git a/js/src/forum/components/CommentPost.js b/js/src/forum/components/CommentPost.js index 7bcc1eea9..f9395b07e 100644 --- a/js/src/forum/components/CommentPost.js +++ b/js/src/forum/components/CommentPost.js @@ -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; diff --git a/js/src/forum/components/Composer.js b/js/src/forum/components/Composer.js index 1f9b5e91a..44a8c942e 100644 --- a/js/src/forum/components/Composer.js +++ b/js/src/forum/components/Composer.js @@ -3,28 +3,21 @@ import ItemList from '../../common/utils/ItemList'; import ComposerButton from './ComposerButton'; import listItems from '../../common/helpers/listItems'; import classList from '../../common/utils/classList'; +import ComposerState from '../states/ComposerState'; /** * The `Composer` component displays the composer. It can be loaded with a * content component with `load` and then its position/state can be altered with * `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`. */ -class Composer extends Component { +export default class Composer extends Component { init() { /** - * The composer's current position. + * The composer's "state". * - * @type {Composer.PositionEnum} + * @type {ComposerState} */ - this.position = Composer.PositionEnum.HIDDEN; - - /** - * The composer's intended height, which can be modified by the user - * (by dragging the composer handle). - * - * @type {Integer} - */ - this.height = null; + this.state = this.props.state; /** * Whether or not the composer currently has focus. @@ -32,39 +25,45 @@ class Composer extends Component { * @type {Boolean} */ this.active = false; + + // Store the initial position so that we can trigger animations correctly. + this.prevPosition = this.state.position; } view() { + const body = this.state.body; const classes = { - normal: this.position === Composer.PositionEnum.NORMAL, - minimized: this.position === Composer.PositionEnum.MINIMIZED, - fullScreen: this.position === Composer.PositionEnum.FULLSCREEN, + normal: this.state.position === ComposerState.Position.NORMAL, + minimized: this.state.position === ComposerState.Position.MINIMIZED, + fullScreen: this.state.position === ComposerState.Position.FULLSCREEN, active: this.active, + visible: this.state.isVisible(), }; - classes.visible = classes.normal || classes.minimized || classes.fullScreen; - // If the composer is minimized, tell the composer's content component that - // it shouldn't let the user interact with it. Set up a handler so that if - // the content IS clicked, the composer will be shown. - if (this.component) this.component.props.disabled = classes.minimized; - - const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined; + // Set up a handler so that clicks on the content will show the composer. + const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined; return ( <div className={'Composer ' + classList(classes)}> <div className="Composer-handle" config={this.configHandle.bind(this)} /> <ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul> <div className="Composer-content" onclick={showIfMinimized}> - {this.component ? this.component.render() : ''} + {body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''} </div> </div> ); } config(isInitialized, context) { - // Set the height of the Composer element and its contents on each redraw, - // so that they do not lose it if their DOM elements are recreated. - this.updateHeight(); + if (this.state.position === this.prevPosition) { + // Set the height of the Composer element and its contents on each redraw, + // so that they do not lose it if their DOM elements are recreated. + this.updateHeight(); + } else { + this.animatePositionChange(); + + this.prevPosition = this.state.position; + } if (isInitialized) return; @@ -73,7 +72,7 @@ class Composer extends Component { context.retain = true; this.initializeHeight(); - this.$().hide().css('bottom', -this.computedHeight()); + this.$().hide().css('bottom', -this.state.computedHeight()); // Whenever any of the inputs inside the composer are have focus, we want to // add a class to the composer to draw attention to it. @@ -85,13 +84,6 @@ class Composer extends Component { // When the escape key is pressed on any inputs, close the composer. this.$().on('keydown', ':input', 'esc', () => this.close()); - // Don't let the user leave the page without first giving the composer's - // component a chance to scream at the user to make sure they don't - // unintentionally lose any contnet. - window.onbeforeunload = () => { - return (this.component && this.component.preventExit()) || undefined; - }; - const handlers = {}; $(window) @@ -166,13 +158,20 @@ class Composer extends Component { $('body').css('cursor', ''); } + /** + * Draw focus to the first focusable content element (the text editor). + */ + focus() { + this.$('.Composer-content :input:enabled:visible:first').focus(); + } + /** * Update the DOM to reflect the composer's current height. This involves * setting the height of the composer's root element, and adjusting the height * of any flexible elements inside the composer's body. */ updateHeight() { - const height = this.computedHeight(); + const height = this.state.computedHeight(); const $flexible = this.$('.Composer-flexible'); this.$().height(height); @@ -193,109 +192,59 @@ class Composer extends Component { */ updateBodyPadding() { const visible = - this.position !== Composer.PositionEnum.HIDDEN && this.position !== Composer.PositionEnum.MINIMIZED && this.$().css('position') !== 'absolute'; + this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone'; - const paddingBottom = visible ? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0; + const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0; $('#content').css({ paddingBottom }); } /** - * Determine whether or not the Composer is covering the screen. - * - * This will be true if the Composer is in full-screen mode on desktop, or - * if the Composer is positioned absolutely as on mobile devices. - * - * @return {Boolean} - * @public + * Trigger the right animation depending on the desired new position. */ - isFullScreen() { - return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute'; - } + animatePositionChange() { + // When exiting full-screen mode: focus content + if (this.prevPosition === ComposerState.Position.FULLSCREEN) { + this.focus(); + return; + } - /** - * Confirm with the user that they want to close the composer and lose their - * content. - * - * @return {Boolean} Whether or not the exit was cancelled. - */ - preventExit() { - if (this.component) { - const preventExit = this.component.preventExit(); - - if (preventExit) { - return !confirm(preventExit); - } + switch (this.state.position) { + case ComposerState.Position.HIDDEN: + return this.hide(); + case ComposerState.Position.MINIMIZED: + return this.minimize(); + case ComposerState.Position.FULLSCREEN: + return this.focus(); + case ComposerState.Position.NORMAL: + return this.show(); } } /** - * Load a content component into the composer. - * - * @param {Component} component - * @public + * Animate the Composer into the new position by changing the height. */ - load(component) { - if (this.preventExit()) return; - - // If we load a similar component into the composer, then Mithril will be - // able to diff the old/new contents and some DOM-related state from the - // old composer will remain. To prevent this from happening, we clear the - // component and force a redraw, so that the new component will be working - // on a blank slate. - if (this.component) { - this.clear(); - m.redraw(true); - } - - this.component = component; - } - - /** - * Clear the composer's content component. - * - * @public - */ - clear() { - this.component = null; - } - - /** - * Animate the Composer into the given position. - * - * @param {Composer.PositionEnum} position - */ - animateToPosition(position) { - // Before we redraw the composer to its new state, we need to save the - // current height of the composer, as well as the page's scroll position, so - // that we can smoothly transition from the old to the new state. - const oldPosition = this.position; + animateHeightChange() { const $composer = this.$().stop(true); const oldHeight = $composer.outerHeight(); const scrollTop = $(window).scrollTop(); - this.position = position; - - m.redraw(true); - - // Now that we've redrawn and the composer's DOM has been updated, we want - // to update the composer's height. Once we've done that, we'll capture the - // real value to use as the end point for our animation later on. $composer.show(); this.updateHeight(); const newHeight = $composer.outerHeight(); - if (oldPosition === Composer.PositionEnum.HIDDEN) { + if (this.prevPosition === ComposerState.Position.HIDDEN) { $composer.css({ bottom: -newHeight, height: newHeight }); } else { $composer.css({ height: oldHeight }); } - $composer.animate({ bottom: 0, height: newHeight }, 'fast', () => this.component.focus()); + const animation = $composer.animate({ bottom: 0, height: newHeight }, 'fast').promise(); this.updateBodyPadding(); $(window).scrollTop(scrollTop); + return animation; } /** @@ -313,40 +262,30 @@ class Composer extends Component { } /** - * Show the composer. + * Animate the composer sliding up from the bottom to take its normal height. * - * @public + * @private */ show() { - if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) { - return; - } + this.animateHeightChange().then(() => this.focus()); - this.animateToPosition(Composer.PositionEnum.NORMAL); - - if (this.isFullScreen()) { + if (app.screen() === 'phone') { this.$().css('top', $(window).scrollTop()); this.showBackdrop(); - this.component.focus(); } } /** - * Close the composer. + * Animate closing the composer. * - * @public + * @private */ hide() { const $composer = this.$(); // Animate the composer sliding down off the bottom edge of the viewport. - // Only when the animation is completed, update the Composer state flag and - // other elements on the page. + // Only when the animation is completed, update other elements on the page. $composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => { - this.position = Composer.PositionEnum.HIDDEN; - this.clear(); - m.redraw(); - $composer.hide(); this.hideBackdrop(); this.updateBodyPadding(); @@ -354,60 +293,17 @@ class Composer extends Component { } /** - * Confirm with the user so they don't lose their content, then close the - * composer. + * Shrink the composer until only its title is visible. * - * @public - */ - close() { - if (!this.preventExit()) { - this.hide(); - } - } - - /** - * Minimize the composer. Has no effect if the composer is hidden. - * - * @public + * @private */ minimize() { - if (this.position === Composer.PositionEnum.HIDDEN) return; - - this.animateToPosition(Composer.PositionEnum.MINIMIZED); + this.animateHeightChange(); this.$().css('top', 'auto'); this.hideBackdrop(); } - /** - * Take the composer into fullscreen mode. Has no effect if the composer is - * hidden. - * - * @public - */ - fullScreen() { - if (this.position !== Composer.PositionEnum.HIDDEN) { - this.position = Composer.PositionEnum.FULLSCREEN; - m.redraw(); - this.updateHeight(); - this.component.focus(); - } - } - - /** - * Exit fullscreen mode. - * - * @public - */ - exitFullScreen() { - if (this.position === Composer.PositionEnum.FULLSCREEN) { - this.position = Composer.PositionEnum.NORMAL; - m.redraw(); - this.updateHeight(); - this.component.focus(); - } - } - /** * Build an item list for the composer's controls. * @@ -416,23 +312,23 @@ class Composer extends Component { controlItems() { const items = new ItemList(); - if (this.position === Composer.PositionEnum.FULLSCREEN) { + if (this.state.position === ComposerState.Position.FULLSCREEN) { items.add( 'exitFullScreen', ComposerButton.component({ icon: 'fas fa-compress', title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'), - onclick: this.exitFullScreen.bind(this), + onclick: this.state.exitFullScreen.bind(this.state), }) ); } else { - if (this.position !== Composer.PositionEnum.MINIMIZED) { + if (this.state.position !== ComposerState.Position.MINIMIZED) { items.add( 'minimize', ComposerButton.component({ icon: 'fas fa-minus minimize', title: app.translator.trans('core.forum.composer.minimize_tooltip'), - onclick: this.minimize.bind(this), + onclick: this.state.minimize.bind(this.state), itemClassName: 'App-backControl', }) ); @@ -442,7 +338,7 @@ class Composer extends Component { ComposerButton.component({ icon: 'fas fa-expand', title: app.translator.trans('core.forum.composer.full_screen_tooltip'), - onclick: this.fullScreen.bind(this), + onclick: this.state.fullScreen.bind(this.state), }) ); } @@ -452,7 +348,7 @@ class Composer extends Component { ComposerButton.component({ icon: 'fas fa-times', title: app.translator.trans('core.forum.composer.close_tooltip'), - onclick: this.close.bind(this), + onclick: this.state.close.bind(this.state), }) ); } @@ -464,10 +360,10 @@ class Composer extends Component { * Initialize default Composer height. */ initializeHeight() { - this.height = localStorage.getItem('composerHeight'); + this.state.height = localStorage.getItem('composerHeight'); - if (!this.height) { - this.height = this.defaultHeight(); + if (!this.state.height) { + this.state.height = this.defaultHeight(); } } @@ -479,60 +375,14 @@ class Composer extends Component { return this.$().height(); } - /** - * Minimum height of the Composer. - * @returns {Integer} - */ - minimumHeight() { - return 200; - } - - /** - * Maxmimum height of the Composer. - * @returns {Integer} - */ - maximumHeight() { - return $(window).height() - $('#header').outerHeight(); - } - - /** - * Computed the composer's current height, based on the intended height, and - * the composer's current state. This will be applied to the composer's - * content's DOM element. - * @returns {Integer|String} - */ - computedHeight() { - // If the composer is minimized, then we don't want to set a height; we'll - // let the CSS decide how high it is. If it's fullscreen, then we need to - // make it as high as the window. - if (this.position === Composer.PositionEnum.MINIMIZED) { - return ''; - } else if (this.position === Composer.PositionEnum.FULLSCREEN) { - return $(window).height(); - } - - // Otherwise, if it's normal or hidden, then we use the intended height. - // We don't let the composer get too small or too big, though. - return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight())); - } - /** * Save a new Composer height and update the DOM. * @param {Integer} height */ changeHeight(height) { - this.height = height; + this.state.height = height; this.updateHeight(); - localStorage.setItem('composerHeight', this.height); + localStorage.setItem('composerHeight', this.state.height); } } - -Composer.PositionEnum = { - HIDDEN: 'hidden', - NORMAL: 'normal', - MINIMIZED: 'minimized', - FULLSCREEN: 'fullScreen', -}; - -export default Composer; diff --git a/js/src/forum/components/ComposerBody.js b/js/src/forum/components/ComposerBody.js index 670785228..3f22dc608 100644 --- a/js/src/forum/components/ComposerBody.js +++ b/js/src/forum/components/ComposerBody.js @@ -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 ( - <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> + <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"> + {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> - {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; } /** diff --git a/js/src/forum/components/DiscussionComposer.js b/js/src/forum/components/DiscussionComposer.js index f8123d625..57cda5767 100644 --- a/js/src/forum/components/DiscussionComposer.js +++ b/js/src/forum/components/DiscussionComposer.js @@ -16,12 +16,14 @@ export default class DiscussionComposer extends ComposerBody { init() { super.init(); + this.composer.fields.title = this.composer.fields.title || m.prop(''); + /** * The value of the title input. * * @type {Function} */ - this.title = m.prop(''); + this.title = this.composer.fields.title; } static initProps(props) { @@ -66,14 +68,14 @@ export default class DiscussionComposer extends ComposerBody { if (e.which === 13) { // Return e.preventDefault(); - this.editor.setSelectionRange(0, 0); + this.composer.editor.moveCursorTo(0); } m.redraw.strategy('none'); } - preventExit() { - return (this.title() || this.content()) && this.props.confirmExit; + hasChanges() { + return this.title() || this.composer.fields.content(); } /** @@ -84,7 +86,7 @@ export default class DiscussionComposer extends ComposerBody { data() { return { title: this.title(), - content: this.content(), + content: this.composer.fields.content(), }; } @@ -97,7 +99,7 @@ export default class DiscussionComposer extends ComposerBody { .createRecord('discussions') .save(data) .then((discussion) => { - app.composer.hide(); + this.composer.hide(); app.discussions.refresh(); m.route(app.route.discussion(discussion)); }, this.loaded.bind(this)); diff --git a/js/src/forum/components/DiscussionPage.js b/js/src/forum/components/DiscussionPage.js index 27cecc520..1d1a6678d 100644 --- a/js/src/forum/components/DiscussionPage.js +++ b/js/src/forum/components/DiscussionPage.js @@ -79,7 +79,7 @@ export default class DiscussionPage extends Page { // we'll just close it. app.pane.disable(); - if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) { + if (app.composer.composingReplyTo(this.discussion) && !app.composer.fields.content()) { app.composer.hide(); } else { app.composer.minimize(); diff --git a/js/src/forum/components/EditPostComposer.js b/js/src/forum/components/EditPostComposer.js index 258e8ecc3..a49d133f0 100644 --- a/js/src/forum/components/EditPostComposer.js +++ b/js/src/forum/components/EditPostComposer.js @@ -20,16 +20,6 @@ function minimizeComposerIfFullScreen(e) { * - `post` */ export default class EditPostComposer extends ComposerBody { - init() { - super.init(); - - this.editor.props.preview = (e) => { - minimizeComposerIfFullScreen(e); - - m.route(app.route.post(this.props.post)); - }; - } - static initProps(props) { super.initProps(props); @@ -64,6 +54,15 @@ export default class EditPostComposer extends ComposerBody { return items; } + /** + * Jump to the preview when triggered by the text editor. + */ + jumpToPreview(e) { + minimizeComposerIfFullScreen(e); + + m.route(app.route.post(this.props.post)); + } + /** * Get the data to submit to the server when the post is saved. * @@ -71,7 +70,7 @@ export default class EditPostComposer extends ComposerBody { */ data() { return { - content: this.content(), + content: this.composer.fields.content(), }; } @@ -86,7 +85,7 @@ export default class EditPostComposer extends ComposerBody { // If we're currently viewing the discussion which this edit was made // in, then we can scroll to the post. if (app.viewingDiscussion(discussion)) { - app.current.stream.goToNumber(post.number()); + app.current.get('stream').goToNumber(post.number()); } else { // Otherwise, we'll create an alert message to inform the user that // their edit has been made, containing a button which will @@ -107,7 +106,7 @@ export default class EditPostComposer extends ComposerBody { }); } - app.composer.hide(); + this.composer.hide(); }, this.loaded.bind(this)); } } diff --git a/js/src/forum/components/IndexPage.js b/js/src/forum/components/IndexPage.js index 5f48640ff..f1bc41cb5 100644 --- a/js/src/forum/components/IndexPage.js +++ b/js/src/forum/components/IndexPage.js @@ -273,12 +273,10 @@ export default class IndexPage extends Page { const deferred = m.deferred(); if (app.session.user) { - const component = new DiscussionComposer({ user: app.session.user }); - - app.composer.load(component); + app.composer.load(DiscussionComposer, { user: app.session.user }); app.composer.show(); - deferred.resolve(component); + deferred.resolve(app.composer); } else { deferred.reject(); diff --git a/js/src/forum/components/ReplyComposer.js b/js/src/forum/components/ReplyComposer.js index af942d186..65c8b5f7e 100644 --- a/js/src/forum/components/ReplyComposer.js +++ b/js/src/forum/components/ReplyComposer.js @@ -20,16 +20,6 @@ function minimizeComposerIfFullScreen(e) { * - `discussion` */ export default class ReplyComposer extends ComposerBody { - init() { - super.init(); - - this.editor.props.preview = (e) => { - minimizeComposerIfFullScreen(e); - - m.route(app.route.discussion(this.props.discussion, 'reply')); - }; - } - static initProps(props) { super.initProps(props); @@ -61,6 +51,15 @@ export default class ReplyComposer extends ComposerBody { return items; } + /** + * Jump to the preview when triggered by the text editor. + */ + jumpToPreview(e) { + minimizeComposerIfFullScreen(e); + + m.route(app.route.discussion(this.props.discussion, 'reply')); + } + /** * Get the data to submit to the server when the reply is saved. * @@ -68,7 +67,7 @@ export default class ReplyComposer extends ComposerBody { */ data() { return { - content: this.content(), + content: this.composer.fields.content(), relationships: { discussion: this.props.discussion }, }; } @@ -110,7 +109,7 @@ export default class ReplyComposer extends ComposerBody { }); } - app.composer.hide(); + this.composer.hide(); }, this.loaded.bind(this)); } } diff --git a/js/src/forum/components/ReplyPlaceholder.js b/js/src/forum/components/ReplyPlaceholder.js index fba927d00..7a7970403 100644 --- a/js/src/forum/components/ReplyPlaceholder.js +++ b/js/src/forum/components/ReplyPlaceholder.js @@ -15,7 +15,7 @@ import DiscussionControls from '../utils/DiscussionControls'; */ export default class ReplyPlaceholder extends Component { view() { - if (app.composingReplyTo(this.props.discussion)) { + if (app.composer.composingReplyTo(this.props.discussion)) { return ( <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; diff --git a/js/src/forum/components/TextEditor.js b/js/src/forum/components/TextEditor.js index 1f3e971de..a3c054472 100644 --- a/js/src/forum/components/TextEditor.js +++ b/js/src/forum/components/TextEditor.js @@ -1,5 +1,6 @@ import Component from '../../common/Component'; import ItemList from '../../common/utils/ItemList'; +import SuperTextarea from '../../common/utils/SuperTextarea'; import listItems from '../../common/helpers/listItems'; import Button from '../../common/components/Button'; @@ -9,10 +10,12 @@ import Button from '../../common/components/Button'; * * ### Props * + * - `composer` * - `submitLabel` * - `value` * - `placeholder` * - `disabled` + * - `preview` */ export default class TextEditor extends Component { init() { @@ -21,7 +24,7 @@ export default class TextEditor extends Component { * * @type {String} */ - this.value = m.prop(this.props.value || ''); + this.value = this.props.value || ''; } view() { @@ -33,7 +36,7 @@ export default class TextEditor extends Component { oninput={m.withAttr('value', this.oninput.bind(this))} placeholder={this.props.placeholder || ''} disabled={!!this.props.disabled} - value={this.value()} + value={this.value} /> <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); } } diff --git a/js/src/forum/states/ComposerState.js b/js/src/forum/states/ComposerState.js new file mode 100644 index 000000000..d8cae0157 --- /dev/null +++ b/js/src/forum/states/ComposerState.js @@ -0,0 +1,285 @@ +import subclassOf from '../../common/utils/subclassOf'; +import ReplyComposer from '../components/ReplyComposer'; + +class ComposerState { + constructor() { + /** + * The composer's current position. + * + * @type {ComposerState.Position} + */ + this.position = ComposerState.Position.HIDDEN; + + /** + * The composer's intended height, which can be modified by the user + * (by dragging the composer handle). + * + * @type {Integer} + */ + this.height = null; + + /** + * The dynamic component being shown inside the composer. + * + * @type {Object} + */ + this.body = { attrs: {} }; + + /** + * A reference to the text editor that allows text manipulation. + * + * @type {SuperTextArea|null} + */ + this.editor = null; + + this.clear(); + + /** + * @deprecated BC layer, remove in Beta 15. + */ + this.component = this; + } + + /** + * Load a content component into the composer. + * + * @param {ComposerBody} componentClass + * @public + */ + load(componentClass, attrs) { + const body = { componentClass, attrs }; + + if (this.preventExit()) return; + + // If we load a similar component into the composer, then Mithril will be + // able to diff the old/new contents and some DOM-related state from the + // old composer will remain. To prevent this from happening, we clear the + // component and force a redraw, so that the new component will be working + // on a blank slate. + if (this.isVisible()) { + this.clear(); + m.redraw(true); + } + + this.body = body; + } + + /** + * Clear the composer's content component. + */ + clear() { + this.position = ComposerState.Position.HIDDEN; + this.body = { attrs: {} }; + this.editor = null; + this.onExit = null; + + this.fields = { + content: m.prop(''), + }; + + /** + * @deprecated BC layer, remove in Beta 15. + */ + this.content = this.fields.content; + this.value = this.fields.content; + } + + /** + * Show the composer. + * + * @public + */ + show() { + if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return; + + this.position = ComposerState.Position.NORMAL; + m.redraw(); + } + + /** + * Close the composer. + * + * @public + */ + hide() { + this.clear(); + m.redraw(); + } + + /** + * Confirm with the user so they don't lose their content, then close the + * composer. + * + * @public + */ + close() { + if (this.preventExit()) return; + + this.hide(); + } + + /** + * Minimize the composer. Has no effect if the composer is hidden. + * + * @public + */ + minimize() { + if (!this.isVisible()) return; + + this.position = ComposerState.Position.MINIMIZED; + m.redraw(); + } + + /** + * Take the composer into fullscreen mode. Has no effect if the composer is + * hidden. + * + * @public + */ + fullScreen() { + if (!this.isVisible()) return; + + this.position = ComposerState.Position.FULLSCREEN; + m.redraw(); + } + + /** + * Exit fullscreen mode. + * + * @public + */ + exitFullScreen() { + if (this.position !== ComposerState.Position.FULLSCREEN) return; + + this.position = ComposerState.Position.NORMAL; + m.redraw(); + } + + /** + * Determine whether the body matches the given component class and data. + * + * @param {object} type The component class to check against. Subclasses are + * accepted as well. + * @param {object} data + * @return {boolean} + */ + bodyMatches(type, data = {}) { + // Fail early when the body is of a different type + if (!subclassOf(this.body.componentClass, type)) return false; + + // Now that the type is known to be correct, we loop through the provided + // data to see whether it matches the data in the attributes for the body. + return Object.keys(data).every((key) => this.body.attrs[key] === data[key]); + } + + /** + * Determine whether or not the Composer is visible. + * + * True when the composer is displayed on the screen and has a body component. + * It could be open in "normal" or full-screen mode, or even minimized. + * + * @returns {boolean} + */ + isVisible() { + return this.position !== ComposerState.Position.HIDDEN; + } + + /** + * Determine whether or not the Composer is covering the screen. + * + * This will be true if the Composer is in full-screen mode on desktop, + * or if we are on a mobile device, where we always consider the composer as full-screen.. + * + * @return {Boolean} + * @public + */ + isFullScreen() { + return this.position === ComposerState.Position.FULLSCREEN || app.screen() === 'phone'; + } + + /** + * Check whether or not the user is currently composing a reply to a + * discussion. + * + * @param {Discussion} discussion + * @return {Boolean} + */ + composingReplyTo(discussion) { + return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion }); + } + + /** + * Confirm with the user that they want to close the composer and lose their + * content. + * + * @return {Boolean} Whether or not the exit was cancelled. + */ + preventExit() { + if (!this.isVisible()) return; + if (!this.onExit) return; + + if (this.onExit.callback()) { + return !confirm(this.onExit.message); + } + } + + /** + * Configure when / what to ask the user before closing the composer. + * + * The provided callback will be used to determine whether asking for + * confirmation is necessary. If the callback returns true at the time of + * closing, the provided text will be shown in a standard confirmation dialog. + * + * @param {Function} callback + * @param {String} message + */ + preventClosingWhen(callback, message) { + this.onExit = { callback, message }; + } + + /** + * Minimum height of the Composer. + * @returns {Integer} + */ + minimumHeight() { + return 200; + } + + /** + * Maxmimum height of the Composer. + * @returns {Integer} + */ + maximumHeight() { + return $(window).height() - $('#header').outerHeight(); + } + + /** + * Computed the composer's current height, based on the intended height, and + * the composer's current state. This will be applied to the composer's + * content's DOM element. + * @returns {Integer|String} + */ + computedHeight() { + // If the composer is minimized, then we don't want to set a height; we'll + // let the CSS decide how high it is. If it's fullscreen, then we need to + // make it as high as the window. + if (this.position === ComposerState.Position.MINIMIZED) { + return ''; + } else if (this.position === ComposerState.Position.FULLSCREEN) { + return $(window).height(); + } + + // Otherwise, if it's normal or hidden, then we use the intended height. + // We don't let the composer get too small or too big, though. + return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight())); + } +} + +ComposerState.Position = { + HIDDEN: 'hidden', + NORMAL: 'normal', + MINIMIZED: 'minimized', + FULLSCREEN: 'fullScreen', +}; + +export default ComposerState; diff --git a/js/src/forum/utils/DiscussionControls.js b/js/src/forum/utils/DiscussionControls.js index 45d5f94d6..7a49dcab8 100644 --- a/js/src/forum/utils/DiscussionControls.js +++ b/js/src/forum/utils/DiscussionControls.js @@ -167,13 +167,11 @@ export default { if (app.session.user) { if (this.canReply()) { - let component = app.composer.component; - if (!app.composingReplyTo(this) || forceRefresh) { - component = new ReplyComposer({ + if (!app.composer.composingReplyTo(this) || forceRefresh) { + app.composer.load(ReplyComposer, { user: app.session.user, discussion: this, }); - app.composer.load(component); } app.composer.show(); @@ -181,7 +179,7 @@ export default { app.current.get('stream').goToNumber('reply'); } - deferred.resolve(component); + deferred.resolve(app.composer); } else { deferred.reject(); } diff --git a/js/src/forum/utils/PostControls.js b/js/src/forum/utils/PostControls.js index 4c36ed4bc..c90b097c7 100644 --- a/js/src/forum/utils/PostControls.js +++ b/js/src/forum/utils/PostControls.js @@ -130,12 +130,10 @@ export default { editAction() { const deferred = m.deferred(); - const component = new EditPostComposer({ post: this }); - - app.composer.load(component); + app.composer.load(EditPostComposer, { post: this }); app.composer.show(); - deferred.resolve(component); + deferred.resolve(app.composer); return deferred.promise; }, diff --git a/less/common/scaffolding.less b/less/common/scaffolding.less index 87ae50792..5f4a9b562 100644 --- a/less/common/scaffolding.less +++ b/less/common/scaffolding.less @@ -1,3 +1,14 @@ +// Store the current responsive screen mode in a CSS variable, to make it +// available to the JS code. +:root { + --flarum-screen: none; + + @media @phone { --flarum-screen: phone } + @media @tablet { --flarum-screen: tablet } + @media @desktop { --flarum-screen: desktop } + @media @desktop-hd { --flarum-screen: desktop-hd } +} + * { &, &:before,