Merge pull request #5 from jorilallo/mobx-edit

Mobx edit
This commit is contained in:
Jori Lallo 2016-06-02 22:22:50 -07:00
commit c63e3df854
28 changed files with 506 additions and 189 deletions

View File

@ -1,5 +1,6 @@
{
"presets": ["react", "es2015", "stage-0"],
"plugins": ["transform-decorators-legacy"],
"env": {
"development": {
"presets": ["react-hmre"]

View File

@ -26,6 +26,7 @@
"babel-core": "^6.4.5",
"babel-eslint": "^4.1.8",
"babel-loader": "^6.2.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-polyfill": "^6.7.4",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
@ -66,9 +67,13 @@
"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",
"mobx": "^2.2.2",
"mobx-react": "^3.3.0",
"mobx-react-devtools": "^4.2.0",
"moment": "^2.13.0",
"node-dev": "^3.1.0",
"node-sass": "^3.4.2",
@ -110,10 +115,11 @@
"webpack": "^1.12.12"
},
"devDependencies": {
"babel-regenerator-runtime": "^6.5.0",
"fsevents": "^1.0.11",
"koa-webpack-dev-middleware": "^1.2.0",
"koa-webpack-hot-middleware": "^1.0.3",
"node-dev": "^3.1.0",
"nodemon": "^1.9.1",
"fsevents": "^1.0.11"
"nodemon": "^1.9.1"
}
}

View File

@ -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';

View File

@ -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; }

View File

@ -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 (
<div
className={ styles.document }
dangerouslySetInnerHTML={{ __html: props.html }}
{ ...props }
/>
);
});
@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 }
/>
<div
className={ styles.document }
dangerouslySetInnerHTML={{ __html: this.props.document.html }}
/>
<DocumentHtml html={ this.props.document.html } />
</div>
);
}
};
export default Document;
export {
DocumentHtml
};

View File

@ -1,2 +1,6 @@
import Document from './Document';
import Document, { DocumentHtml } from './Document';
export default Document;
export {
DocumentHtml,
};

View File

@ -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;

View File

@ -23,7 +23,7 @@
align-items: center;
padding: 0 20px;
height: 42px;
height: $headerHeight;
border-bottom: 1px solid #eee;
font-size: 14px;

View File

@ -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',

View File

@ -1,5 +1,5 @@
.container {
padding-top: 100px;
padding-top: 50px;
cursor: text;
}

View File

@ -1,2 +0,0 @@
import Preview from './Preview';
export default Preview;

69
src/components/Switch.js Normal file
View File

@ -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 (
<Base
{...props}
className='Switch'
role='checkbox'
aria-checked={checked}
baseStyle={sx.root}>
<div style={sx.dot} />
</Base>
)
}
Switch.propTypes = {
/** Sets the Switch to an active style */
checked: React.PropTypes.bool
}
Switch.contextTypes = {
rebass: React.PropTypes.object
}
export default Switch

View File

@ -10,6 +10,7 @@ import { persistStore, autoRehydrate } from 'redux-persist';
import thunkMiddleware from 'redux-thunk';
import createLogger from 'redux-logger';
import History from 'utils/History';
import DevTools from 'mobx-react-devtools';
import auth from 'utils/auth';
@ -56,21 +57,24 @@ persistStore(store, {
]
}, () => {
render((
<Provider store={store}>
<Router history={History}>
<Route path="/" component={ Application }>
<IndexRoute component={Home} />
<div style={{ display: 'flex', flex: 1, }}>
<Provider store={store}>
<Router history={History}>
<Route path="/" component={ Application }>
<IndexRoute component={Home} />
<Route path="/dashboard" component={ Dashboard } onEnter={ requireAuth } />
<Route path="/atlas/:id" component={ Atlas } onEnter={ requireAuth } />
<Route path="/atlas/:id/new" component={ Editor } onEnter={ requireAuth } />
<Route path="/documents/:id" component={ DocumentScene } onEnter={ requireAuth } />
<Route path="/documents/:id/edit" component={ DocumentEdit } onEnter={ requireAuth } />
<Route path="/dashboard" component={ Dashboard } onEnter={ requireAuth } />
<Route path="/atlas/:id" component={ Atlas } onEnter={ requireAuth } />
<Route path="/atlas/:id/new" component={ Editor } onEnter={ requireAuth } />
<Route path="/documents/:id" component={ DocumentScene } onEnter={ requireAuth } />
<Route path="/documents/:id/edit" component={ DocumentEdit } onEnter={ requireAuth } />
<Route path="/auth/slack" component={SlackAuth} />
</Route>
</Router>
</Provider>
<Route path="/auth/slack" component={SlackAuth} />
</Route>
</Router>
</Provider>
{ __DEV__ ? <DevTools position={{ bottom: 0, right: 0 }} /> : null }
</div>
), document.getElementById('root'));
});

View File

@ -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;

View File

@ -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,
});

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>
);
}

View File

@ -0,0 +1,8 @@
import { observable, action } from 'mobx';
class UiState {
}
const singleton = new UiState();
export default singleton;

View File

@ -1,6 +1,8 @@
$textColor: #171B35;
$linkColor: #0C77F8;
$headerHeight: 42px;
:export {
textColor: $textColor;
}

58
src/utils/markdown2.js Normal file
View File

@ -0,0 +1,58 @@
import slug from 'slug';
import truncate from 'truncate-html';
import marked, { Renderer } from 'marked';
import highlight from 'highlight.js';
slug.defaults.mode ='rfc3986';
const renderer = new Renderer();
renderer.code = (code, language) => {
const validLang = !!(language && highlight.getLanguage(language));
const highlighted = validLang ? highlight.highlight(language, code).value : code;
return `<pre><code class="hljs ${language}">${highlighted}</code></pre>`;
};
renderer.heading = (text, level) => {
const headingSlug = slug(text);
return `
<h${level}>
<a name="${headingSlug}" class="anchor" href="#${headingSlug}">
<span class="header-link">&nbsp;</span>
</a>
${text}
</h${level}>
`;
},
marked.setOptions({
renderer: renderer,
gfm: true,
tables: true,
breaks: false,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: true,
});
// TODO: This is syncronous and can be costly,
// should be performed outside http request
const convertToMarkdown = (text) => {
return marked(text);
};
truncate.defaultOptions = {
stripTags: false,
ellipsis: '...',
decodeEntities: false,
excludes: ['h1', 'pre', ],
};
const truncateMarkdown = (text, length) => {
const html = convertToMarkdown(text);
return truncate(html, length);
};
export {
convertToMarkdown,
truncateMarkdown,
};

View File

@ -8,6 +8,7 @@ const developmentWebpackConfig = Object.assign(commonWebpackConfig, {
cache: true,
devtool: 'eval',
entry: [
'babel-regenerator-runtime',
'webpack-hot-middleware/client',
'./src/index',
],