Slate editor (#38)

* WIP: Slate editor

* WIP

* Focus at start / end working

* ah ha

* Super basic floating toolbar

* Nested list editing

* Pulling more logic into plugins

* inline code markdown

* Backspace at end of code block should remove mark

* Ensure there is always an empty line at editor end

* Add keyboard shortcuts for bold, italic, underline

* Add strikethrough shortcode and toolbar

* Toolbar to declarative
Fixed paragraph styling
Removed unused stuffs

* Super basic link editing

* Split Toolbar, now possible to edit and remove links

* Add new link to selection from toolbar working

* Ensure toolbar doesn't extend off screen

* Fix minor js issues, disable formatting of document title

* Boom, icons

* Remove codemirror, fix MD parsing issues

* CMD+S now saves inplace

* Add --- shortcut for horizontal rule

* Improved styling for link editor

* Add header anchors in readOnly

* More readable core text color

* Restored image file uploading 🎉

* Add support for inline md syntax, ** __ etc

* Centered

* Flooooow

* Checklist support

* Upgrade edit list plugin

* Finally. Allow keydown within rich textarea

* Update Markdown serializer

* Cleanup, remove async editor loading

* Editor > MarkdownEditor
Fixed unsaved changes warning triggered when all changes are saved

* MOAR typing

* Combine edit and view

* Fixed checkboxes still editable in readOnly

* wip

* Breadcrumb
Restored scroll

* Move document scene actions to menu

* Added: Support for code blocks, syntax highlighting

* Cleanup

*  > styled component

* Prevent CMD+Enter from adding linebreak

* Show image uploading in layout activity indicator

* Upgrade editor deps

* Improve link toolbar. Only one scenario where it's not working now
This commit is contained in:
Jori Lallo 2017-05-17 19:36:31 -07:00 committed by Tom Moor
parent 7b16d3e5e2
commit ff17047791
84 changed files with 2531 additions and 1659 deletions

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function BoldIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function BulletedListIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
<path d="M0 0h24v24H0V0z" fill="none" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function CloseIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function CodeIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function Heading1Icon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14h-2V9h-2V7h4v10z" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function Heading2Icon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 8c0 1.11-.9 2-2 2h-2v2h4v2H9v-4c0-1.11.9-2 2-2h2V9H9V7h4c1.1 0 2 .89 2 2v2z" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,26 @@
// @flow
import React from 'react';
import styled from 'styled-components';
export type Props = {
className?: string,
light?: boolean,
};
type BaseProps = {
children?: React$Element<any>,
};
export default function Icon({ children, ...rest }: Props & BaseProps) {
return (
<Wrapper {...rest}>
{children}
</Wrapper>
);
}
const Wrapper = styled.span`
svg {
fill: ${props => (props.light ? '#fff' : '#000')};
}
`;

View File

@ -0,0 +1,9 @@
.icon {
}
.light {
svg {
fill: #fff;
}
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function ItalicIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function LinkIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function OrderedListIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function QuoteIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function StrikethroughIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function UnderlinedIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" />
</svg>
</Icon>
);
}

View File

@ -0,0 +1,3 @@
// @flow
import Icon from './Icon';
export default Icon;

View File

@ -4,17 +4,16 @@ import { browserHistory, Link } from 'react-router';
import Helmet from 'react-helmet';
import styled from 'styled-components';
import { observer, inject } from 'mobx-react';
import keydown from 'react-keydown';
import _ from 'lodash';
import keydown from 'react-keydown';
import classNames from 'classnames/bind';
import searchIcon from 'assets/icons/search.svg';
import { Flex } from 'reflexbox';
import styles from './Layout.scss';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import LoadingIndicator from 'components/LoadingIndicator';
import UserStore from 'stores/UserStore';
import styles from './Layout.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
type Props = {
@ -89,10 +88,7 @@ type Props = {
className={styles.search}
title="Search (/)"
>
<img
src={require('assets/icons/search.svg')}
alt="Search"
/>
<img src={searchIcon} alt="Search" />
</div>
</Flex>}
<DropdownMenu label={<Avatar src={user.user.avatarUrl} />}>

View File

@ -1,171 +0,0 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import Codemirror from 'react-codemirror';
import 'codemirror/mode/gfm/gfm';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/addon/edit/continuelist';
import 'codemirror/addon/display/placeholder.js';
import Dropzone from 'react-dropzone';
import ClickablePadding from './components/ClickablePadding';
import styles from './MarkdownEditor.scss';
import './codemirror.scss';
import { client } from 'utils/ApiClient';
@observer class MarkdownEditor extends React.Component {
static propTypes = {
text: React.PropTypes.string,
onChange: React.PropTypes.func.isRequired,
replaceText: React.PropTypes.func.isRequired,
onSave: React.PropTypes.func.isRequired,
onCancel: React.PropTypes.func.isRequired,
// This is actually not used but it triggers
// re-render to help with CodeMirror focus issues
preview: React.PropTypes.bool,
toggleUploadingIndicator: React.PropTypes.func,
};
onChange = (newText: string) => {
if (newText !== this.props.text) {
this.props.onChange(newText);
}
};
onDropAccepted = (files: Object[]) => {
const file = files[0];
const editor = this.getEditorInstance();
const cursorPosition = editor.getCursor();
const insertOnNewLine = cursorPosition.ch !== 0;
let newCursorPositionLine;
this.props.toggleUploadingIndicator();
// Lets set up the upload text
const pendingUploadTag = `![${file.name}](Uploading...)`;
if (insertOnNewLine) {
editor.replaceSelection(`\n${pendingUploadTag}\n`);
newCursorPositionLine = cursorPosition.line + 3;
} else {
editor.replaceSelection(`${pendingUploadTag}\n`);
newCursorPositionLine = cursorPosition.line + 2;
}
editor.setCursor(newCursorPositionLine, 0);
client
.post('/user.s3Upload', {
kind: file.type,
size: file.size,
filename: file.name,
})
.then(response => {
// $FlowFixMe need to augment ApiClient
const data = response.data;
// Upload using FormData API
const formData = new FormData();
for (const key in data.form) {
formData.append(key, data.form[key]);
}
if (file.blob) {
formData.append('file', file.file);
} else {
formData.append('file', file);
}
fetch(data.uploadUrl, {
method: 'POST',
body: formData,
})
.then(_s3Response => {
this.props.toggleUploadingIndicator();
this.props.replaceText({
original: pendingUploadTag,
new: `![${file.name}](${data.asset.url})`,
});
editor.setCursor(newCursorPositionLine, 0);
})
.catch(_err => {
this.props.toggleUploadingIndicator();
this.props.replaceText({
original: pendingUploadTag,
new: '',
});
editor.setCursor(newCursorPositionLine, 0);
});
})
.catch(_err => {
this.props.toggleUploadingIndicator();
});
};
onPaddingTopClick = () => {
const cm = this.getEditorInstance();
cm.setCursor(0, 0);
cm.focus();
};
onPaddingBottomClick = () => {
const cm = this.getEditorInstance();
cm.setCursor(cm.lineCount(), 0);
cm.focus();
};
getEditorInstance = () => {
return this.refs.editor.getCodeMirror();
};
render = () => {
const options = {
readOnly: false,
lineNumbers: false,
mode: 'gfm',
matchBrackets: true,
lineWrapping: true,
viewportMargin: Infinity,
scrollbarStyle: 'null',
theme: 'atlas',
autofocus: true,
extraKeys: {
Enter: 'newlineAndIndentContinueMarkdownList',
'Ctrl-Enter': this.props.onSave,
'Cmd-Enter': this.props.onSave,
'Cmd-Esc': this.props.onCancel,
'Ctrl-Esc': this.props.onCancel,
// 'Cmd-Shift-p': this.props.togglePreview,
// 'Ctrl-Shift-p': this.props.togglePreview,
},
placeholder: '# Start with a title...',
};
return (
<Dropzone
onDropAccepted={this.onDropAccepted}
disableClick
multiple={false}
accept="image/*"
className={styles.container}
>
<ClickablePadding onClick={this.onPaddingTopClick} />
<Codemirror
value={this.props.text}
onChange={this.onChange}
options={options}
ref="editor"
className={styles.codeMirrorContainer}
/>
<ClickablePadding onClick={this.onPaddingBottomClick} />
</Dropzone>
);
};
}
export default MarkdownEditor;

View File

@ -1,29 +0,0 @@
.container {
display: flex;
flex: 1;
flex-direction: column;
font-weight: 400;
font-size: 1em;
line-height: 1.5em;
padding: 0 3em;
max-width: 50em;
}
.codeMirrorContainer {
width: 100%;
}
@media all and (max-width: 2000px) and (min-width: 960px) {
.container {
// margin-top: 48px;
font-size: 1.1em;
}
}
@media all and (max-width: 960px) {
.container {
font-size: 0.9em;
}
}

View File

@ -1,59 +0,0 @@
@import '~styles/constants.scss';
:global {
/* Custom styling */
.cm-s-atlas.CodeMirror {
background: #fff;
color: #202020;
font-family: 'Atlas Typewriter', 'Menlo', 'Cousine', 'Monaco', monospace;
font-weight: 300;
height: auto; // This will break layout for some reason. TODO: investigate
width: 100%;
}
// Use Menlo for stronger weight
.cm-s-atlas .cm-header { font-family: 'Menlo', 'Cousine', 'Monaco', monospace; }
/* Disable ondrag cursor for file uploads */
.cm-s-atlas div.CodeMirror-dragcursors {
visibility: hidden;
}
.cm-s-atlas .CodeMirror-line::selection,
.cm-s-atlas .CodeMirror-line > span::selection,
.cm-s-atlas .CodeMirror-line > span > span::selection {
background: #90CAF9;
}
.cm-s-atlas .CodeMirror-line::-moz-selection, .cm-s-atlas .CodeMirror-line > span::-moz-selection, .cm-s-atlas .CodeMirror-line > span > span::-moz-selection { background: #e0e0e0; }
.cm-s-atlas .CodeMirror-gutters { background: #f5f5f5; border-right: 0px; }
.cm-s-atlas .CodeMirror-guttermarker { color: #ac4142; }
.cm-s-atlas .CodeMirror-guttermarker-subtle { color: #b0b0b0; }
.cm-s-atlas .CodeMirror-linenumber { color: #b0b0b0; }
.cm-s-atlas .CodeMirror-cursor {
border-left: 2px solid #2196F3;
}
.cm-s-atlas span.cm-quote {
font-style: italic;
}
.cm-s-atlas span.cm-comment { color: #969896; }
.cm-s-atlas span.cm-atom { color: #0086b3; }
.cm-s-atlas span.cm-number { color: $textColor; }
.cm-s-atlas span.cm-property, .cm-s-atlas span.cm-attribute { color: $textColor; }
.cm-s-atlas span.cm-keyword { color: #a71d5d; }
.cm-s-atlas span.cm-string { color: #df5000; }
.cm-s-atlas span.cm-variable { color: $textColor; }
.cm-s-atlas span.cm-variable-2 { color: $textColor; }
.cm-s-atlas span.cm-def { color: $textColor; }
.cm-s-atlas span.cm-bracket { color: #202020; }
.cm-s-atlas span.cm-tag { color: #ac4142; }
.cm-s-atlas span.cm-link { color: $actionColor; }
.cm-s-atlas span.cm-error { background: #ac4142; color: #505050; }
.cm-s-atlas .CodeMirror-activeline-background { background: #DDDCDC; }
.cm-s-atlas .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; }
.cm-s-atlas .CodeMirror-placeholder { color: rgba(0, 0, 0, 0.5); font-weight: bold; }
}

View File

@ -1,14 +0,0 @@
// @flow
import React from 'react';
import styles from './ClickablePadding.scss';
const ClickablePadding = (props: { onClick: Function }) => {
return <div className={styles.container} onClick={props.onClick}>&nbsp;</div>;
};
ClickablePadding.propTypes = {
onClick: React.PropTypes.func,
};
export default ClickablePadding;

View File

@ -1,3 +0,0 @@
// @flow
import MarkdownEditor from './MarkdownEditor';
export default MarkdownEditor;

View File

@ -1,7 +1,6 @@
/* eslint-disable */
import React from 'react';
import history from 'utils/History';
import styles from './Tree.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);

View File

@ -13,14 +13,13 @@ import 'normalize.css/normalize.css';
import 'styles/base.scss';
import 'styles/fonts.css';
import 'styles/transitions.scss';
import 'styles/prism-tomorrow.scss';
import 'styles/hljs-github-gist.scss';
import 'styles/codemirror.scss';
import Home from 'scenes/Home';
import Dashboard from 'scenes/Dashboard';
import Atlas from 'scenes/Atlas';
import DocumentScene from 'scenes/DocumentScene';
import DocumentEdit from 'scenes/DocumentEdit';
import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
import SlackAuth from 'scenes/SlackAuth';
@ -63,23 +62,20 @@ render(
/>
<Route
path="/collections/:id/new"
component={DocumentEdit}
component={Document}
onEnter={requireAuth}
newDocument
/>
<Route
path="/d/:id"
component={DocumentScene}
onEnter={requireAuth}
/>
<Route path="/d/:id" component={Document} onEnter={requireAuth} />
<Route
path="/d/:id/edit"
component={DocumentEdit}
component={Document}
onEnter={requireAuth}
editDocument
/>
<Route
path="/d/:id/new"
component={DocumentEdit}
component={Document}
onEnter={requireAuth}
newChildDocument
/>

View File

@ -0,0 +1,150 @@
// @flow
import React, { Component } from 'react';
import get from 'lodash/get';
import { observer } from 'mobx-react';
import { browserHistory, withRouter } from 'react-router';
import { Flex } from 'reflexbox';
import DocumentStore from './DocumentStore';
import Breadcrumbs from './components/Breadcrumbs';
import Editor from './components/Editor';
import Menu from './components/Menu';
import Layout, { HeaderAction, SaveAction } from 'components/Layout';
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
import CenteredContent from 'components/CenteredContent';
const DISCARD_CHANGES = `
You have unsaved changes.
Are you sure you want to discard them?
`;
type Props = {
route: Object,
router: Object,
params: Object,
keydown: Object,
};
@withRouter
@observer
class Document extends Component {
store: DocumentStore;
props: Props;
constructor(props: Props) {
super(props);
this.store = new DocumentStore({});
}
componentDidMount = () => {
if (this.props.route.newDocument) {
this.store.collectionId = this.props.params.id;
this.store.newDocument = true;
} else if (this.props.route.editDocument) {
this.store.documentId = this.props.params.id;
this.store.fetchDocument();
} else if (this.props.route.newChildDocument) {
this.store.documentId = this.props.params.id;
this.store.newChildDocument = true;
this.store.fetchDocument();
} else {
this.store.documentId = this.props.params.id;
this.store.newDocument = false;
this.store.fetchDocument();
}
// Prevent user from accidentally leaving with unsaved changes
const remove = this.props.router.setRouteLeaveHook(this.props.route, () => {
if (this.store.hasPendingChanges) {
return confirm(DISCARD_CHANGES);
}
remove();
return null;
});
};
onEdit = () => {
const url = `${this.store.document.url}/edit`;
browserHistory.push(url);
};
onSave = (options: { redirect?: boolean } = {}) => {
if (this.store.newDocument || this.store.newChildDocument) {
this.store.saveDocument(options);
} else {
this.store.updateDocument(options);
}
};
onImageUploadStart = () => {
this.store.updateUploading(true);
};
onImageUploadStop = () => {
this.store.updateUploading(false);
};
onCancel = () => {
browserHistory.goBack();
};
render() {
const { route } = this.props;
const isNew = route.newDocument || route.newChildDocument;
const isEditing = route.editDocument;
const title = (
<Breadcrumbs
document={this.store.document}
pathToDocument={this.store.pathToDocument}
/>
);
const titleText = `${get(this.store, 'document.collection.name')} - ${get(this.store, 'document.title')}`;
const actions = (
<Flex>
<HeaderAction>
{isEditing
? <SaveAction
onClick={this.onSave}
disabled={this.store.isSaving}
isNew={isNew}
/>
: <a onClick={this.onEdit}>Edit</a>}
</HeaderAction>
<Menu
store={this.store}
document={this.store.document}
collectionTree={this.store.collectionTree}
/>
</Flex>
);
return (
<Layout
actions={actions}
title={title}
titleText={titleText}
loading={this.store.isSaving || this.store.isUploading}
search={false}
fixed
>
{this.store.isFetching &&
<CenteredContent>
<AtlasPreviewLoading />
</CenteredContent>}
{this.store.document &&
<Editor
text={this.store.document.text}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onChange={this.store.updateText}
onSave={this.onSave}
onCancel={this.onCancel}
readOnly={!this.props.route.editDocument}
/>}
</Layout>
);
}
}
export default Document;

View File

@ -0,0 +1,11 @@
@import '~styles/constants.scss';
.container {
display: flex;
position: fixed;
justify-content: center;
top: $headerHeight;
bottom: 0;
left: 0;
right: 0;
}

View File

@ -0,0 +1,197 @@
// @flow
import { observable, action, computed, toJS } from 'mobx';
import { browserHistory } from 'react-router';
import get from 'lodash/get';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import emojify from 'utils/emojify';
import type { Document, NavigationNode } from 'types';
type SaveProps = { redirect?: boolean };
const parseHeader = text => {
const firstLine = text.split(/\r?\n/)[0];
if (firstLine) {
const match = firstLine.match(/^#+ +(.*)$/);
if (match) {
return emojify(match[1]);
} else {
return '';
}
}
return '';
};
class DocumentStore {
@observable collapsedNodes: string[] = [];
@observable documentId = null;
@observable collectionId = null;
@observable document: Document;
@observable parentDocument: Document;
@observable hasPendingChanges = false;
@observable newDocument: ?boolean;
@observable newChildDocument: ?boolean;
@observable isEditing: boolean = false;
@observable isFetching: boolean = false;
@observable isSaving: boolean = false;
@observable isUploading: boolean = false;
/* Computed */
@computed get isCollection(): boolean {
return !!this.document && this.document.collection.type === 'atlas';
}
@computed get collectionTree(): ?Object {
if (
this.document &&
this.document.collection &&
this.document.collection.type === 'atlas'
) {
const tree = this.document.collection.navigationTree;
const collapseNodes = node => {
node.collapsed = this.collapsedNodes.includes(node.id);
node.children = node.children.map(childNode => {
return collapseNodes(childNode);
});
return node;
};
return collapseNodes(toJS(tree));
}
}
@computed get pathToDocument(): Array<NavigationNode> {
let path;
const traveler = (node, previousPath) => {
if (this.document && node.id === this.document.id) {
path = previousPath;
return;
} else {
node.children.forEach(childNode => {
const newPath = [...previousPath, node];
return traveler(childNode, newPath);
});
}
};
if (this.document && this.collectionTree) {
traveler(this.collectionTree, []);
invariant(path, 'Path is not available for collection, abort');
return path.splice(1);
}
return [];
}
/* Actions */
@action fetchDocument = async () => {
this.isFetching = true;
try {
const res = await client.get(
'/documents.info',
{
id: this.documentId,
},
{ cache: true }
);
invariant(res && res.data, 'Data should be available');
if (this.newChildDocument) {
this.parentDocument = res.data;
} else {
this.document = res.data;
}
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
};
@action saveDocument = async ({ redirect = true }: SaveProps) => {
if (this.isSaving) return;
this.isSaving = true;
try {
const res = await client.post(
'/documents.create',
{
parentDocument: get(this.parentDocument, 'id'),
collection: get(
this.parentDocument,
'collection.id',
this.collectionId
),
title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'),
},
{ cache: true }
);
invariant(res && res.data, 'Data should be available');
const { url } = res.data;
this.hasPendingChanges = false;
if (redirect) browserHistory.push(url);
} catch (e) {
console.error('Something went wrong');
}
this.isSaving = false;
};
@action updateDocument = async ({ redirect = true }: SaveProps) => {
if (this.isSaving) return;
this.isSaving = true;
try {
const res = await client.post(
'/documents.update',
{
id: this.documentId,
title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'),
},
{ cache: true }
);
invariant(res && res.data, 'Data should be available');
const { url } = res.data;
this.hasPendingChanges = false;
if (redirect) browserHistory.push(url);
} catch (e) {
console.error('Something went wrong');
}
this.isSaving = false;
};
@action deleteDocument = async () => {
this.isFetching = true;
try {
await client.post('/documents.delete', { id: this.documentId });
browserHistory.push(this.document.collection.id);
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
};
@action updateText = (text: string) => {
if (!this.document) return;
this.document.text = text;
this.document.title = parseHeader(text);
this.hasPendingChanges = true;
};
@action updateUploading = (uploading: boolean) => {
this.isUploading = uploading;
};
}
export default DocumentStore;

View File

@ -1,16 +1,14 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router';
import type { Document, NavigationNode } from 'types';
import DocumentSceneStore from '../../DocumentSceneStore';
type Props = {
store: DocumentSceneStore,
document: Document,
pathToDocument: Array<NavigationNode>,
};
const Breadcrumbs = ({ store }: Props) => {
const { document, pathToDocument } = store;
const Breadcrumbs = ({ document, pathToDocument }: Props) => {
if (document && document.collection) {
const titleSections = pathToDocument
? pathToDocument.map(node => (
@ -31,6 +29,7 @@ const Breadcrumbs = ({ store }: Props) => {
</span>
);
}
return null;
};
export default Breadcrumbs;

View File

@ -0,0 +1,121 @@
// @flow
import React, { Component } from 'react';
import { observer } from 'mobx-react';
import { Editor, Plain } from 'slate';
import classnames from 'classnames/bind';
import type { Document, State, Editor as EditorType } from './types';
import ClickablePadding from './components/ClickablePadding';
import Toolbar from './components/Toolbar';
import schema from './schema';
import Markdown from './serializer';
import createPlugins from './plugins';
import styles from './Editor.scss';
const cx = classnames.bind(styles);
type Props = {
text: string,
onChange: Function,
onSave: Function,
onCancel: Function,
onImageUploadStart: Function,
onImageUploadStop: Function,
readOnly: boolean,
};
type KeyData = {
isMeta: boolean,
key: string,
};
@observer
export default class MarkdownEditor extends Component {
props: Props;
editor: EditorType;
plugins: Array<Object>;
state: {
state: State,
};
constructor(props: Props) {
super(props);
this.plugins = createPlugins({
onImageUploadStart: props.onImageUploadStart,
onImageUploadStop: props.onImageUploadStop,
});
if (props.text) {
this.state = { state: Markdown.deserialize(props.text) };
} else {
this.state = { state: Plain.deserialize('') };
}
}
onChange = (state: State) => {
this.setState({ state });
};
onDocumentChange = (document: Document, state: State) => {
this.props.onChange(Markdown.serialize(state));
};
onKeyDown = (ev: SyntheticKeyboardEvent, data: KeyData, state: State) => {
if (!data.isMeta) return;
switch (data.key) {
case 's':
ev.preventDefault();
ev.stopPropagation();
return this.props.onSave({ redirect: false });
case 'enter':
ev.preventDefault();
ev.stopPropagation();
this.props.onSave();
return state;
case 'escape':
return this.props.onCancel();
default:
}
};
focusAtStart = () => {
const state = this.editor.getState();
const transform = state.transform();
transform.collapseToStartOf(state.document);
transform.focus();
this.setState({ state: transform.apply() });
};
focusAtEnd = () => {
const state = this.editor.getState();
const transform = state.transform();
transform.collapseToEndOf(state.document);
transform.focus();
this.setState({ state: transform.apply() });
};
render = () => {
return (
<span className={styles.container}>
<ClickablePadding onClick={this.focusAtStart} />
<Toolbar state={this.state.state} onChange={this.onChange} />
<Editor
ref={ref => (this.editor = ref)}
placeholder="Start with a title…"
className={cx(styles.editor, { readOnly: this.props.readOnly })}
schema={schema}
plugins={this.plugins}
state={this.state.state}
onChange={this.onChange}
onDocumentChange={this.onDocumentChange}
onKeyDown={this.onKeyDown}
onSave={this.props.onSave}
readOnly={this.props.readOnly}
/>
<ClickablePadding onClick={this.focusAtEnd} grow />
</span>
);
};
}

View File

@ -0,0 +1,143 @@
.container {
display: flex;
flex: 1;
flex-direction: column;
font-weight: 400;
font-size: 1em;
line-height: 1.5em;
padding: 0 3em;
max-width: 50em;
}
.editor {
background: #fff;
color: #1b2631;
height: auto;
width: 100%;
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 500;
.anchor {
visibility: hidden;
color: #dedede;
padding-left: .25em;
}
&:hover {
.anchor {
visibility: visible;
&:hover {
color: #cdcdcd;
}
}
}
}
ul,
ol {
margin: 1em .1em;
padding-left: 1em;
ul,
ol {
margin: .1em;
}
}
li p {
display: inline;
margin: 0;
}
.todoList {
list-style: none;
padding-left: 0;
.todoList {
padding-left: 1em;
}
}
.todo {
span:last-child:focus {
outline: none;
}
}
code,
pre {
background: #efefef;
border-radius: 3px;
border: 1px solid #dedede;
}
pre {
padding: 0 .5em;
code {
background: none;
border: 0;
padding: 0;
border-radius: 0;
}
}
blockquote {
border-left: 3px solid #efefef;
padding-left: 10px;
}
table {
border-collapse: collapse;
}
tr {
border-bottom: 1px solid #eee;
}
th {
font-weight: bold;
}
th,
td {
padding: 5px 20px 5px 0;
}
}
.readOnly {
cursor: default;
}
.title {
position: relative;
}
.placeholder {
position: absolute;
top: 0;
pointer-events: none;
color: #ddd;
}
@media all and (max-width: 2000px) and (min-width: 960px) {
.container {
// margin-top: 48px;
font-size: 1.1em;
}
}
@media all and (max-width: 960px) {
.container {
font-size: 0.9em;
}
}

View File

@ -0,0 +1,20 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import styles from './ClickablePadding.scss';
type Props = {
onClick: Function,
grow?: boolean,
};
const ClickablePadding = (props: Props) => {
return (
<div
className={classnames(styles.container, { [styles.grow]: props.grow })}
onClick={props.onClick}
/>
);
};
export default ClickablePadding;

View File

@ -3,6 +3,10 @@
cursor: text;
}
.grow {
flex-grow: 1;
}
@media all and (max-width: 960px) {
.container {
padding-top: 50px;

View File

@ -0,0 +1,13 @@
// @flow
import React from 'react';
import type { Props } from '../types';
export default function Code({ children, attributes }: Props) {
return (
<pre>
<code {...attributes}>
{children}
</code>
</pre>
);
}

View File

@ -0,0 +1,43 @@
// @flow
import React from 'react';
import _ from 'lodash';
import slug from 'slug';
import type { Node, Editor } from '../types';
import styles from '../Editor.scss';
type Props = {
children: React$Element<any>,
placeholder?: boolean,
parent: Node,
node: Node,
editor: Editor,
readOnly: boolean,
component?: string,
};
export default function Heading({
parent,
placeholder,
node,
editor,
readOnly,
children,
component = 'h1',
}: Props) {
const firstHeading = parent.nodes.first() === node;
const showPlaceholder = placeholder && firstHeading && !node.text;
const slugish = readOnly && _.escape(`${component}-${slug(node.text)}`);
const Component = component;
return (
<Component className={styles.title}>
{children}
{showPlaceholder &&
<span className={styles.placeholder}>
{editor.props.placeholder}
</span>}
{slugish &&
<a name={slugish} className={styles.anchor} href={`#${slugish}`}>#</a>}
</Component>
);
}

View File

@ -0,0 +1,13 @@
// @flow
import React from 'react';
import type { Props } from '../types';
export default function Image({ attributes, node }: Props) {
return (
<img
{...attributes}
src={node.data.get('src')}
alt={node.data.get('alt')}
/>
);
}

View File

@ -0,0 +1,11 @@
// @flow
import React from 'react';
import type { Props } from '../types';
export default function Link({ attributes, node, children }: Props) {
return (
<a {...attributes} href={node.data.get('href')}>
{children}
</a>
);
}

View File

@ -0,0 +1,16 @@
// @flow
import React from 'react';
import type { Props } from '../types';
import TodoItem from './TodoItem';
export default function ListItem({ children, node, ...props }: Props) {
const checked = node.data.get('checked');
if (checked !== undefined) {
return (
<TodoItem checked={checked} node={node} {...props}>
{children}
</TodoItem>
);
}
return <li>{children}</li>;
}

View File

@ -0,0 +1,39 @@
// @flow
import React, { Component } from 'react';
import type { Props } from '../types';
import styles from '../Editor.scss';
export default class TodoItem extends Component {
props: Props & { checked: boolean };
handleChange = (ev: SyntheticInputEvent) => {
const checked = ev.target.checked;
const { editor, node } = this.props;
const state = editor
.getState()
.transform()
.setNodeByKey(node.key, { data: { checked } })
.apply();
editor.onChange(state);
};
render() {
const { children, checked, readOnly } = this.props;
return (
<li contentEditable={false} className={styles.todo}>
<input
type="checkbox"
checked={checked}
onChange={this.handleChange}
disabled={readOnly}
/>
{' '}
<span contentEditable={!readOnly} suppressContentEditableWarning>
{children}
</span>
</li>
);
}
}

View File

@ -0,0 +1,142 @@
// @flow
import React, { Component } from 'react';
import Portal from 'react-portal';
import classnames from 'classnames';
import _ from 'lodash';
import type { State } from '../../types';
import FormattingToolbar from './components/FormattingToolbar';
import LinkToolbar from './components/LinkToolbar';
import styles from './Toolbar.scss';
export default class Toolbar extends Component {
props: {
state: State,
onChange: Function,
};
menu: HTMLElement;
state: {
active: boolean,
focused: boolean,
link: React$Element<any>,
top: string,
left: string,
};
state = {
active: false,
focused: false,
link: null,
top: '',
left: '',
};
componentDidMount = () => {
this.update();
};
componentDidUpdate = () => {
this.update();
};
handleFocus = () => {
this.setState({ focused: true });
};
handleBlur = () => {
this.setState({ focused: false });
};
get linkInSelection(): any {
const { state } = this.props;
try {
const selectedLinks = state.startBlock
.getInlinesAtRange(state.selection)
.filter(node => node.type === 'link');
if (selectedLinks.size) {
return selectedLinks.first();
}
} catch (err) {
//
}
}
update = () => {
const { state } = this.props;
const link = this.linkInSelection;
if (state.isBlurred || (state.isCollapsed && !link)) {
if (this.state.active && !this.state.focused) {
this.setState({ active: false, link: null, top: '', left: '' });
}
return;
}
// don't display toolbar for document title
const firstNode = state.document.nodes.first();
if (firstNode === state.startBlock) return;
// don't display toolbar for code blocks
if (state.startBlock.type === 'code') return;
const data = {
...this.state,
active: true,
link,
focused: !!link,
};
if (!_.isEqual(data, this.state)) {
const padding = 16;
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.top === 0 && rect.left === 0) {
this.setState(data);
return;
}
const left =
rect.left + window.scrollX - this.menu.offsetWidth / 2 + rect.width / 2;
data.top = `${Math.round(rect.top + window.scrollY - this.menu.offsetHeight)}px`;
data.left = `${Math.round(Math.max(padding, left))}px`;
this.setState(data);
}
};
setRef = (ref: HTMLElement) => {
this.menu = ref;
};
render() {
const link = this.state.link;
const classes = classnames(styles.menu, {
[styles.active]: this.state.active,
});
const style = {
top: this.state.top,
left: this.state.left,
};
return (
<Portal isOpened>
<div className={classes} style={style} ref={this.setRef}>
{link &&
<LinkToolbar
{...this.props}
link={link}
onBlur={this.handleBlur}
/>}
{!link &&
<FormattingToolbar
onCreateLink={this.handleFocus}
{...this.props}
/>}
</div>
</Portal>
);
}
}

View File

@ -0,0 +1,62 @@
.menu {
padding: 8px 16px;
position: absolute;
z-index: 1;
top: -10000px;
left: -10000px;
opacity: 0;
background-color: #222;
border-radius: 4px;
transition: opacity 250ms ease-in-out, transform 250ms ease-in-out;
line-height: 0;
height: 40px;
min-width: 260px;
}
.active {
transform: translateY(-6px);
opacity: 1;
}
.linkEditor {
display: flex;
margin-left: -8px;
margin-right: -8px;
input {
background: rgba(255,255,255,.1);
border-radius: 2px;
padding: 5px 8px;
border: 0;
margin: 0;
outline: none;
color: #fff;
flex-grow: 1;
}
}
.button {
display: inline-block;
flex: 0;
width: 24px;
height: 24px;
cursor: pointer;
margin-left: 10px;
border: none;
background: none;
transition: opacity 100ms ease-in-out;
padding: 0;
opacity: .7;
&:first-child {
margin-left: 0;
}
&:hover {
opacity: 1;
}
&[data-active="true"] {
opacity: 1;
}
}

View File

@ -0,0 +1,112 @@
// @flow
import React, { Component } from 'react';
import styles from '../Toolbar.scss';
import type { State } from '../../../types';
import BoldIcon from 'components/Icon/BoldIcon';
import CodeIcon from 'components/Icon/CodeIcon';
import Heading1Icon from 'components/Icon/Heading1Icon';
import Heading2Icon from 'components/Icon/Heading2Icon';
import LinkIcon from 'components/Icon/LinkIcon';
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
import BulletedListIcon from 'components/Icon/BulletedListIcon';
export default class FormattingToolbar extends Component {
props: {
state: State,
onChange: Function,
onCreateLink: Function,
};
/**
* Check if the current selection has a mark with `type` in it.
*
* @param {String} type
* @return {Boolean}
*/
hasMark = (type: string) => {
return this.props.state.marks.some(mark => mark.type === type);
};
isBlock = (type: string) => {
return this.props.state.startBlock.type === type;
};
/**
* When a mark button is clicked, toggle the current mark.
*
* @param {Event} ev
* @param {String} type
*/
onClickMark = (ev: SyntheticEvent, type: string) => {
ev.preventDefault();
let { state } = this.props;
state = state.transform().toggleMark(type).apply();
this.props.onChange(state);
};
onClickBlock = (ev: SyntheticEvent, type: string) => {
ev.preventDefault();
let { state } = this.props;
state = state.transform().setBlock(type).apply();
this.props.onChange(state);
};
onCreateLink = (ev: SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
let { state } = this.props;
const data = { href: '' };
state = state.transform().wrapInline({ type: 'link', data }).apply();
this.props.onChange(state);
this.props.onCreateLink();
};
renderMarkButton = (type: string, IconClass: Function) => {
const isActive = this.hasMark(type);
const onMouseDown = ev => this.onClickMark(ev, type);
return (
<button
className={styles.button}
onMouseDown={onMouseDown}
data-active={isActive}
>
<IconClass light />
</button>
);
};
renderBlockButton = (type: string, IconClass: Function) => {
const isActive = this.isBlock(type);
const onMouseDown = ev =>
this.onClickBlock(ev, isActive ? 'paragraph' : type);
return (
<button
className={styles.button}
onMouseDown={onMouseDown}
data-active={isActive}
>
<IconClass light />
</button>
);
};
render() {
return (
<span>
{this.renderMarkButton('bold', BoldIcon)}
{this.renderMarkButton('deleted', StrikethroughIcon)}
{this.renderBlockButton('heading1', Heading1Icon)}
{this.renderBlockButton('heading2', Heading2Icon)}
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
{this.renderMarkButton('code', CodeIcon)}
<button className={styles.button} onMouseDown={this.onCreateLink}>
<LinkIcon light />
</button>
</span>
);
}
}

View File

@ -0,0 +1,66 @@
// @flow
import React, { Component } from 'react';
import type { State } from '../../../types';
import keydown from 'react-keydown';
import styles from '../Toolbar.scss';
import CloseIcon from 'components/Icon/CloseIcon';
@keydown
export default class LinkToolbar extends Component {
input: HTMLElement;
props: {
state: State,
link: Object,
onBlur: Function,
onChange: Function,
};
onKeyDown = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
switch (ev.keyCode) {
case 13: // enter
ev.preventDefault();
return this.save(ev.target.value);
case 26: // escape
return this.input.blur();
default:
}
};
removeLink = () => {
this.save('');
};
save = (href: string) => {
href = href.trim();
const transform = this.props.state.transform();
transform.unwrapInline('link');
if (href) {
const data = { href };
transform.wrapInline({ type: 'link', data });
}
const state = transform.apply();
this.props.onChange(state);
this.input.blur();
};
render() {
const href = this.props.link.data.get('href');
return (
<span className={styles.linkEditor}>
<input
ref={ref => (this.input = ref)}
defaultValue={href}
placeholder="http://"
onBlur={this.props.onBlur}
onKeyDown={this.onKeyDown}
autoFocus
/>
<button className={styles.button} onMouseDown={this.removeLink}>
<CloseIcon light />
</button>
</span>
);
}
}

View File

@ -0,0 +1,3 @@
// @flow
import Toolbar from './Toolbar';
export default Toolbar;

View File

@ -0,0 +1,3 @@
// @flow
import Editor from './Editor';
export default Editor;

View File

@ -0,0 +1,67 @@
// @flow
import DropOrPasteImages from 'slate-drop-or-paste-images';
import PasteLinkify from 'slate-paste-linkify';
import EditList from 'slate-edit-list';
import CollapseOnEscape from 'slate-collapse-on-escape';
import TrailingBlock from 'slate-trailing-block';
import EditCode from 'slate-edit-code';
import Prism from 'slate-prism';
import uploadFile from 'utils/uploadFile';
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
const onlyInCode = node => node.type === 'code';
const createPlugins = ({
onImageUploadStart,
onImageUploadStop,
}: { onImageUploadStart: Function, onImageUploadStop: Function }) => {
return [
PasteLinkify({
type: 'link',
collapseTo: 'end',
}),
DropOrPasteImages({
extensions: ['png', 'jpg', 'gif'],
applyTransform: async (transform, file) => {
try {
onImageUploadStart();
const asset = await uploadFile(file);
const alt = file.name;
const src = asset.url;
return transform.insertBlock({
type: 'image',
isVoid: true,
data: { src, alt },
});
} catch (err) {
// TODO: Show a failure alert
} finally {
onImageUploadStop();
}
},
}),
EditList({
types: ['ordered-list', 'bulleted-list', 'todo-list'],
typeItem: 'list-item',
}),
EditCode({
onlyIn: onlyInCode,
containerType: 'code',
lineType: 'code-line',
exitBlocktype: 'paragraph',
selectAll: true,
}),
Prism({
onlyIn: onlyInCode,
getSyntax: node => 'javascript',
}),
CollapseOnEscape({ toEdge: 'end' }),
TrailingBlock({ type: 'paragraph' }),
KeyboardShortcuts(),
MarkdownShortcuts(),
];
};
export default createPlugins;

View File

@ -0,0 +1,39 @@
// @flow
export default function KeyboardShortcuts() {
return {
/**
* On key down, check for our specific key shortcuts.
*
* @param {Event} e
* @param {Data} data
* @param {State} state
* @return {State or Null} state
*/
onKeyDown(ev: SyntheticEvent, data: Object, state: Object) {
if (!data.isMeta) return null;
switch (data.key) {
case 'b':
return this.toggleMark(state, 'bold');
case 'i':
return this.toggleMark(state, 'italic');
case 'u':
return this.toggleMark(state, 'underlined');
case 'd':
return this.toggleMark(state, 'deleted');
default:
return null;
}
},
toggleMark(state: Object, type: string) {
// don't allow formatting of document title
const firstNode = state.document.nodes.first();
if (firstNode === state.startBlock) return;
state = state.transform().toggleMark(type).apply();
return state;
},
};
}

View File

@ -0,0 +1,244 @@
// @flow
const inlineShortcuts = [
{ mark: 'bold', shortcut: '**' },
{ mark: 'bold', shortcut: '__' },
{ mark: 'italic', shortcut: '*' },
{ mark: 'italic', shortcut: '_' },
{ mark: 'code', shortcut: '`' },
{ mark: 'added', shortcut: '++' },
{ mark: 'deleted', shortcut: '~~' },
];
export default function MarkdownShortcuts() {
return {
/**
* On key down, check for our specific key shortcuts.
*/
onKeyDown(ev: SyntheticEvent, data: Object, state: Object) {
switch (data.key) {
case '-':
return this.onDash(ev, state);
case '`':
return this.onBacktick(ev, state);
case 'space':
return this.onSpace(ev, state);
case 'backspace':
return this.onBackspace(ev, state);
case 'enter':
return this.onEnter(ev, state);
default:
return null;
}
},
/**
* On space, if it was after an auto-markdown shortcut, convert the current
* node into the shortcut's corresponding type.
*/
onSpace(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset } = state;
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
const type = this.getType(chars);
if (type) {
if (type === 'list-item' && startBlock.type === 'list-item') return;
ev.preventDefault();
const transform = state.transform().setBlock(type);
if (type === 'list-item') {
if (chars === '1.') {
transform.wrapBlock('ordered-list');
} else {
transform.wrapBlock('bulleted-list');
}
}
state = transform.extendToStartOf(startBlock).delete().apply();
return state;
}
for (const key of inlineShortcuts) {
// find all inline characters
let { mark, shortcut } = key;
let inlineTags = [];
for (let i = 0; i < startBlock.text.length; i++) {
if (startBlock.text.slice(i, i + shortcut.length) === shortcut)
inlineTags.push(i);
}
// if we have multiple tags then mark the text between as inline code
if (inlineTags.length > 1) {
const transform = state.transform();
const firstText = startBlock.getFirstText();
const firstCodeTagIndex = inlineTags[0];
const lastCodeTagIndex = inlineTags[inlineTags.length - 1];
transform.removeTextByKey(
firstText.key,
lastCodeTagIndex,
shortcut.length
);
transform.removeTextByKey(
firstText.key,
firstCodeTagIndex,
shortcut.length
);
transform.moveOffsetsTo(
firstCodeTagIndex,
lastCodeTagIndex - shortcut.length
);
transform.addMark(mark);
state = transform.collapseToEnd().removeMark(mark).apply();
return state;
}
}
},
onDash(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset } = state;
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
if (chars === '--') {
ev.preventDefault();
const transform = state
.transform()
.extendToStartOf(startBlock)
.delete()
.setBlock({
type: 'horizontal-rule',
isVoid: true,
});
state = transform
.collapseToStartOfNextBlock()
.insertBlock('paragraph')
.apply();
return state;
}
},
onBacktick(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset } = state;
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
if (chars === '``') {
ev.preventDefault();
return state
.transform()
.extendToStartOf(startBlock)
.delete()
.setBlock({
type: 'code',
})
.apply();
}
},
onBackspace(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, selection, startOffset } = state;
// If at the start of a non-paragraph, convert it back into a paragraph
if (startOffset === 0) {
if (startBlock.type === 'paragraph') return;
ev.preventDefault();
const transform = state.transform().setBlock('paragraph');
if (startBlock.type === 'list-item')
transform.unwrapBlock('bulleted-list');
state = transform.apply();
return state;
}
// If at the end of a code mark hitting backspace should remove the mark
if (selection.isCollapsed) {
const marksAtCursor = startBlock.getMarksAtRange(selection);
const codeMarksAtCursor = marksAtCursor.filter(
mark => mark.type === 'code'
);
if (codeMarksAtCursor.size > 0) {
ev.preventDefault();
const textNode = startBlock.getTextAtOffset(startOffset);
const charsInCodeBlock = textNode.characters
.takeUntil((v, k) => k === startOffset)
.reverse()
.takeUntil((v, k) => !v.marks.some(mark => mark.type === 'code'));
const transform = state.transform();
transform.removeMarkByKey(
textNode.key,
state.startOffset - charsInCodeBlock.size,
state.startOffset,
'code'
);
state = transform.apply();
return state;
}
}
},
/**
* On return, if at the end of a node type that should not be extended,
* create a new paragraph below it.
*/
onEnter(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset, endOffset } = state;
if (startOffset === 0 && startBlock.length === 0)
return this.onBackspace(ev, state);
if (endOffset !== startBlock.length) return;
if (
startBlock.type !== 'heading1' &&
startBlock.type !== 'heading2' &&
startBlock.type !== 'heading3' &&
startBlock.type !== 'heading4' &&
startBlock.type !== 'heading5' &&
startBlock.type !== 'heading6' &&
startBlock.type !== 'block-quote'
) {
return;
}
ev.preventDefault();
return state.transform().splitBlock().setBlock('paragraph').apply();
},
/**
* Get the block type for a series of auto-markdown shortcut `chars`.
*/
getType(chars: string) {
switch (chars) {
case '*':
case '-':
case '+':
case '1.':
return 'list-item';
case '>':
return 'block-quote';
case '#':
return 'heading1';
case '##':
return 'heading2';
case '###':
return 'heading3';
case '####':
return 'heading4';
case '#####':
return 'heading5';
case '######':
return 'heading6';
default:
return null;
}
},
};
}

View File

@ -0,0 +1,94 @@
// @flow
import React from 'react';
import Code from './components/Code';
import Image from './components/Image';
import Link from './components/Link';
import ListItem from './components/ListItem';
import Heading from './components/Heading';
import type { Props, Node, Transform } from './types';
import styles from './Editor.scss';
const schema = {
marks: {
bold: (props: Props) => <strong>{props.children}</strong>,
code: (props: Props) => <code>{props.children}</code>,
italic: (props: Props) => <em>{props.children}</em>,
underlined: (props: Props) => <u>{props.children}</u>,
deleted: (props: Props) => <del>{props.children}</del>,
added: (props: Props) => <mark>{props.children}</mark>,
},
nodes: {
paragraph: (props: Props) => <p>{props.children}</p>,
'block-quote': (props: Props) => <blockquote>{props.children}</blockquote>,
'horizontal-rule': (props: Props) => <hr />,
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
'todo-list': (props: Props) => (
<ul className={styles.todoList}>{props.children}</ul>
),
table: (props: Props) => <table>{props.children}</table>,
'table-row': (props: Props) => <tr>{props.children}</tr>,
'table-head': (props: Props) => <th>{props.children}</th>,
'table-cell': (props: Props) => <td>{props.children}</td>,
code: Code,
image: Image,
link: Link,
'list-item': ListItem,
heading1: (props: Props) => <Heading placeholder {...props} />,
heading2: (props: Props) => <Heading component="h2" {...props} />,
heading3: (props: Props) => <Heading component="h3" {...props} />,
heading4: (props: Props) => <Heading component="h4" {...props} />,
heading5: (props: Props) => <Heading component="h5" {...props} />,
heading6: (props: Props) => <Heading component="h6" {...props} />,
},
rules: [
// ensure first node is a heading
{
match: (node: Node) => {
return node.kind === 'document';
},
validate: (document: Node) => {
const firstNode = document.nodes.first();
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
},
normalize: (transform: Transform, document: Node, firstNode: Node) => {
transform.setBlock({ type: 'heading1' });
},
},
// remove any marks in first heading
{
match: (node: Node) => {
return node.kind === 'heading1';
},
validate: (heading: Node) => {
const hasMarks = heading.getMarks().isEmpty();
const hasInlines = heading.getInlines().isEmpty();
return !(hasMarks && hasInlines);
},
normalize: (transform: Transform, heading: Node) => {
transform.unwrapInlineByKey(heading.key);
heading.getMarks().forEach(mark => {
heading.nodes.forEach(textNode => {
if (textNode.kind === 'text') {
transform.removeMarkByKey(
textNode.key,
0,
textNode.text.length,
mark
);
}
});
});
return transform;
},
},
],
};
export default schema;

View File

@ -0,0 +1,3 @@
// @flow
import MarkdownSerializer from 'slate-markdown-serializer';
export default new MarkdownSerializer();

View File

@ -0,0 +1,110 @@
// @flow
import { List, Set, Map } from 'immutable';
export type NodeTransform = {
addMarkByKey: Function,
insertNodeByKey: Function,
insertTextByKey: Function,
moveNodeByKey: Function,
removeMarkByKey: Function,
removeNodeByKey: Function,
removeTextByKey: Function,
setMarkByKey: Function,
setNodeByKey: Function,
splitNodeByKey: Function,
unwrapInlineByKey: Function,
unwrapBlockByKey: Function,
unwrapNodeByKey: Function,
wrapBlockByKey: Function,
wrapInlineByKey: Function,
};
export type StateTransform = {
deleteBackward: Function,
deleteForward: Function,
delete: Function,
insertBlock: Function,
insertFragment: Function,
insertInline: Function,
insertText: Function,
addMark: Function,
setBlock: Function,
setInline: Function,
splitBlock: Function,
splitInline: Function,
removeMark: Function,
toggleMark: Function,
unwrapBlock: Function,
unwrapInline: Function,
wrapBlock: Function,
wrapInline: Function,
wrapText: Function,
};
export type Transform = NodeTransform & StateTransform;
export type Editor = {
props: Object,
className: string,
onChange: Function,
onDocumentChange: Function,
onSelectionChange: Function,
plugins: Array<Object>,
readOnly: boolean,
state: Object,
style: Object,
placeholder?: string,
placeholderClassName?: string,
placeholderStyle?: string,
blur: Function,
focus: Function,
getSchema: Function,
getState: Function,
};
export type Node = {
key: string,
kind: string,
length: number,
text: string,
data: Map<string, any>,
nodes: List<Node>,
getMarks: Function,
getBlocks: Function,
getParent: Function,
getInlines: Function,
getInlinesAtRange: Function,
setBlock: Function,
};
export type Block = Node & {
type: string,
};
export type Document = Node;
export type Props = {
node: Node,
parent?: Node,
attributes?: Object,
editor: Editor,
readOnly?: boolean,
children?: React$Element<any>,
};
export type State = {
document: Document,
selection: Selection,
startBlock: Block,
endBlock: Block,
startText: Node,
endText: Node,
marks: Set<*>,
blocks: List<Block>,
fragment: Document,
lines: List<Node>,
tests: List<Node>,
startBlock: Block,
transform: Function,
isBlurred: Function,
};

View File

@ -0,0 +1,78 @@
// @flow
import React, { Component } from 'react';
import invariant from 'invariant';
import get from 'lodash/get';
import { browserHistory } from 'react-router';
import { observer } from 'mobx-react';
import type { Document as DocumentType } from 'types';
import DropdownMenu, { MenuItem, MoreIcon } from 'components/DropdownMenu';
import DocumentStore from '../DocumentStore';
type Props = {
document: DocumentType,
collectionTree: ?Object,
store: DocumentStore,
};
@observer class Menu extends Component {
props: Props;
onCreateDocument = () => {
invariant(this.props.collectionTree, 'collectionTree is not available');
browserHistory.push(`${this.props.collectionTree.url}/new`);
};
onCreateChild = () => {
invariant(this.props.document, 'Document is not available');
browserHistory.push(`${this.props.document.url}/new`);
};
onDelete = () => {
let msg;
if (get(this.props, 'document.collection.type') === 'atlas') {
msg =
"Are you sure you want to delete this document and all it's child documents (if any)?";
} else {
msg = 'Are you sure you want to delete this document?';
}
if (confirm(msg)) {
this.props.store.deleteDocument();
}
};
onExport = () => {
const doc = this.props.document;
if (doc) {
const a = document.createElement('a');
a.textContent = 'download';
a.download = `${doc.title}.md`;
a.href = `data:text/markdown;charset=UTF-8,${encodeURIComponent(doc.text)}`;
a.click();
}
};
render() {
const document = get(this.props, 'document');
const collection = get(document, 'collection.type') === 'atlas';
const allowDelete =
collection &&
document.id !== get(document, 'collection.navigationTree.id');
return (
<DropdownMenu label={<MoreIcon />}>
{collection &&
<div>
<MenuItem onClick={this.onCreateDocument}>
New document
</MenuItem>
<MenuItem onClick={this.onCreateChild}>New child</MenuItem>
</div>}
<MenuItem onClick={this.onExport}>Export</MenuItem>
{allowDelete && <MenuItem onClick={this.onDelete}>Delete</MenuItem>}
</DropdownMenu>
);
}
}
export default Menu;

View File

@ -0,0 +1,3 @@
// @flow
import Document from './Document';
export default Document;

View File

@ -1,183 +0,0 @@
// @flow
import React, { Component } from 'react';
import { observer } from 'mobx-react';
import { browserHistory, withRouter } from 'react-router';
import keydown from 'react-keydown';
import { Flex } from 'reflexbox';
import DocumentEditStore, { DOCUMENT_EDIT_SETTINGS } from './DocumentEditStore';
import EditorLoader from './components/EditorLoader';
import Layout, { Title, HeaderAction, SaveAction } from 'components/Layout';
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
import CenteredContent from 'components/CenteredContent';
import DropdownMenu, { MenuItem, MoreIcon } from 'components/DropdownMenu';
const DISREGARD_CHANGES = `You have unsaved changes.
Are you sure you want to disgard them?`;
type Props = {
route: Object,
router: Object,
params: Object,
keydown: Object,
};
@keydown([
'cmd+enter',
'ctrl+enter',
'cmd+esc',
'ctrl+esc',
'cmd+shift+p',
'ctrl+shift+p',
])
@withRouter
@observer
class DocumentEdit extends Component {
store: DocumentEditStore;
props: Props;
constructor(props: Props) {
super(props);
this.store = new DocumentEditStore(
JSON.parse(localStorage[DOCUMENT_EDIT_SETTINGS] || '{}')
);
}
state = {
scrollTop: 0,
};
componentDidMount = () => {
if (this.props.route.newDocument) {
this.store.collectionId = this.props.params.id;
this.store.newDocument = true;
} else if (this.props.route.newChildDocument) {
this.store.documentId = this.props.params.id;
this.store.newChildDocument = true;
this.store.fetchDocument();
} else {
this.store.documentId = this.props.params.id;
this.store.newDocument = false;
this.store.fetchDocument();
}
// Load editor async
EditorLoader().then(({ Editor }) => {
// $FlowIssue we can remove after moving to new editor
this.setState({ Editor });
});
// Set onLeave hook
this.props.router.setRouteLeaveHook(this.props.route, () => {
if (this.store.hasPendingChanges) {
return confirm(DISREGARD_CHANGES);
}
return null;
});
};
componentWillReceiveProps = (nextProps: Props) => {
const key = nextProps.keydown.event;
if (key) {
// Cmd + Enter
if (key.key === 'Enter' && (key.metaKey || key.ctrl.Key)) {
this.onSave();
}
// Cmd + Esc
if (key.key === 'Escape' && (key.metaKey || key.ctrl.Key)) {
this.onCancel();
}
// Cmd + m
if (key.key === 'P' && key.shiftKey && (key.metaKey || key.ctrl.Key)) {
this.store.togglePreview();
}
}
};
onSave = () => {
// if (this.props.title.length === 0) {
// alert("Please add a title before saving (hint: Write a markdown header)");
// return
// }
if (this.store.newDocument || this.store.newChildDocument) {
this.store.saveDocument();
} else {
this.store.updateDocument();
}
};
onCancel = () => {
browserHistory.goBack();
};
onScroll = (scrollTop: number) => {
this.setState({
scrollTop,
});
};
render() {
const title = (
<Title
truncate={60}
placeholder={!this.store.isFetching ? 'Untitled document' : null}
content={this.store.title}
/>
);
const titleText = this.store.title;
const isNew =
this.props.route.newDocument || this.props.route.newChildDocument;
const actions = (
<Flex>
<HeaderAction>
<SaveAction
onClick={this.onSave}
disabled={this.store.isSaving}
isNew={isNew}
/>
</HeaderAction>
<DropdownMenu label={<MoreIcon />}>
<MenuItem onClick={this.store.togglePreview}>
Toggle Preview
</MenuItem>
<MenuItem onClick={this.onCancel}>
Cancel
</MenuItem>
</DropdownMenu>
</Flex>
);
return (
<Layout
actions={actions}
title={title}
titleText={titleText}
fixed
loading={this.store.isSaving || this.store.isUploading}
search={false}
>
{this.store.isFetching || !('Editor' in this.state)
? <CenteredContent>
<AtlasPreviewLoading />
</CenteredContent>
: <this.state.Editor
store={this.store}
scrollTop={this.state.scrollTop}
onScroll={this.onScroll}
onSave={this.onSave}
onCancel={this.onCancel}
togglePreview={this.togglePreview}
/>}
</Layout>
);
}
}
export default DocumentEdit;

View File

@ -1,37 +0,0 @@
@import '~styles/constants.scss';
.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;
height: 100%;
}
.fullWidth {
flex: 1;
display: flex;
.paneContent {
display: flex;
}
}
:global {
::-webkit-scrollbar {
display: none;
}
}

View File

@ -1,170 +0,0 @@
// @flow
import { observable, action, toJS, autorun } from 'mobx';
import { browserHistory } from 'react-router';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import emojify from 'utils/emojify';
import type { Document } from 'types';
const DOCUMENT_EDIT_SETTINGS = 'DOCUMENT_EDIT_SETTINGS';
const parseHeader = text => {
const firstLine = text.split(/\r?\n/)[0];
if (firstLine) {
const match = firstLine.match(/^#+ +(.*)$/);
if (match) {
return emojify(match[1]);
} else {
return '';
}
}
return '';
};
class DocumentEditStore {
@observable documentId = null;
@observable collectionId = null;
@observable parentDocument: ?Document;
@observable title: string;
@observable text: string;
@observable hasPendingChanges = false;
@observable newDocument: ?boolean;
@observable newChildDocument: ?boolean;
@observable preview: ?boolean = false;
@observable isFetching: boolean = false;
@observable isSaving: boolean = false;
@observable isUploading: boolean = false;
/* Actions */
@action fetchDocument = async () => {
this.isFetching = true;
try {
const res = await client.get(
'/documents.info',
{
id: this.documentId,
},
{ cache: true }
);
invariant(res && res.data, 'Data shoule be available');
if (this.newChildDocument) {
this.parentDocument = res.data;
} else {
const { title, text } = res.data;
this.title = title;
this.text = text;
}
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
};
@action saveDocument = async () => {
if (this.isSaving) return;
this.isSaving = true;
try {
const res = await client.post(
'/documents.create',
{
parentDocument: this.parentDocument && this.parentDocument.id,
// $FlowFixMe this logic will probably get rewritten soon anyway
collection: this.collectionId || this.parentDocument.collection.id,
title: this.title || 'Untitled document',
text: this.text,
},
{ cache: true }
);
invariant(res && res.data, 'Data shoule be available');
const { url } = res.data;
this.hasPendingChanges = false;
browserHistory.push(url);
} catch (e) {
console.error('Something went wrong');
}
this.isSaving = false;
};
@action updateDocument = async () => {
if (this.isSaving) return;
this.isSaving = true;
try {
const res = await client.post(
'/documents.update',
{
id: this.documentId,
title: this.title || 'Untitled document',
text: this.text,
},
{ cache: true }
);
invariant(res && res.data, 'Data shoule be available');
const { url } = res.data;
this.hasPendingChanges = false;
browserHistory.push(url);
} catch (e) {
console.error('Something went wrong');
}
this.isSaving = false;
};
@action updateText = (text: string) => {
this.text = text;
this.title = parseHeader(text);
this.hasPendingChanges = true;
};
@action updateTitle = (title: string) => {
this.title = title;
};
@action replaceText = (args: { original: string, new: string }) => {
this.text = this.text.replace(args.original, args.new);
this.hasPendingChanges = true;
};
@action togglePreview = () => {
this.preview = !this.preview;
};
@action reset = () => {
this.title = 'Lets start with a title';
this.text = '# Lets start with a title\n\nAnd continue from there...';
};
@action toggleUploadingIndicator = () => {
this.isUploading = !this.isUploading;
};
// Generic
persistSettings = () => {
localStorage[DOCUMENT_EDIT_SETTINGS] = JSON.stringify({
preview: toJS(this.preview),
});
};
constructor(settings: { preview: ?boolean }) {
// Rehydrate settings
this.preview = settings.preview;
// Persist settings to localStorage
// TODO: This could be done more selectively
autorun(() => {
this.persistSettings();
});
}
}
export default DocumentEditStore;
export { DOCUMENT_EDIT_SETTINGS };

View File

@ -1,38 +0,0 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import { convertToMarkdown } from 'utils/markdown';
import MarkdownEditor from 'components/MarkdownEditor';
import Preview from './Preview';
import EditorPane from './EditorPane';
import styles from '../DocumentEdit.scss';
const Editor = observer(props => {
const store = props.store;
return (
<div className={styles.container}>
<EditorPane fullWidth={!store.preview} onScroll={props.onScroll}>
<MarkdownEditor
onChange={store.updateText}
text={store.text}
replaceText={store.replaceText}
preview={store.preview}
onSave={props.onSave}
onCancel={props.onCancel}
togglePreview={props.togglePreview}
toggleUploadingIndicator={store.toggleUploadingIndicator}
/>
</EditorPane>
{store.preview
? <EditorPane scrollTop={props.scrollTop}>
<Preview html={convertToMarkdown(store.text)} />
</EditorPane>
: null}
</div>
);
});
export default Editor;

View File

@ -1,11 +0,0 @@
// @flow
export default () => {
return new Promise(resolve => {
// $FlowIssue this is available with webpack
require.ensure([], () => {
resolve({
Editor: require('./Editor').default,
});
});
});
};

View File

@ -1,65 +0,0 @@
// @flow
import React from 'react';
import styles from '../DocumentEdit.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
type Props = {
children?: ?React.Element<any>,
onScroll?: Function,
scrollTop?: ?number,
fullWidth?: ?boolean,
};
class EditorPane extends React.Component {
props: Props;
componentWillReceiveProps = (nextProps: Props) => {
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: Event) => {
setTimeout(() => {
const element = this.refs.pane;
const contentEl = this.refs.content;
this.props.onScroll &&
this.props.onScroll(element.scrollTop / contentEl.offsetHeight);
}, 50);
};
scrollToPosition = (percentage: number) => {
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

@ -1,22 +0,0 @@
// @flow
import React from 'react';
import { DocumentHtml } from 'components/Document';
import styles from './Preview.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
type Props = {
html: ?string,
};
const Preview = (props: Props) => {
return (
<div className={cx(styles.container)}>
<DocumentHtml html={props.html} />
</div>
);
};
export default Preview;

View File

@ -1,14 +0,0 @@
.container {
display: flex;
flex: 1;
padding: 50px 0;
padding: 50px 3em;
max-width: 50em;
line-height: 1.5em;
}
.container :global {
h1:hover .anchor {
visibility: hidden;
}
}

View File

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

View File

@ -1,3 +0,0 @@
// @flow
import DocumentEdit from './DocumentEdit';
export default DocumentEdit;

View File

@ -1,217 +0,0 @@
// @flow
import React, { PropTypes } from 'react';
import invariant from 'invariant';
import { Link, browserHistory } from 'react-router';
import { observer, inject } from 'mobx-react';
import { toJS } from 'mobx';
import keydown from 'react-keydown';
import _ from 'lodash';
import DocumentSceneStore, { DOCUMENT_PREFERENCES } from './DocumentSceneStore';
import UiStore from 'stores/UiStore';
import Layout from 'components/Layout';
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
import CenteredContent from 'components/CenteredContent';
import Document from 'components/Document';
import DropdownMenu, { MenuItem, MoreIcon } from 'components/DropdownMenu';
import { Flex } from 'reflexbox';
import Sidebar from './components/Sidebar';
import Breadcrumbs from './components/Breadcrumbs';
import styles from './DocumentScene.scss';
type Props = {
ui: UiStore,
routeParams: Object,
params: Object,
location: Object,
keydown: Object,
};
@keydown(['cmd+/', 'ctrl+/', 'c', 'e'])
@inject('ui')
@observer
class DocumentScene extends React.Component {
store: DocumentSceneStore;
static propTypes = {
ui: PropTypes.object.isRequired,
routeParams: PropTypes.object,
params: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
};
constructor(props: Props) {
super(props);
this.store = new DocumentSceneStore(
JSON.parse(localStorage[DOCUMENT_PREFERENCES] || '{}')
);
}
state = {
didScroll: false,
};
componentDidMount = () => {
const { id } = this.props.routeParams;
this.store
.fetchDocument(id, {
replaceUrl: !this.props.location.hash,
})
.then(() => this.scrollTohash());
};
componentWillReceiveProps = (nextProps: Props) => {
const key = nextProps.keydown.event;
if (key) {
if (key.key === '/' && (key.metaKey || key.ctrl.Key)) {
this.props.ui.toggleSidebar();
}
if (key.key === 'c') {
_.defer(this.onCreateDocument);
}
if (key.key === 'e') {
_.defer(this.onEdit);
}
}
// Reload on url change
const oldId = this.props.params.id;
const newId = nextProps.params.id;
if (oldId !== newId) {
this.store
.fetchDocument(newId, {
softLoad: true,
replaceUrl: !this.props.location.hash,
})
.then(() => this.scrollTohash());
}
};
onEdit = () => {
invariant(this.store.document, 'Document is not available');
const url = `${this.store.document.url}/edit`;
browserHistory.push(url);
};
onCreateDocument = () => {
invariant(this.store.collectionTree, 'collectionTree is not available');
browserHistory.push(`${this.store.collectionTree.url}/new`);
};
onCreateChild = () => {
invariant(this.store.document, 'Document is not available');
browserHistory.push(`${this.store.document.url}/new`);
};
onDelete = () => {
let msg;
if (
this.store.document &&
this.store.document.collection &&
this.store.document.collection.type === 'atlas'
) {
msg =
"Are you sure you want to delete this document and all it's child documents (if any)?";
} else {
msg = 'Are you sure you want to delete this document?';
}
if (confirm(msg)) {
this.store.deleteDocument();
}
};
onExport = () => {
const doc = this.store.document;
if (doc) {
const a = document.createElement('a');
a.textContent = 'download';
a.download = `${doc.title}.md`;
a.href = `data:text/markdown;charset=UTF-8,${encodeURIComponent(doc.text)}`;
a.click();
}
};
scrollTohash = () => {
// Scroll to anchor after loading, and only once
const { hash } = this.props.location;
if (hash && !this.state.didScroll) {
const name = hash.slice(1);
this.setState({ didScroll: true });
const element = window.document.getElementsByName(name)[0];
if (element) element.scrollIntoView();
}
};
render() {
const { sidebar } = this.props.ui;
const doc = this.store.document;
// FIXME: feels ghetto
if (!doc) return <div />;
const allowDelete =
doc &&
doc.collection.type === 'atlas' &&
doc.id !== doc.collection.navigationTree.id;
let title;
let titleText;
let actions;
if (doc) {
actions = (
<div className={styles.actions}>
<DropdownMenu label={<MoreIcon />}>
{this.store.isCollection &&
<div className={styles.menuGroup}>
<MenuItem onClick={this.onCreateDocument}>
New document
</MenuItem>
<MenuItem onClick={this.onCreateChild}>New child</MenuItem>
</div>}
<MenuItem onClick={this.onEdit}>Edit</MenuItem>
<MenuItem onClick={this.onExport}>Export</MenuItem>
{allowDelete && <MenuItem onClick={this.onDelete}>Delete</MenuItem>}
</DropdownMenu>
</div>
);
title = <Breadcrumbs store={this.store} />;
titleText = `${doc.collection.name} - ${doc.title}`;
}
return (
<Layout
title={title}
titleText={titleText}
actions={doc && actions}
loading={this.store.updatingStructure}
>
{this.store.isFetching
? <CenteredContent>
<AtlasPreviewLoading />
</CenteredContent>
: <Flex auto>
{this.store.isCollection &&
<Sidebar
open={sidebar}
onToggle={this.props.ui.toggleSidebar}
navigationTree={toJS(this.store.collectionTree)}
onNavigationUpdate={this.store.updateNavigationTree}
onNodeCollapse={this.store.onNodeCollapse}
/>}
<Flex auto justify="center" className={styles.content}>
<CenteredContent>
<Document document={doc} />
</CenteredContent>
</Flex>
</Flex>}
</Layout>
);
}
}
export default DocumentScene;

View File

@ -1,13 +0,0 @@
.actions {
display: flex;
flex-direction: row;
}
.content {
position: relative;
overflow: scroll;
}
.menuGroup {
border-bottom: 1px solid #eee;
}

View File

@ -1,182 +0,0 @@
// @flow
import _ from 'lodash';
import { browserHistory } from 'react-router';
import invariant from 'invariant';
import {
observable,
action,
computed,
runInAction,
toJS,
autorunAsync,
} from 'mobx';
import { client } from 'utils/ApiClient';
import type {
Document as DocumentType,
Collection,
NavigationNode,
} from 'types';
const DOCUMENT_PREFERENCES = 'DOCUMENT_PREFERENCES';
type Document = {
collection: Collection,
} & DocumentType;
class DocumentSceneStore {
@observable document: ?Document;
@observable collapsedNodes: string[] = [];
@observable isFetching: boolean = true;
@observable updatingContent: boolean = false;
@observable updatingStructure: boolean = false;
@observable isDeleting: boolean = false;
/* Computed */
@computed get isCollection(): boolean {
return !!this.document && this.document.collection.type === 'atlas';
}
@computed get collectionTree(): ?Object {
if (
this.document &&
this.document.collection &&
this.document.collection.type === 'atlas'
) {
const tree = this.document.collection.navigationTree;
const collapseNodes = node => {
node.collapsed = this.collapsedNodes.includes(node.id);
node.children = node.children.map(childNode => {
return collapseNodes(childNode);
});
return node;
};
return collapseNodes(toJS(tree));
}
}
@computed get pathToDocument(): ?Array<NavigationNode> {
let path;
const traveler = (node, previousPath) => {
if (this.document && node.id === this.document.id) {
path = previousPath;
return;
} else {
node.children.forEach(childNode => {
const newPath = [...previousPath, node];
return traveler(childNode, newPath);
});
}
};
if (this.document && this.collectionTree) {
traveler(this.collectionTree, []);
invariant(path, 'Path is not available for collection, abort');
return path.splice(1);
}
}
/* Actions */
@action fetchDocument = async (
id: string,
options: { softLoad?: boolean, replaceUrl?: boolean } = {}
) => {
options = {
softLoad: false,
replaceUrl: true,
...options,
};
this.isFetching = !options.softLoad;
this.updatingContent = true;
try {
const res = await client.get('/documents.info', { id });
invariant(res && res.data, 'data should be available');
const { data } = res;
runInAction('fetchDocument', () => {
this.document = data;
if (options.replaceUrl) browserHistory.replace(data.url);
});
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
this.updatingContent = false;
};
@action deleteDocument = async () => {
if (!this.document) return;
this.isFetching = true;
try {
await client.post('/documents.delete', { id: this.document.id });
// $FlowFixMe don't be stupid
browserHistory.push(this.document.collection.url);
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
};
@action updateNavigationTree = async (tree: Object) => {
// Only update when tree changes
// $FlowFixMe don't be stupid
if (_.isEqual(toJS(tree), toJS(this.document.collection.navigationTree))) {
return true;
}
this.updatingStructure = true;
try {
const res = await client.post('/collections.updateNavigationTree', {
// $FlowFixMe don't be stupid
id: this.document.collection.id,
tree,
});
invariant(res && res.data, 'data should be available');
runInAction('updateNavigationTree', () => {
const { data } = res;
// $FlowFixMe don't be stupid
this.document.collection = data;
});
} catch (e) {
console.error('Something went wrong');
}
this.updatingStructure = false;
};
@action onNodeCollapse = (nodeId: string) => {
if (_.indexOf(this.collapsedNodes, nodeId) >= 0) {
this.collapsedNodes = _.without(this.collapsedNodes, nodeId);
} else {
this.collapsedNodes.push(nodeId);
}
};
// General
persistSettings = () => {
localStorage[DOCUMENT_PREFERENCES] = JSON.stringify({
collapsedNodes: toJS(this.collapsedNodes),
});
};
constructor(settings: { collapsedNodes: string[] }) {
// Rehydrate settings
this.collapsedNodes = settings.collapsedNodes || [];
// Persist settings to localStorage
// TODO: This could be done more selectively
autorunAsync(() => {
this.persistSettings();
});
}
}
export default DocumentSceneStore;
export { DOCUMENT_PREFERENCES };

View File

@ -1,3 +0,0 @@
// @flow
import DocumentScene from './DocumentScene';
export default DocumentScene;

View File

@ -2,7 +2,7 @@
import { observable, action, runInAction } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import type { Pagination, Document } from 'types';
import type { Document } from 'types';
class SearchStore {
@observable documents: ?(Document[]);

View File

@ -15,7 +15,7 @@ class SearchStore {
try {
const res = await client.post('/apiKeys.list');
invariant(res && res.data, 'Data shoule be available');
invariant(res && res.data, 'Data should be available');
const { data } = res;
runInAction('fetchApiKeys', () => {
@ -34,7 +34,7 @@ class SearchStore {
const res = await client.post('/apiKeys.create', {
name: this.keyName ? this.keyName : 'Untitled key',
});
invariant(res && res.data, 'Data shoule be available');
invariant(res && res.data, 'Data should be available');
const { data } = res;
runInAction('createApiKey', () => {
this.apiKeys.push(data);

View File

@ -87,12 +87,11 @@ blockquote {
margin-left: 0;
}
hr {
width: 75%;
margin: 3em auto;
margin: 2em 0;
border: 0;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
border-bottom-color: #dedede;
}
*[role=button] {
cursor: pointer;

View File

@ -1,342 +0,0 @@
@import './constants.scss';
:global {
/* BASICS */
.CodeMirror {
/* Set height, width, borders, and global font properties here */
font-family: monospace;
height: 300px;
color: black;
}
/* PADDING */
.CodeMirror-lines {
padding: 4px 0; /* Vertical padding around content */
}
.CodeMirror pre {
padding: 0 4px; /* Horizontal padding of content */
}
.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
border-right: 1px solid #ddd;
background-color: #f7f7f7;
white-space: nowrap;
}
.CodeMirror-linenumbers {}
.CodeMirror-linenumber {
padding: 0 3px 0 5px;
min-width: 20px;
text-align: right;
color: #999;
white-space: nowrap;
}
.CodeMirror-guttermarker { color: black; }
.CodeMirror-guttermarker-subtle { color: #999; }
/* CURSOR */
.CodeMirror-cursor {
border-left: 1px solid black;
border-right: none;
width: 0;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-fat-cursor .CodeMirror-cursor {
width: auto;
border: 0;
background: $actionColor;
}
.cm-fat-cursor div.CodeMirror-cursors {
z-index: 1;
}
.cm-animate-fat-cursor {
width: auto;
border: 0;
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
background-color: #7e7;
}
@-moz-keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
@-webkit-keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
@keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
/* Can style cursor different in overwrite (non-insert) mode */
.CodeMirror-overwrite .CodeMirror-cursor {}
.cm-tab { display: inline-block; text-decoration: inherit; }
.CodeMirror-ruler {
border-left: 1px solid #ccc;
position: absolute;
}
/* DEFAULT THEME */
.cm-s-default .cm-header {color: blue;}
.cm-s-default .cm-quote {color: #090;}
.cm-negative {color: #d44;}
.cm-positive {color: #292;}
.cm-header, .cm-strong {font-weight: bold;}
.cm-em {font-style: italic;}
.cm-link {text-decoration: underline;}
.cm-strikethrough {text-decoration: line-through;}
.cm-s-default .cm-keyword {color: #708;}
.cm-s-default .cm-atom {color: #219;}
.cm-s-default .cm-number {color: #164;}
.cm-s-default .cm-def {color: #00f;}
.cm-s-default .cm-variable,
.cm-s-default .cm-punctuation,
.cm-s-default .cm-property,
.cm-s-default .cm-operator {}
.cm-s-default .cm-variable-2 {color: #05a;}
.cm-s-default .cm-variable-3 {color: #085;}
.cm-s-default .cm-comment {color: #a50;}
.cm-s-default .cm-string {color: #a11;}
.cm-s-default .cm-string-2 {color: #f50;}
.cm-s-default .cm-meta {color: #555;}
.cm-s-default .cm-qualifier {color: #555;}
.cm-s-default .cm-builtin {color: #30a;}
.cm-s-default .cm-bracket {color: #997;}
.cm-s-default .cm-tag {color: #170;}
.cm-s-default .cm-attribute {color: #00c;}
.cm-s-default .cm-hr {color: #999;}
.cm-s-default .cm-link {color: #00c;}
.cm-s-default .cm-error {color: #f00;}
.cm-invalidchar {color: #f00;}
.CodeMirror-composing { border-bottom: 2px solid; }
/* Default styles for common addons */
div.CodeMirror span.CodeMirror-matchingbracket {color: #0f0;}
div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); }
.CodeMirror-activeline-background {background: #e8f2ff;}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror {
position: relative;
overflow: hidden;
background: white;
}
.CodeMirror-scroll {
overflow: scroll !important; /* Things will break if this is overridden */
/* 30px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -30px; margin-right: -30px;
padding-bottom: 30px;
height: 100%;
outline: none; /* Prevent dragging from highlighting the element */
position: relative;
}
.CodeMirror-sizer {
position: relative;
border-right: 30px solid transparent;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actual scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
}
.CodeMirror-vscrollbar {
right: 0; top: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
bottom: 0; left: 0;
overflow-y: hidden;
overflow-x: scroll;
}
.CodeMirror-scrollbar-filler {
right: 0; bottom: 0;
}
.CodeMirror-gutter-filler {
left: 0; bottom: 0;
}
.CodeMirror-gutters {
position: absolute; left: 0; top: 0;
min-height: 100%;
z-index: 3;
}
.CodeMirror-gutter {
white-space: normal;
height: 100%;
display: inline-block;
vertical-align: top;
margin-bottom: -30px;
/* Hack to make IE7 behave */
*zoom:1;
*display:inline;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0; bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
cursor: default;
z-index: 4;
}
.CodeMirror-gutter-wrapper {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.CodeMirror-lines {
cursor: text;
min-height: 1px; /* prevents collapsing before first draw */
}
.CodeMirror pre {
/* Reset some styles that the rest of the page might have set */
-moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0;
border-width: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre;
word-wrap: normal;
line-height: inherit;
color: inherit;
z-index: 2;
position: relative;
overflow: visible;
-webkit-tap-highlight-color: transparent;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
}
.CodeMirror-wrap pre {
word-wrap: break-word;
white-space: pre-wrap;
word-break: normal;
}
.CodeMirror-linebackground {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
overflow: auto;
}
.CodeMirror-widget {}
.CodeMirror-code {
outline: none;
}
/* Force content-box sizing for the elements where we expect it */
.CodeMirror-scroll,
.CodeMirror-sizer,
.CodeMirror-gutter,
.CodeMirror-gutters,
.CodeMirror-linenumber {
-moz-box-sizing: content-box;
box-sizing: content-box;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
}
.CodeMirror-cursor { position: absolute; }
.CodeMirror-measure pre { position: static; }
div.CodeMirror-cursors {
visibility: hidden;
position: relative;
z-index: 3;
}
div.CodeMirror-dragcursors {
visibility: visible;
}
.CodeMirror-focused div.CodeMirror-cursors {
visibility: visible;
}
.CodeMirror-selected { background: #d9d9d9; }
.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; }
.cm-searching {
background: #ffa;
background: rgba(255, 255, 0, .4);
}
/* IE7 hack to prevent it from returning funny offsetTops on the spans */
.CodeMirror span { *vertical-align: text-bottom; }
/* Used to force a border model for a node */
.cm-force-border { padding-right: .1px; }
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
/* See issue #2901 */
.cm-tab-wrap-hack:after { content: ''; }
/* Help users use markselection to safely style text background */
span.CodeMirror-selectedtext { background: none; }
}

View File

@ -0,0 +1,123 @@
/**
* prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML
* Based on https://github.com/chriskempson/tomorrow-theme
* @author Rose Pritchard
*/
:global {
code[class*="language-"],
pre[class*="language-"] {
color: #ccc;
background: none;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #2d2d2d;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #999;
}
.token.punctuation {
color: #ccc;
}
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: #e2777a;
}
.token.function-name {
color: #6196cc;
}
.token.boolean,
.token.number,
.token.function {
color: #f08d49;
}
.token.property,
.token.class-name,
.token.constant,
.token.symbol {
color: #f8c555;
}
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: #cc99cd;
}
.token.string,
.token.char,
.token.attr-value,
.token.regex,
.token.variable {
color: #7ec699;
}
.token.operator,
.token.entity,
.token.url {
color: #67cdcc;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.inserted {
color: green;
}
}

View File

@ -103,7 +103,6 @@ class ApiClient {
};
// Helpers
constructQueryString = (data: Object) => {
return _.map(data, (v, k) => {
return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`;

View File

@ -0,0 +1,44 @@
// @flow
import { client } from './ApiClient';
import invariant from 'invariant';
type File = {
blob: boolean,
type: string,
size: number,
name: string,
file: string,
};
export default async function uploadFile(file: File) {
const response = await client.post('/user.s3Upload', {
kind: file.type,
size: file.size,
filename: file.name,
});
invariant(response, 'Response should be available');
const data = response.data;
const asset = data.asset;
const formData = new FormData();
for (const key in data.form) {
formData.append(key, data.form[key]);
}
if (file.blob) {
formData.append('file', file.file);
} else {
// $FlowFixMe
formData.append('file', file);
}
const options: Object = {
method: 'post',
body: formData,
};
await fetch(data.uploadUrl, options);
return asset;
}

View File

@ -1,4 +1,6 @@
require('safestart')(__dirname);
require('safestart')(__dirname, {
exclude: ['slate-markdown-serializer'],
});
require('babel-core/register');
require('babel-polyfill');
require('localenv');

View File

@ -77,7 +77,6 @@
"bcrypt": "^0.8.7",
"bugsnag": "^1.7.0",
"classnames": "2.2.3",
"codemirror": "^5.25.2",
"cross-env": "1.0.7",
"css-loader": "0.23.1",
"debug": "2.2.0",
@ -137,21 +136,30 @@
"raw-loader": "^0.5.1",
"react": "15.3.2",
"react-addons-css-transition-group": "15.3.2",
"react-codemirror": "0.2.6",
"react-dom": "15.3.2",
"react-dropzone": "3.6.0",
"react-helmet": "3.1.0",
"react-keydown": "^1.6.1",
"react-keydown": "^1.7.3",
"react-portal": "^3.1.0",
"react-router": "2.8.0",
"redis": "^2.6.2",
"redis-lock": "^0.1.0",
"reflexbox": "^2.2.3",
"rimraf": "^2.5.4",
"safestart": "0.8.0",
"safestart": "1.1.0",
"sass-loader": "4.0.0",
"sequelize": "3.24.1",
"sequelize-cli": "2.4.0",
"sequelize-encrypted": "0.1.0",
"slate": "^0.19.30",
"slate-collapse-on-escape": "^0.2.1",
"slate-drop-or-paste-images": "^0.5.0",
"slate-edit-code": "^0.10.2",
"slate-edit-list": "^0.7.0",
"slate-markdown-serializer": "tommoor/slate-markdown-serializer",
"slate-paste-linkify": "^0.2.1",
"slate-prism": "^0.2.2",
"slate-trailing-block": "^0.2.4",
"slug": "0.9.1",
"string-hash": "^1.1.0",
"style-loader": "0.13.0",

273
yarn.lock
View File

@ -1363,7 +1363,7 @@ charenc@~0.0.1:
version "0.0.2"
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
cheerio@0.22.0:
cheerio@0.22.0, cheerio@^0.22.0:
version "0.22.0"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e"
dependencies:
@ -1427,7 +1427,7 @@ clap@^1.0.9:
dependencies:
chalk "^1.1.3"
classnames@2.2.3, classnames@^2.2.3:
classnames@2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.3.tgz#551b774b6762a0c0a997187f7ba4f1d603961ac5"
@ -1481,6 +1481,14 @@ cli-width@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a"
clipboard@^1.5.5:
version "1.6.1"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.6.1.tgz#65c5b654812466b0faab82dc6ba0f1d2f8e4be53"
dependencies:
good-listener "^1.2.0"
select "^1.1.2"
tiny-emitter "^1.0.0"
cliui@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1"
@ -1533,10 +1541,6 @@ code-point-at@^1.0.0:
dependencies:
number-is-nan "^1.0.0"
codemirror@^5.13.4, codemirror@^5.25.2:
version "5.25.2"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.25.2.tgz#8c77677ca9c9248d757d3a07ed1e89a8404850b7"
color-convert@^1.0.0, color-convert@^1.3.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.5.0.tgz#7a2b4efb4488df85bca6443cb038b7100fbe7de1"
@ -2067,6 +2071,14 @@ dashify@^0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/dashify/-/dashify-0.2.2.tgz#6a07415a01c91faf4a32e38d9dfba71f61cb20fe"
data-uri-regex@^0.1.2:
version "0.1.4"
resolved "https://registry.yarnpkg.com/data-uri-regex/-/data-uri-regex-0.1.4.tgz#1e1db6c8397eca8a48ecdb55ad1b927ec0bbac2e"
data-uri-to-blob@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/data-uri-to-blob/-/data-uri-to-blob-0.0.4.tgz#087a7bff42f41a6cc0b2e2fb7312a7c29904fbaa"
date-fns@^1.27.2:
version "1.28.4"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.28.4.tgz#7938aec34ba31fc8bd134d2344bc2e0bbfd95165"
@ -2098,7 +2110,7 @@ debug@*, debug@2.2.0, debug@^2.1.1, debug@^2.2.0, debug@~2.2.0:
dependencies:
ms "0.7.1"
debug@^2.6.1:
debug@^2.3.2, debug@^2.6.1:
version "2.6.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.4.tgz#7586a9b3c39741c0282ae33445c4e8ac74734fe0"
dependencies:
@ -2153,6 +2165,10 @@ delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
delegate@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.1.2.tgz#1e1bc6f5cadda6cb6cbf7e6d05d0bcdd5712aebe"
delegates@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
@ -2192,6 +2208,10 @@ detect-indent@^4.0.0:
dependencies:
repeating "^2.0.0"
detect-newline@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
diff@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.0.1.tgz#a52d90cc08956994be00877bff97110062582c35"
@ -2204,6 +2224,10 @@ diffie-hellman@^5.0.0:
miller-rabin "^4.0.0"
randombytes "^2.0.0"
direction@^0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/direction/-/direction-0.1.5.tgz#ce5d797f97e26f8be7beff53f7dc40e1c1a9ec4c"
discontinuous-range@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
@ -2410,6 +2434,10 @@ end-of-stream@~0.1.5:
dependencies:
once "~1.3.0"
ends-with@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
enhanced-resolve@~0.9.0:
version "0.9.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz#4d6e689b3725f86090927ccc86cd9f1635b89e2e"
@ -2506,7 +2534,7 @@ es6-iterator@~0.1.3:
es5-ext "~0.10.5"
es6-symbol "~2.0.1"
es6-map@^0.1.3:
es6-map@^0.1.3, es6-map@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897"
dependencies:
@ -2521,6 +2549,10 @@ es6-promise@^3.0.2:
version "3.3.1"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
es6-promise@^4.0.5:
version "4.1.0"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.0.tgz#dda03ca8f9f89bc597e689842929de7ba8cebdf0"
es6-set@~0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8"
@ -2739,6 +2771,10 @@ esrecurse@^4.1.0:
estraverse "~4.1.0"
object-assign "^4.0.1"
esrever@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/esrever/-/esrever-0.2.0.tgz#96e9d28f4f1b1a76784cd5d490eaae010e7407b8"
estraverse@^1.9.1:
version "1.9.3"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44"
@ -3243,6 +3279,10 @@ get-caller-file@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
get-document@1:
version "1.0.0"
resolved "https://registry.yarnpkg.com/get-document/-/get-document-1.0.0.tgz#4821bce66f1c24cb0331602be6cb6b12c4f01c4b"
get-stdin@5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398"
@ -3255,6 +3295,12 @@ get-stream@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
get-window@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/get-window/-/get-window-1.1.1.tgz#0750f8970c88a54ac1294deb97add9568b3db594"
dependencies:
get-document "1"
getpass@^0.1.1:
version "0.1.6"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.6.tgz#283ffd9fc1256840875311c1b60e8c40187110e6"
@ -3412,6 +3458,12 @@ glogg@^1.0.0:
dependencies:
sparkles "^1.0.0"
good-listener@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
dependencies:
delegate "^3.1.2"
got@^3.2.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/got/-/got-3.3.1.tgz#e5d0ed4af55fc3eef4d56007769d98192bcb2eca"
@ -3826,7 +3878,15 @@ ignore@^3.2.0:
version "3.2.7"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.7.tgz#4810ca5f1d8eca5595213a34b94f2eb4ed926bbd"
immutable@^3.7.6:
image-extensions@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/image-extensions/-/image-extensions-1.1.0.tgz#b8e6bf6039df0056e333502a00b6637a3105d894"
image-to-data-uri@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/image-to-data-uri/-/image-to-data-uri-1.1.0.tgz#23f9d7f17b6562ca6a8145e9779c9a166b829f6e"
immutable@^3.7.6, immutable@^3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.1.tgz#200807f11ab0f72710ea485542de088075f68cd2"
@ -3980,6 +4040,12 @@ is-callable@^1.1.1, is-callable@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
is-data-uri@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/is-data-uri/-/is-data-uri-0.1.0.tgz#46ee67b63c18c1ffa0bd4dfab2cd2c81c728237f"
dependencies:
data-uri-regex "^0.1.2"
is-date-object@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
@ -3988,6 +4054,10 @@ is-dotfile@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.2.tgz#2c132383f39199f8edc268ca01b9b007d205cc4d"
is-empty@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/is-empty/-/is-empty-1.2.0.tgz#de9bb5b278738a05a0b09a57e1fb4d4a341a9f6b"
is-equal-shallow@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
@ -4028,6 +4098,16 @@ is-glob@^2.0.0, is-glob@^2.0.1:
dependencies:
is-extglob "^1.0.0"
is-image@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-image/-/is-image-1.0.1.tgz#6fd51a752a1a111506d060d952118b0b989b426e"
dependencies:
image-extensions "^1.0.1"
is-in-browser@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835"
is-lower-case@^1.1.0:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-lower-case/-/is-lower-case-1.1.3.tgz#7e147be4768dc466db3bfb21cc60b31e6ad69393"
@ -4153,10 +4233,18 @@ is-upper-case@^1.1.0:
dependencies:
upper-case "^1.1.0"
is-url@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.2.tgz#498905a593bf47cc2d9e7f738372bbf7696c7f26"
is-utf8@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
is-window@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-window/-/is-window-1.0.2.tgz#2c896ca53db97de45d3c33133a65d8c9f563480d"
is-windows@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.1.1.tgz#be310715431cfabccc54ab3951210fa0b6d01abe"
@ -4689,6 +4777,10 @@ jws@^3.1.4:
jwa "^1.1.4"
safe-buffer "^5.0.1"
keycode@^2.1.2:
version "2.1.8"
resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.1.8.tgz#94d2b7098215eff0e8f9a8931d5a59076c4532fb"
keygrip@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.0.1.tgz#b02fa4816eef21a8c4b35ca9e52921ffc89a30e9"
@ -5135,10 +5227,6 @@ lodash.cond@^4.3.0:
version "4.5.2"
resolved "https://registry.yarnpkg.com/lodash.cond/-/lodash.cond-4.5.2.tgz#f471a1da486be60f6ab955d17115523dd1d255d5"
lodash.debounce@^4.0.4:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
lodash.deburr@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-3.2.0.tgz#6da8f54334a366a7cf4c4c76ef8d80aa1b365ed5"
@ -5947,9 +6035,9 @@ nopt@~1.0.10:
dependencies:
abbrev "1"
normalize-git-url@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/normalize-git-url/-/normalize-git-url-1.0.1.tgz#1b561345d66e3a3bc5513a5ace85f155ca42613e"
normalize-git-url@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/normalize-git-url/-/normalize-git-url-3.0.2.tgz#8e5f14be0bdaedb73e07200310aa416c27350fc4"
normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
version "2.3.5"
@ -6742,6 +6830,12 @@ pretty-hrtime@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.2.tgz#70ca96f4d0628a443b918758f79416a9a7bc9fa8"
prismjs@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.6.0.tgz#118d95fb7a66dba2272e343b345f5236659db365"
optionalDependencies:
clipboard "^1.5.5"
private@^0.1.6:
version "0.1.6"
resolved "https://registry.yarnpkg.com/private/-/private-0.1.6.tgz#55c6a976d0f9bafb9924851350fe47b9b5fbb7c1"
@ -6768,7 +6862,7 @@ promise@7.x, promise@^7.0.3, promise@^7.1.1:
dependencies:
asap "~2.0.3"
prop-types@^15.5.4:
prop-types@^15.5.4, prop-types@^15.5.8:
version "15.5.8"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.8.tgz#6b7b2e141083be38c8595aa51fc55775c7199394"
dependencies:
@ -6921,19 +7015,11 @@ react-addons-test-utils@^15.3.1:
version "15.3.2"
resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6"
react-codemirror@0.2.6:
version "0.2.6"
resolved "https://registry.yarnpkg.com/react-codemirror/-/react-codemirror-0.2.6.tgz#e71e35717ce6effae68df1dbf2b5a75b84a44f84"
dependencies:
classnames "^2.2.3"
codemirror "^5.13.4"
lodash.debounce "^4.0.4"
react-deep-force-update@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.0.1.tgz#f911b5be1d2a6fe387507dd6e9a767aa2924b4c7"
react-dom@15.3.2, "react-dom@>= 0.14.0", "react-dom@^0.14.0 || ^15.0.0":
react-dom@15.3.2, "react-dom@^0.14.0 || ^15.0.0":
version "15.3.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.2.tgz#c46b0aa5380d7b838e7a59c4a7beff2ed315531f"
@ -6953,12 +7039,15 @@ react-helmet@3.1.0:
shallowequal "0.2.2"
warning "2.1.0"
react-keydown@^1.6.1:
version "1.6.2"
resolved "https://registry.yarnpkg.com/react-keydown/-/react-keydown-1.6.2.tgz#a90b7fd2492f926e43500415ecc2fd091aaf2b76"
react-keydown@^1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/react-keydown/-/react-keydown-1.7.3.tgz#51262d5e6e5ce5909e0279783e607bd5a6cc480c"
react-portal@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.1.0.tgz#865c44fb72a1da106c649206936559ce891ee899"
dependencies:
react ">= 0.14.0"
react-dom ">= 0.14.0"
prop-types "^15.5.8"
react-proxy@^1.1.7:
version "1.1.8"
@ -6998,7 +7087,7 @@ react-transform-hmr@^1.0.3:
global "^4.3.0"
react-proxy "^1.1.7"
react@15.3.2, "react@>= 0.14.0":
react@15.3.2:
version "15.3.2"
resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e"
dependencies:
@ -7447,12 +7536,12 @@ safe-buffer@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
safestart@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/safestart/-/safestart-0.8.0.tgz#f6716cb863afa54db7fb2169c29ce85e30b5654d"
safestart@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/safestart/-/safestart-1.1.0.tgz#a72880d28460c8b8211ccee83a7e0d542628b5dd"
dependencies:
normalize-git-url "1.0.1"
semver "4.2.0"
normalize-git-url "3.0.2"
semver "5.3.0"
sane@~1.4.1:
version "1.4.1"
@ -7493,20 +7582,24 @@ sax@^1.1.4, sax@~1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
select@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
selection-is-backward@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/selection-is-backward/-/selection-is-backward-1.0.0.tgz#97a54633188a511aba6419fc5c1fa91b467e6be1"
semver-diff@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
dependencies:
semver "^5.0.3"
"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.0.1, semver@^5.0.3, semver@^5.1.0, semver@~5.3.0:
"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@5.3.0, semver@^5.0.1, semver@^5.0.3, semver@^5.1.0, semver@~5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
semver@4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-4.2.0.tgz#a571fd4adbe974fe32bd9cb4c5e249606f498423"
semver@4.3.2, semver@^4.1.0:
version "4.3.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7"
@ -7676,6 +7769,78 @@ slash@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
slate-collapse-on-escape@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/slate-collapse-on-escape/-/slate-collapse-on-escape-0.2.1.tgz#988f474439f0a21f94cc0016da52ea3c1a061100"
dependencies:
to-pascal-case "^1.0.0"
slate-drop-or-paste-images@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/slate-drop-or-paste-images/-/slate-drop-or-paste-images-0.5.0.tgz#c90367f9612f75abae0d1d6b8b2008108da02598"
dependencies:
data-uri-to-blob "0.0.4"
es6-promise "^4.0.5"
image-to-data-uri "^1.0.0"
is-data-uri "^0.1.0"
is-image "^1.0.1"
is-url "^1.2.2"
mime-types "^2.1.11"
slate-edit-code@^0.10.2:
version "0.10.2"
resolved "https://registry.yarnpkg.com/slate-edit-code/-/slate-edit-code-0.10.2.tgz#17168ecca95cbd8b14e3259a970bcd1b2c730df2"
dependencies:
detect-indent "^4.0.0"
detect-newline "^2.1.0"
ends-with "^0.2.0"
immutable "^3.8.1"
slate-edit-list@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/slate-edit-list/-/slate-edit-list-0.7.0.tgz#dd7eb44085f54e7b6709f82c1713be9b0c14ea56"
slate-markdown-serializer@tommoor/slate-markdown-serializer:
version "0.4.1"
resolved "https://codeload.github.com/tommoor/slate-markdown-serializer/tar.gz/614598153c0bfdc2426946b2b193bfcb2b3f1ed8"
slate-paste-linkify@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/slate-paste-linkify/-/slate-paste-linkify-0.2.1.tgz#4647b5207b910d2d084f7d5d256384869b0a9c75"
dependencies:
is-url "^1.2.2"
to-pascal-case "^1.0.0"
slate-prism@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/slate-prism/-/slate-prism-0.2.2.tgz#0c9d5c2bee0e94a6df5fc564b7a99f6b8e1ea492"
dependencies:
prismjs "^1.6.0"
slate-trailing-block@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/slate-trailing-block/-/slate-trailing-block-0.2.4.tgz#6ce9525fa15f9f098d810d9312a4267799cd0e12"
slate@^0.19.30:
version "0.19.30"
resolved "https://registry.yarnpkg.com/slate/-/slate-0.19.30.tgz#6c28a7ef53fd48d445c13c6037d39b96efacc22a"
dependencies:
cheerio "^0.22.0"
debug "^2.3.2"
direction "^0.1.5"
es6-map "^0.1.4"
esrever "^0.2.0"
get-window "^1.1.1"
immutable "^3.8.1"
is-empty "^1.0.0"
is-in-browser "^1.1.3"
is-window "^1.0.2"
keycode "^2.1.2"
prop-types "^15.5.8"
react-portal "^3.1.0"
selection-is-backward "^1.0.0"
type-of "^2.0.1"
slice-ansi@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
@ -8117,6 +8282,10 @@ timers-ext@0.1:
es5-ext "~0.10.2"
next-tick "~0.2.2"
tiny-emitter@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-1.2.0.tgz#6dc845052cb08ebefc1874723b58f24a648c3b6f"
title-case@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/title-case/-/title-case-2.1.0.tgz#c68ccb4232079ded64f94b91b4941ade91391979"
@ -8132,6 +8301,22 @@ to-fast-properties@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.2.tgz#f3f5c0c3ba7299a7ef99427e44633257ade43320"
to-no-case@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a"
to-pascal-case@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/to-pascal-case/-/to-pascal-case-1.0.0.tgz#0bbdc8df448886ba01535e543327048d0aa1ce78"
dependencies:
to-space-case "^1.0.0"
to-space-case@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17"
dependencies:
to-no-case "^1.0.0"
topo@1.x.x:
version "1.1.0"
resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5"
@ -8217,6 +8402,10 @@ type-is@^1.5.5, type-is@~1.6.6:
media-typer "0.3.0"
mime-types "~2.1.11"
type-of@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/type-of/-/type-of-2.0.1.tgz#e72a1741896568e9f628378d816d6912f7f23972"
typedarray@^0.0.6, typedarray@~0.0.5:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"