MobX based editing

This commit is contained in:
Jori Lallo
2016-06-02 22:04:33 -07:00
parent aac20341f7
commit e6c7e95115
26 changed files with 436 additions and 185 deletions

View File

@@ -1,73 +1,45 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { observer } from 'mobx-react';
import {
resetEditor,
updateText,
updateTitle,
replaceText,
} from 'actions/EditorActions';
import {
resetDocument,
fetchDocumentAsync,
saveDocumentAsync,
} from 'actions/DocumentActions';
import state from './DocumentEditState';
import Layout, { Title } from 'components/Layout';
import Switch from 'components/Switch';
import Layout, { Title, HeaderAction } from 'components/Layout';
import Flex from 'components/Flex';
import MarkdownEditor from 'components/MarkdownEditor';
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
import CenteredContent from 'components/CenteredContent';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import SaveAction from './components/SaveAction';
import Preview from './components/Preview';
import EditorPane from './components/EditorPane';
import styles from './DocumentEdit.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
@observer
class DocumentEdit extends Component {
static propTypes = {
updateText: React.PropTypes.func.isRequired,
updateTitle: React.PropTypes.func.isRequired,
replaceText: React.PropTypes.func.isRequired,
resetDocument: React.PropTypes.func.isRequired,
saveDocumentAsync: React.PropTypes.func.isRequired,
text: React.PropTypes.string,
title: React.PropTypes.string,
isSaving: React.PropTypes.bool,
}
state = {
loadingDocument: false,
}
componentWillMount = () => {
this.props.resetEditor();
this.props.resetDocument();
}
componentDidMount = () => {
const id = this.props.routeParams.id;
this.props.fetchDocumentAsync(id);
}
componentWillReceiveProps = (nextProps) => {
if (!this.props.document && nextProps.document) {
const doc = nextProps.document;
this.props.updateText(doc.text);
this.props.updateTitle(doc.title);
}
state.documentId = this.props.params.id;
state.fetchDocument();
}
onSave = () => {
if (this.props.title.length === 0) {
alert("Please add a title before saving (hint: Write a markdown header)");
return
}
// if (this.props.title.length === 0) {
// alert("Please add a title before saving (hint: Write a markdown header)");
// return
// }
state.updateDocument();
}
this.props.saveDocumentAsync(
null,
this.props.document.id,
this.props.title,
this.props.text,
)
state = {}
onScroll = (scrollTop) => {
this.setState({
scrollTop: scrollTop,
})
}
render() {
@@ -76,62 +48,60 @@ class DocumentEdit extends Component {
truncate={ 60 }
placeholder={ "Untitle document" }
>
{ this.props.title }
{ state.title }
</Title>
);
const actions = (
<Flex direction="row">
<HeaderAction>
<SaveAction
onClick={ this.onSave }
disabled={ state.isSaving }
/>
</HeaderAction>
<DropdownMenu label="More">
<MenuItem onClick={ state.togglePreview }>
Preview <Switch checked={ state.preview } />
</MenuItem>
</DropdownMenu>
</Flex>
);
return (
<Layout
actions={(
<Flex direction="row" align="center">
<SaveAction onClick={ this.onSave } />
</Flex>
)}
actions={ actions }
title={ title }
fixed={ true }
loading={ this.props.isSaving }
loading={ state.isSaving }
>
{ (this.props.isLoading && !this.props.document) ? (
{ (state.isFetching) ? (
<CenteredContent>
<AtlasPreviewLoading />
</CenteredContent>
) : (
<MarkdownEditor
onChange={ this.props.updateText }
text={ this.props.text }
replaceText={this.props.replaceText}
/>
<div className={ styles.container }>
<EditorPane
fullWidth={ !state.preview }
onScroll={ this.onScroll }
>
<MarkdownEditor
onChange={ state.updateText }
text={ state.text }
replaceText={ state.replaceText }
/>
</EditorPane>
{ state.preview ? (
<EditorPane
scrollTop={ this.state.scrollTop }
>
<Preview html={ state.htmlPreview } />
</EditorPane>
) : null }
</div>
) }
</Layout>
);
}
}
const mapStateToProps = (state) => {
return {
document: state.document.data,
text: state.editor.text,
title: state.editor.title,
isLoading: state.document.isLoading,
isSaving: state.document.isSaving,
};
};
const mapDispatchToProps = (dispatch) => {
return bindActionCreators({
resetEditor,
updateText,
updateTitle,
replaceText,
resetDocument,
fetchDocumentAsync,
saveDocumentAsync,
}, dispatch)
};
DocumentEdit = connect(
mapStateToProps,
mapDispatchToProps,
)(DocumentEdit);
export default DocumentEdit;

View File

@@ -0,0 +1,45 @@
@import '../../utils/constants.scss';
.preview {
display: flex;
flex: 1;
padding: 50px 0;
padding: 50px 3em;
max-width: 50em;
line-height: 1.5em;
}
.container {
display: flex;
position: fixed;
top: $headerHeight;
bottom: 0;
left: 0;
right: 0;
}
.editorPane {
flex: 0 0 50%;
justify-content: center;
overflow: scroll;
}
.paneContent {
flex: 1;
justify-content: center;
}
.fullWidth {
flex: 1;
display: flex;
.paneContent {
display: flex;
}
}
:global {
::-webkit-scrollbar {
display: none;
}
}

View File

@@ -0,0 +1,108 @@
import { observable, action, computed, autorun } from 'mobx';
import { client } from 'utils/ApiClient';
import localforage from 'localforage';
import { convertToMarkdown } from 'utils/markdown';
import { browserHistory } from 'react-router'
const DOCUMENT_EDIT_SETTINGS = 'DOCUMENT_EDIT_SETTINGS';
const parseHeader = (text) => {
const firstLine = text.split(/\r?\n/)[0];
const match = firstLine.match(/^#+ +(.*)$/);
if (match) {
return match[1];
}
}
const documentEditState = new class DocumentEditState {
@observable documentId = null;
@observable title = 'title';
@observable text = 'default state';
@observable preview;
@observable isFetching;
@observable isSaving;
/* Computed */
@computed get htmlPreview() {
// Only compute if preview is active
// if (this.preview) {
// }
return convertToMarkdown(this.text);
}
/* Actions */
@action fetchDocument = async () => {
this.isFetching = true;
try {
const data = await client.post('/documents.info', {
id: this.documentId,
})
const { title, text } = data.data;
this.title = title;
this.text = text;
} catch (e) {
console.error("Something went wrong");
}
this.isFetching = false;
}
@action updateDocument = async (nextPath) => {
if (this.isSaving) return;
this.isSaving = true;
try {
const data = await client.post('/documents.update', {
id: this.documentId,
title: this.title,
text: this.text,
})
browserHistory.push(`/atlas/${data.data.atlas.id}`);
} catch (e) {
console.error("Something went wrong");
}
this.isSaving = false;
}
@action updateText = (text) => {
this.text = text;
this.title = parseHeader(text);
}
@action updateTitle = (title) => {
this.title = title;
}
@action replaceText = (args) => {
this.text = this.text.replace(args.original, args.new);
}
@action togglePreview = () => {
console.log('toggle')
this.preview = !this.preview;
}
constructor() {
// Rehydrate
localforage.getItem(DOCUMENT_EDIT_SETTINGS)
.then(data => {
this.preview = data.preview;
});
}
}();
// Persist settings to localStorage
autorun(() => {
localforage.setItem(DOCUMENT_EDIT_SETTINGS, {
preview: documentEditState.preview,
});
});
export default documentEditState;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import styles from '../DocumentEdit.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
class EditorPane extends React.Component {
static propTypes = {
children: React.PropTypes.node.isRequired,
onScroll: React.PropTypes.func.isRequired,
scrollTop: React.PropTypes.number,
fullWidth: React.PropTypes.bool,
}
componentWillReceiveProps = (nextProps) => {
if (nextProps.scrollTop) {
this.scrollToPosition(nextProps.scrollTop)
}
}
componentDidMount = () => {
this.refs.pane.addEventListener('scroll', this.handleScroll);
}
componentWillUnmount = () => {
this.refs.pane.removeEventListener('scroll', this.handleScroll);
}
handleScroll = (e) => {
setTimeout(() => {
const element = this.refs.pane;
const contentEl = this.refs.content;
this.props.onScroll(element.scrollTop / contentEl.offsetHeight);
}, 50);
}
scrollToPosition = (percentage) => {
const contentEl = this.refs.content;
// Push to edges
if (percentage < 0.02) percentage = 0;
if (percentage > 0.99) percentage = 100;
this.refs.pane.scrollTop = percentage * contentEl.offsetHeight;
}
render() {
return (
<div
className={ cx(styles.editorPane, { fullWidth: this.props.fullWidth }) }
ref="pane"
>
<div ref="content" className={ styles.paneContent }>
{ this.props.children }
</div>
</div>
);
}
};
export default EditorPane;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { DocumentHtml } from 'components/Document';
import styles from '../DocumentEdit.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
const Preview = (props) => {
return (
<div className={ styles.preview }>
<DocumentHtml html={ props.html } />
</div>
);
};
Preview.propTypes = {
html: React.PropTypes.string.isRequired,
};
export default Preview;

View File

@@ -1,12 +1,16 @@
import React from 'react';
import { Arrow } from 'rebass';
import { observer } from 'mobx-react';
@observer
class SaveAction extends React.Component {
propTypes = {
onClick: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool,
}
onClick = (event) => {
if (this.props.disabled) return;
event.preventDefault();
this.props.onClick();
}
@@ -14,7 +18,11 @@ class SaveAction extends React.Component {
render() {
return (
<div>
<a href onClick={ this.onClick }>Save</a>
<a
href
onClick={ this.onClick }
style={{ opacity: this.props.disabled ? 0.5 : 1 }}
>Save</a>
</div>
);
}