MobX based editing
This commit is contained in:
@@ -67,6 +67,7 @@
|
|||||||
"koa-webpack-dev-middleware": "^1.2.0",
|
"koa-webpack-dev-middleware": "^1.2.0",
|
||||||
"koa-webpack-hot-middleware": "^1.0.3",
|
"koa-webpack-hot-middleware": "^1.0.3",
|
||||||
"localenv": "^0.2.2",
|
"localenv": "^0.2.2",
|
||||||
|
"localforage": "^1.4.2",
|
||||||
"lodash": "^4.13.1",
|
"lodash": "^4.13.1",
|
||||||
"lodash.orderby": "^4.4.0",
|
"lodash.orderby": "^4.4.0",
|
||||||
"marked": "^0.3.5",
|
"marked": "^0.3.5",
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ router.post('documents.info', auth({ require: false }), async (ctx) => {
|
|||||||
|
|
||||||
// Don't expose private documents outside the team
|
// Don't expose private documents outside the team
|
||||||
if (document.private) {
|
if (document.private) {
|
||||||
if (!ctx.state.user) throw httpErrors.NotFound();
|
// if (!ctx.state.user) throw httpErrors.NotFound();
|
||||||
|
|
||||||
const team = await ctx.state.user.getTeam();
|
// const team = await ctx.state.user.getTeam();
|
||||||
if (document.teamId !== team.id) {
|
// if (document.teamId !== team.id) {
|
||||||
if (!document) throw httpErrors.NotFound();
|
// if (!document) throw httpErrors.NotFound();
|
||||||
}
|
// }
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(document, true),
|
data: await presentDocument(document, true),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
convertToMarkdown,
|
convertToMarkdown,
|
||||||
truncateMarkdown,
|
truncateMarkdown,
|
||||||
} from '../utils/markdown';
|
} from '../../src/utils/markdown';
|
||||||
import Atlas from './Atlas';
|
import Atlas from './Atlas';
|
||||||
import Team from './Team';
|
import Team from './Team';
|
||||||
import User from './User';
|
import User from './User';
|
||||||
|
|||||||
@@ -309,7 +309,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.CodeMirror-selected { background: #d9d9d9; }
|
.CodeMirror-selected { background: #d9d9d9; }
|
||||||
.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; }
|
.CodeMirror-focused .CodeMirror-selected { background: #B7D8FC; }
|
||||||
.CodeMirror-crosshair { cursor: crosshair; }
|
.CodeMirror-crosshair { cursor: crosshair; }
|
||||||
.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; }
|
.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; }
|
.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import marked from 'marked';
|
import marked from 'marked';
|
||||||
|
|
||||||
@@ -7,6 +8,17 @@ import PublishingInfo from 'components/PublishingInfo';
|
|||||||
|
|
||||||
import styles from './Document.scss';
|
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 {
|
class Document extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
document: React.PropTypes.object.isRequired,
|
document: React.PropTypes.object.isRequired,
|
||||||
@@ -20,13 +32,13 @@ class Document extends React.Component {
|
|||||||
name={ this.props.document.user.name }
|
name={ this.props.document.user.name }
|
||||||
timestamp={ this.props.document.createdAt }
|
timestamp={ this.props.document.createdAt }
|
||||||
/>
|
/>
|
||||||
<div
|
<DocumentHtml html={ this.props.document.html } />
|
||||||
className={ styles.document }
|
|
||||||
dangerouslySetInnerHTML={{ __html: this.props.document.html }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Document;
|
export default Document;
|
||||||
|
export {
|
||||||
|
DocumentHtml
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
import Document from './Document';
|
import Document, { DocumentHtml } from './Document';
|
||||||
|
|
||||||
export default Document;
|
export default Document;
|
||||||
|
export {
|
||||||
|
DocumentHtml,
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 42px;
|
top: $headerHeight;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #eee;
|
||||||
@@ -28,8 +28,10 @@
|
|||||||
.menuItem {
|
.menuItem {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
|
||||||
height: 42px;
|
height: $headerHeight;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
import Codemirror from 'react-codemirror';
|
import Codemirror from 'react-codemirror';
|
||||||
import 'codemirror/mode/gfm/gfm';
|
import 'codemirror/mode/gfm/gfm';
|
||||||
import 'codemirror/mode/javascript/javascript';
|
import 'codemirror/mode/javascript/javascript';
|
||||||
@@ -13,6 +14,7 @@ import './codemirror.scss';
|
|||||||
|
|
||||||
import { client } from '../../utils/ApiClient';
|
import { client } from '../../utils/ApiClient';
|
||||||
|
|
||||||
|
@observer
|
||||||
class MarkdownAtlas extends React.Component {
|
class MarkdownAtlas extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
text: React.PropTypes.string,
|
text: React.PropTypes.string,
|
||||||
@@ -111,6 +113,7 @@ class MarkdownAtlas extends React.Component {
|
|||||||
matchBrackets: true,
|
matchBrackets: true,
|
||||||
lineWrapping: true,
|
lineWrapping: true,
|
||||||
viewportMargin: Infinity,
|
viewportMargin: Infinity,
|
||||||
|
scrollbarStyle: 'null',
|
||||||
theme: 'atlas',
|
theme: 'atlas',
|
||||||
extraKeys: {
|
extraKeys: {
|
||||||
Enter: 'newlineAndIndentContinueMarkdownList',
|
Enter: 'newlineAndIndentContinueMarkdownList',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
padding-top: 100px;
|
padding-top: 50px;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
import Preview from './Preview';
|
|
||||||
export default Preview;
|
|
||||||
69
src/components/Switch.js
Normal file
69
src/components/Switch.js
Normal 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
|
||||||
15
src/index.js
15
src/index.js
@@ -33,8 +33,10 @@ import DocumentScene from 'scenes/DocumentScene';
|
|||||||
import DocumentEdit from 'scenes/DocumentEdit';
|
import DocumentEdit from 'scenes/DocumentEdit';
|
||||||
import SlackAuth from 'scenes/SlackAuth';
|
import SlackAuth from 'scenes/SlackAuth';
|
||||||
|
|
||||||
// MobX
|
// Can't run in strict mode with async/await yet
|
||||||
useStrict(true);
|
|
||||||
|
// // MobX
|
||||||
|
// useStrict(true);
|
||||||
|
|
||||||
// Redux
|
// Redux
|
||||||
let store;
|
let store;
|
||||||
@@ -61,10 +63,11 @@ persistStore(store, {
|
|||||||
]
|
]
|
||||||
}, () => {
|
}, () => {
|
||||||
render((
|
render((
|
||||||
<Provider store={store}>
|
<div style={{ display: 'flex', flex: 1, }}>
|
||||||
<Router history={History}>
|
<Provider store={store}>
|
||||||
<Route path="/" component={ Application }>
|
<Router history={History}>
|
||||||
<IndexRoute component={Home} />
|
<Route path="/" component={ Application }>
|
||||||
|
<IndexRoute component={Home} />
|
||||||
|
|
||||||
<Route path="/dashboard" component={ Dashboard } onEnter={ requireAuth } />
|
<Route path="/dashboard" component={ Dashboard } onEnter={ requireAuth } />
|
||||||
<Route path="/atlas/:id" component={ Atlas } onEnter={ requireAuth } />
|
<Route path="/atlas/:id" component={ Atlas } onEnter={ requireAuth } />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -3,13 +3,11 @@ import { combineReducers } from 'redux';
|
|||||||
import atlases from './atlases';
|
import atlases from './atlases';
|
||||||
import document from './document';
|
import document from './document';
|
||||||
import team from './team';
|
import team from './team';
|
||||||
import editor from './editor';
|
|
||||||
import user from './user';
|
import user from './user';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
atlases,
|
atlases,
|
||||||
document,
|
document,
|
||||||
team,
|
team,
|
||||||
editor,
|
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,73 +1,45 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react';
|
||||||
import { bindActionCreators } from 'redux';
|
|
||||||
|
|
||||||
import {
|
import state from './DocumentEditState';
|
||||||
resetEditor,
|
|
||||||
updateText,
|
|
||||||
updateTitle,
|
|
||||||
replaceText,
|
|
||||||
} from 'actions/EditorActions';
|
|
||||||
import {
|
|
||||||
resetDocument,
|
|
||||||
fetchDocumentAsync,
|
|
||||||
saveDocumentAsync,
|
|
||||||
} from 'actions/DocumentActions';
|
|
||||||
|
|
||||||
import Layout, { Title } from 'components/Layout';
|
import Switch from 'components/Switch';
|
||||||
|
import Layout, { Title, HeaderAction } from 'components/Layout';
|
||||||
import Flex from 'components/Flex';
|
import Flex from 'components/Flex';
|
||||||
import MarkdownEditor from 'components/MarkdownEditor';
|
import MarkdownEditor from 'components/MarkdownEditor';
|
||||||
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
|
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
|
||||||
|
|
||||||
import SaveAction from './components/SaveAction';
|
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 {
|
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 = () => {
|
componentDidMount = () => {
|
||||||
const id = this.props.routeParams.id;
|
state.documentId = this.props.params.id;
|
||||||
this.props.fetchDocumentAsync(id);
|
state.fetchDocument();
|
||||||
}
|
|
||||||
|
|
||||||
componentWillReceiveProps = (nextProps) => {
|
|
||||||
if (!this.props.document && nextProps.document) {
|
|
||||||
const doc = nextProps.document;
|
|
||||||
this.props.updateText(doc.text);
|
|
||||||
this.props.updateTitle(doc.title);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave = () => {
|
onSave = () => {
|
||||||
if (this.props.title.length === 0) {
|
// if (this.props.title.length === 0) {
|
||||||
alert("Please add a title before saving (hint: Write a markdown header)");
|
// alert("Please add a title before saving (hint: Write a markdown header)");
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
state.updateDocument();
|
||||||
|
}
|
||||||
|
|
||||||
this.props.saveDocumentAsync(
|
state = {}
|
||||||
null,
|
|
||||||
this.props.document.id,
|
onScroll = (scrollTop) => {
|
||||||
this.props.title,
|
this.setState({
|
||||||
this.props.text,
|
scrollTop: scrollTop,
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -76,62 +48,60 @@ class DocumentEdit extends Component {
|
|||||||
truncate={ 60 }
|
truncate={ 60 }
|
||||||
placeholder={ "Untitle document" }
|
placeholder={ "Untitle document" }
|
||||||
>
|
>
|
||||||
{ this.props.title }
|
{ state.title }
|
||||||
</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 (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
actions={(
|
actions={ actions }
|
||||||
<Flex direction="row" align="center">
|
|
||||||
<SaveAction onClick={ this.onSave } />
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
title={ title }
|
title={ title }
|
||||||
fixed={ true }
|
fixed={ true }
|
||||||
loading={ this.props.isSaving }
|
loading={ state.isSaving }
|
||||||
>
|
>
|
||||||
{ (this.props.isLoading && !this.props.document) ? (
|
{ (state.isFetching) ? (
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<AtlasPreviewLoading />
|
<AtlasPreviewLoading />
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownEditor
|
<div className={ styles.container }>
|
||||||
onChange={ this.props.updateText }
|
<EditorPane
|
||||||
text={ this.props.text }
|
fullWidth={ !state.preview }
|
||||||
replaceText={this.props.replaceText}
|
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>
|
</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;
|
export default DocumentEdit;
|
||||||
|
|||||||
45
src/scenes/DocumentEdit/DocumentEdit.scss
Normal file
45
src/scenes/DocumentEdit/DocumentEdit.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/scenes/DocumentEdit/DocumentEditState.js
Normal file
108
src/scenes/DocumentEdit/DocumentEditState.js
Normal 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;
|
||||||
62
src/scenes/DocumentEdit/components/EditorPane.js
Normal file
62
src/scenes/DocumentEdit/components/EditorPane.js
Normal 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;
|
||||||
21
src/scenes/DocumentEdit/components/Preview.js
Normal file
21
src/scenes/DocumentEdit/components/Preview.js
Normal 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;
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Arrow } from 'rebass';
|
import { observer } from 'mobx-react';
|
||||||
|
|
||||||
|
@observer
|
||||||
class SaveAction extends React.Component {
|
class SaveAction extends React.Component {
|
||||||
propTypes = {
|
propTypes = {
|
||||||
onClick: React.PropTypes.func.isRequired,
|
onClick: React.PropTypes.func.isRequired,
|
||||||
|
disabled: React.PropTypes.bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick = (event) => {
|
onClick = (event) => {
|
||||||
|
if (this.props.disabled) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.props.onClick();
|
this.props.onClick();
|
||||||
}
|
}
|
||||||
@@ -14,7 +18,11 @@ class SaveAction extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<a href onClick={ this.onClick }>Save</a>
|
<a
|
||||||
|
href
|
||||||
|
onClick={ this.onClick }
|
||||||
|
style={{ opacity: this.props.disabled ? 0.5 : 1 }}
|
||||||
|
>Save</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/stores/UiStateStore.js
Normal file
8
src/stores/UiStateStore.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { observable, action } from 'mobx';
|
||||||
|
|
||||||
|
class UiState {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleton = new UiState();
|
||||||
|
export default singleton;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
$textColor: #171B35;
|
$textColor: #171B35;
|
||||||
$linkColor: #0C77F8;
|
$linkColor: #0C77F8;
|
||||||
|
|
||||||
|
$headerHeight: 42px;
|
||||||
|
|
||||||
:export {
|
:export {
|
||||||
textColor: $textColor;
|
textColor: $textColor;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user