Skip to content

Commit

Permalink
Extract Composer state (#2161)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
askvortsov1 authored Jul 24, 2020
1 parent 62a2e84 commit 5e465f6
Show file tree
Hide file tree
Showing 18 changed files with 630 additions and 396 deletions.
10 changes: 10 additions & 0 deletions js/src/common/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ export default class Application {
return null;
}

/**
* Determine the current screen mode, based on our media queries.
*
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
*/
screen() {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue('--flarum-screen');
}

/**
* Set the <title> of the page.
*
Expand Down
37 changes: 37 additions & 0 deletions js/src/common/components/ConfirmDocumentUnload.js
Original file line number Diff line number Diff line change
@@ -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 changes: 109 additions & 0 deletions js/src/common/utils/SuperTextarea.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
24 changes: 7 additions & 17 deletions js/src/forum/ForumApplication.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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'));
Expand All @@ -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.
*
Expand Down
4 changes: 2 additions & 2 deletions js/src/forum/components/CommentPost.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;

Expand Down
Loading

0 comments on commit 5e465f6

Please sign in to comment.