1
.babelrc
1
.babelrc
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"presets": ["react", "es2015", "stage-0"],
|
"presets": ["react", "es2015", "stage-0"],
|
||||||
|
"plugins": ["transform-decorators-legacy"],
|
||||||
"env": {
|
"env": {
|
||||||
"development": {
|
"development": {
|
||||||
"presets": ["react-hmre"]
|
"presets": ["react-hmre"]
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -26,6 +26,7 @@
|
|||||||
"babel-core": "^6.4.5",
|
"babel-core": "^6.4.5",
|
||||||
"babel-eslint": "^4.1.8",
|
"babel-eslint": "^4.1.8",
|
||||||
"babel-loader": "^6.2.1",
|
"babel-loader": "^6.2.1",
|
||||||
|
"babel-plugin-transform-decorators-legacy": "^1.3.4",
|
||||||
"babel-polyfill": "^6.7.4",
|
"babel-polyfill": "^6.7.4",
|
||||||
"babel-preset-es2015": "^6.3.13",
|
"babel-preset-es2015": "^6.3.13",
|
||||||
"babel-preset-react": "^6.3.13",
|
"babel-preset-react": "^6.3.13",
|
||||||
@@ -66,9 +67,13 @@
|
|||||||
"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",
|
||||||
|
"mobx": "^2.2.2",
|
||||||
|
"mobx-react": "^3.3.0",
|
||||||
|
"mobx-react-devtools": "^4.2.0",
|
||||||
"moment": "^2.13.0",
|
"moment": "^2.13.0",
|
||||||
"node-dev": "^3.1.0",
|
"node-dev": "^3.1.0",
|
||||||
"node-sass": "^3.4.2",
|
"node-sass": "^3.4.2",
|
||||||
@@ -110,10 +115,11 @@
|
|||||||
"webpack": "^1.12.12"
|
"webpack": "^1.12.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"babel-regenerator-runtime": "^6.5.0",
|
||||||
|
"fsevents": "^1.0.11",
|
||||||
"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",
|
||||||
"node-dev": "^3.1.0",
|
"node-dev": "^3.1.0",
|
||||||
"nodemon": "^1.9.1",
|
"nodemon": "^1.9.1"
|
||||||
"fsevents": "^1.0.11"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
30
src/index.js
30
src/index.js
@@ -10,6 +10,7 @@ import { persistStore, autoRehydrate } from 'redux-persist';
|
|||||||
import thunkMiddleware from 'redux-thunk';
|
import thunkMiddleware from 'redux-thunk';
|
||||||
import createLogger from 'redux-logger';
|
import createLogger from 'redux-logger';
|
||||||
import History from 'utils/History';
|
import History from 'utils/History';
|
||||||
|
import DevTools from 'mobx-react-devtools';
|
||||||
|
|
||||||
import auth from 'utils/auth';
|
import auth from 'utils/auth';
|
||||||
|
|
||||||
@@ -56,21 +57,24 @@ 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 } />
|
||||||
<Route path="/atlas/:id/new" component={ Editor } onEnter={ requireAuth } />
|
<Route path="/atlas/:id/new" component={ Editor } onEnter={ requireAuth } />
|
||||||
<Route path="/documents/:id" component={ DocumentScene } onEnter={ requireAuth } />
|
<Route path="/documents/:id" component={ DocumentScene } onEnter={ requireAuth } />
|
||||||
<Route path="/documents/:id/edit" component={ DocumentEdit } onEnter={ requireAuth } />
|
<Route path="/documents/:id/edit" component={ DocumentEdit } onEnter={ requireAuth } />
|
||||||
|
|
||||||
<Route path="/auth/slack" component={SlackAuth} />
|
<Route path="/auth/slack" component={SlackAuth} />
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
{ __DEV__ ? <DevTools position={{ bottom: 0, right: 0 }} /> : null }
|
||||||
|
</div>
|
||||||
), document.getElementById('root'));
|
), document.getElementById('root'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
58
src/utils/markdown2.js
Normal file
58
src/utils/markdown2.js
Normal 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"> </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,
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ const developmentWebpackConfig = Object.assign(commonWebpackConfig, {
|
|||||||
cache: true,
|
cache: true,
|
||||||
devtool: 'eval',
|
devtool: 'eval',
|
||||||
entry: [
|
entry: [
|
||||||
|
'babel-regenerator-runtime',
|
||||||
'webpack-hot-middleware/client',
|
'webpack-hot-middleware/client',
|
||||||
'./src/index',
|
'./src/index',
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user