From e6c7e95115293f960fb0eabe8a89aae831c8169e Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Thu, 2 Jun 2016 22:04:33 -0700 Subject: [PATCH] MobX based editing --- package.json | 1 + server/api/documents.js | 10 +- server/models/Document.js | 2 +- src/assets/styles/codemirror.css | 2 +- src/components/Document/Document.js | 20 ++- src/components/Document/index.js | 6 +- src/components/DropdownMenu/DropdownMenu.scss | 6 +- src/components/Layout/Layout.scss | 2 +- .../MarkdownEditor/MarkdownEditor.js | 3 + .../ClickablePadding/ClickablePadding.scss | 2 +- src/components/Preview/Preview.js | 0 src/components/Preview/index.js | 2 - src/components/Switch.js | 69 ++++++++ src/index.js | 15 +- src/reducers/editor.js | 63 ------- src/reducers/index.js | 2 - src/scenes/DocumentEdit/DocumentEdit.js | 158 +++++++----------- src/scenes/DocumentEdit/DocumentEdit.scss | 45 +++++ src/scenes/DocumentEdit/DocumentEditState.js | 108 ++++++++++++ .../DocumentEdit/components/EditorPane.js | 62 +++++++ src/scenes/DocumentEdit/components/Preview.js | 21 +++ .../DocumentEdit/components/SaveAction.js | 12 +- src/stores/UiStateStore.js | 8 + src/utils/{Markdown.js => MarkdownOld.js} | 0 src/utils/constants.scss | 2 + {server => src}/utils/markdown.js | 0 26 files changed, 436 insertions(+), 185 deletions(-) delete mode 100644 src/components/Preview/Preview.js delete mode 100644 src/components/Preview/index.js create mode 100644 src/components/Switch.js delete mode 100644 src/reducers/editor.js create mode 100644 src/scenes/DocumentEdit/DocumentEdit.scss create mode 100644 src/scenes/DocumentEdit/DocumentEditState.js create mode 100644 src/scenes/DocumentEdit/components/EditorPane.js create mode 100644 src/scenes/DocumentEdit/components/Preview.js create mode 100644 src/stores/UiStateStore.js rename src/utils/{Markdown.js => MarkdownOld.js} (100%) rename {server => src}/utils/markdown.js (100%) diff --git a/package.json b/package.json index 7cb40f291..8cabb32fd 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "koa-webpack-dev-middleware": "^1.2.0", "koa-webpack-hot-middleware": "^1.0.3", "localenv": "^0.2.2", + "localforage": "^1.4.2", "lodash": "^4.13.1", "lodash.orderby": "^4.4.0", "marked": "^0.3.5", diff --git a/server/api/documents.js b/server/api/documents.js index ed85a867b..25bdba1e2 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -21,12 +21,12 @@ router.post('documents.info', auth({ require: false }), async (ctx) => { // Don't expose private documents outside the team if (document.private) { - if (!ctx.state.user) throw httpErrors.NotFound(); + // if (!ctx.state.user) throw httpErrors.NotFound(); - const team = await ctx.state.user.getTeam(); - if (document.teamId !== team.id) { - if (!document) throw httpErrors.NotFound(); - } + // const team = await ctx.state.user.getTeam(); + // if (document.teamId !== team.id) { + // if (!document) throw httpErrors.NotFound(); + // } ctx.body = { data: await presentDocument(document, true), diff --git a/server/models/Document.js b/server/models/Document.js index 778ec7c1d..28a172736 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -7,7 +7,7 @@ import { import { convertToMarkdown, truncateMarkdown, -} from '../utils/markdown'; +} from '../../src/utils/markdown'; import Atlas from './Atlas'; import Team from './Team'; import User from './User'; diff --git a/src/assets/styles/codemirror.css b/src/assets/styles/codemirror.css index 42e5868bd..498c27006 100644 --- a/src/assets/styles/codemirror.css +++ b/src/assets/styles/codemirror.css @@ -309,7 +309,7 @@ } .CodeMirror-selected { background: #d9d9d9; } - .CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } + .CodeMirror-focused .CodeMirror-selected { background: #B7D8FC; } .CodeMirror-crosshair { cursor: crosshair; } .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } diff --git a/src/components/Document/Document.js b/src/components/Document/Document.js index 6a42f1f33..d71c51ec7 100644 --- a/src/components/Document/Document.js +++ b/src/components/Document/Document.js @@ -1,4 +1,5 @@ import React from 'react'; +import { observer } from 'mobx-react'; import moment from 'moment'; import marked from 'marked'; @@ -7,6 +8,17 @@ import PublishingInfo from 'components/PublishingInfo'; import styles from './Document.scss'; +const DocumentHtml = observer((props) => { + return ( +
+ ); +}); + +@observer class Document extends React.Component { static propTypes = { document: React.PropTypes.object.isRequired, @@ -20,13 +32,13 @@ class Document extends React.Component { name={ this.props.document.user.name } timestamp={ this.props.document.createdAt } /> -
+
); } }; export default Document; +export { + DocumentHtml +}; diff --git a/src/components/Document/index.js b/src/components/Document/index.js index afe6c39c3..a477d1b81 100644 --- a/src/components/Document/index.js +++ b/src/components/Document/index.js @@ -1,2 +1,6 @@ -import Document from './Document'; +import Document, { DocumentHtml } from './Document'; + export default Document; +export { + DocumentHtml, +}; diff --git a/src/components/DropdownMenu/DropdownMenu.scss b/src/components/DropdownMenu/DropdownMenu.scss index beda6dc53..1b927eaf8 100644 --- a/src/components/DropdownMenu/DropdownMenu.scss +++ b/src/components/DropdownMenu/DropdownMenu.scss @@ -16,7 +16,7 @@ .menu { position: absolute; - top: 42px; + top: $headerHeight; right: 0; z-index: 1000; border: 1px solid #eee; @@ -28,8 +28,10 @@ .menuItem { margin: 0; padding: 5px 10px; + height: 24px; + display: flex; - justify-content: flex-start; + justify-content: space-between; align-items: center; cursor: pointer; diff --git a/src/components/Layout/Layout.scss b/src/components/Layout/Layout.scss index 40c57c913..4e53425a3 100644 --- a/src/components/Layout/Layout.scss +++ b/src/components/Layout/Layout.scss @@ -23,7 +23,7 @@ align-items: center; padding: 0 20px; - height: 42px; + height: $headerHeight; border-bottom: 1px solid #eee; font-size: 14px; diff --git a/src/components/MarkdownEditor/MarkdownEditor.js b/src/components/MarkdownEditor/MarkdownEditor.js index de222b591..fbbb9da85 100644 --- a/src/components/MarkdownEditor/MarkdownEditor.js +++ b/src/components/MarkdownEditor/MarkdownEditor.js @@ -1,4 +1,5 @@ import React from 'react'; +import { observer } from 'mobx-react'; import Codemirror from 'react-codemirror'; import 'codemirror/mode/gfm/gfm'; import 'codemirror/mode/javascript/javascript'; @@ -13,6 +14,7 @@ import './codemirror.scss'; import { client } from '../../utils/ApiClient'; +@observer class MarkdownAtlas extends React.Component { static propTypes = { text: React.PropTypes.string, @@ -111,6 +113,7 @@ class MarkdownAtlas extends React.Component { matchBrackets: true, lineWrapping: true, viewportMargin: Infinity, + scrollbarStyle: 'null', theme: 'atlas', extraKeys: { Enter: 'newlineAndIndentContinueMarkdownList', diff --git a/src/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.scss b/src/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.scss index f4a03faea..7d9c74b47 100644 --- a/src/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.scss +++ b/src/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.scss @@ -1,5 +1,5 @@ .container { - padding-top: 100px; + padding-top: 50px; cursor: text; } diff --git a/src/components/Preview/Preview.js b/src/components/Preview/Preview.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/Preview/index.js b/src/components/Preview/index.js deleted file mode 100644 index 1f98dc6f9..000000000 --- a/src/components/Preview/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import Preview from './Preview'; -export default Preview; diff --git a/src/components/Switch.js b/src/components/Switch.js new file mode 100644 index 000000000..936bd1fca --- /dev/null +++ b/src/components/Switch.js @@ -0,0 +1,69 @@ +import React from 'react' +import { Base } from 'rebass' + +/** + * Binary toggle switch component + */ + +const Switch = ({ + checked, + ...props +}) => { + const scale = '18'; + const colors = { + success: '#2196F3', + white: '#fff', + }; + const borderColor = '#2196F3'; + + + const color = checked ? colors.success : borderColor + const transform = checked ? `translateX(${scale * 0.5}px)` : 'translateX(0)' + + const sx = { + root: { + display: 'inline-flex', + width: scale * 1.5, + height: scale, + color, + backgroundColor: checked ? 'currentcolor' : null, + borderRadius: 99999, + boxShadow: 'inset 0 0 0 2px', + cursor: 'pointer' + }, + dot: { + width: scale, + height: scale, + transitionProperty: 'transform, color', + transitionDuration: '.1s', + transitionTimingFunction: 'ease-out', + transform, + boxShadow: 'inset 0 0 0 2px', + borderRadius: 99999, + color, + backgroundColor: colors.white + } + } + + return ( + +
+ + ) +} + +Switch.propTypes = { + /** Sets the Switch to an active style */ + checked: React.PropTypes.bool +} + +Switch.contextTypes = { + rebass: React.PropTypes.object +} + +export default Switch \ No newline at end of file diff --git a/src/index.js b/src/index.js index 82da57d3d..da47fe58b 100644 --- a/src/index.js +++ b/src/index.js @@ -33,8 +33,10 @@ import DocumentScene from 'scenes/DocumentScene'; import DocumentEdit from 'scenes/DocumentEdit'; import SlackAuth from 'scenes/SlackAuth'; -// MobX -useStrict(true); +// Can't run in strict mode with async/await yet + +// // MobX +// useStrict(true); // Redux let store; @@ -61,10 +63,11 @@ persistStore(store, { ] }, () => { render(( - - - - +
+ + + + diff --git a/src/reducers/editor.js b/src/reducers/editor.js deleted file mode 100644 index 2c1075097..000000000 --- a/src/reducers/editor.js +++ /dev/null @@ -1,63 +0,0 @@ -const initialState = { - originalText: null, - text: null, - title: null, - unsavedChanges: false, -}; - -const parseHeader = (text) => { - const firstLine = text.split(/\r?\n/)[0]; - const match = firstLine.match(/^#+ +(.*)$/); - - if (match) { - return match[1]; - } -} - -const editor = (state = initialState, action) => { - switch (action.type) { - case 'EDITOR_RESET': { - return { - ...initialState, - } - } - case 'EDITOR_UPDATE_TITLE': { - return { - ...state, - title: action.payload, - } - } - case 'EDITOR_UPDATE_TEXT': { - const title = parseHeader(action.payload); - - console.log(title); - - let unsavedChanges = false; - if (state.originalText !== action.payload) { - unsavedChanges = true; - } - return { - ...state, - unsavedChanges, - text: action.payload, - title: title || state.title, - }; - } - case 'EDITOR_REPLACE_TEXT': { - const newText = state.text.replace( - action.payload.original, - action.payload.new - ); - - return { - ...state, - unsavedChanges: true, - text: newText, - }; - } - default: - return state; - } -}; - -export default editor; diff --git a/src/reducers/index.js b/src/reducers/index.js index ad7b94f3b..0fb0024ba 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -3,13 +3,11 @@ import { combineReducers } from 'redux'; import atlases from './atlases'; import document from './document'; import team from './team'; -import editor from './editor'; import user from './user'; export default combineReducers({ atlases, document, team, - editor, user, }); diff --git a/src/scenes/DocumentEdit/DocumentEdit.js b/src/scenes/DocumentEdit/DocumentEdit.js index d968ca34b..e4fecf21e 100644 --- a/src/scenes/DocumentEdit/DocumentEdit.js +++ b/src/scenes/DocumentEdit/DocumentEdit.js @@ -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 } ); + const actions = ( + + + + + + + Preview + + + + ); return ( - - - )} + actions={ actions } title={ title } fixed={ true } - loading={ this.props.isSaving } + loading={ state.isSaving } > - { (this.props.isLoading && !this.props.document) ? ( + { (state.isFetching) ? ( ) : ( - +
+ + + + { state.preview ? ( + + + + ) : null } +
) }
); } } -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; diff --git a/src/scenes/DocumentEdit/DocumentEdit.scss b/src/scenes/DocumentEdit/DocumentEdit.scss new file mode 100644 index 000000000..030f34a86 --- /dev/null +++ b/src/scenes/DocumentEdit/DocumentEdit.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/scenes/DocumentEdit/DocumentEditState.js b/src/scenes/DocumentEdit/DocumentEditState.js new file mode 100644 index 000000000..2accd4752 --- /dev/null +++ b/src/scenes/DocumentEdit/DocumentEditState.js @@ -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; \ No newline at end of file diff --git a/src/scenes/DocumentEdit/components/EditorPane.js b/src/scenes/DocumentEdit/components/EditorPane.js new file mode 100644 index 000000000..fcbb0fc3a --- /dev/null +++ b/src/scenes/DocumentEdit/components/EditorPane.js @@ -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 ( +
+
+ { this.props.children } +
+
+ ); + } +}; + +export default EditorPane; diff --git a/src/scenes/DocumentEdit/components/Preview.js b/src/scenes/DocumentEdit/components/Preview.js new file mode 100644 index 000000000..46615fa84 --- /dev/null +++ b/src/scenes/DocumentEdit/components/Preview.js @@ -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 ( +
+ +
+ ); +}; + +Preview.propTypes = { + html: React.PropTypes.string.isRequired, +}; + +export default Preview; \ No newline at end of file diff --git a/src/scenes/DocumentEdit/components/SaveAction.js b/src/scenes/DocumentEdit/components/SaveAction.js index cba36ddbc..948cb59e0 100644 --- a/src/scenes/DocumentEdit/components/SaveAction.js +++ b/src/scenes/DocumentEdit/components/SaveAction.js @@ -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 (
- Save + Save
); } diff --git a/src/stores/UiStateStore.js b/src/stores/UiStateStore.js new file mode 100644 index 000000000..481ae8f7f --- /dev/null +++ b/src/stores/UiStateStore.js @@ -0,0 +1,8 @@ +import { observable, action } from 'mobx'; + +class UiState { + +} + +const singleton = new UiState(); +export default singleton; \ No newline at end of file diff --git a/src/utils/Markdown.js b/src/utils/MarkdownOld.js similarity index 100% rename from src/utils/Markdown.js rename to src/utils/MarkdownOld.js diff --git a/src/utils/constants.scss b/src/utils/constants.scss index 816934655..3445e1db6 100644 --- a/src/utils/constants.scss +++ b/src/utils/constants.scss @@ -1,6 +1,8 @@ $textColor: #171B35; $linkColor: #0C77F8; +$headerHeight: 42px; + :export { textColor: $textColor; } \ No newline at end of file diff --git a/server/utils/markdown.js b/src/utils/markdown.js similarity index 100% rename from server/utils/markdown.js rename to src/utils/markdown.js