diff --git a/frontend/components/Icon/BoldIcon.js b/frontend/components/Icon/BoldIcon.js new file mode 100644 index 00000000..011686bd --- /dev/null +++ b/frontend/components/Icon/BoldIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/BulletedListIcon.js b/frontend/components/Icon/BulletedListIcon.js new file mode 100644 index 00000000..b87afa19 --- /dev/null +++ b/frontend/components/Icon/BulletedListIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/CloseIcon.js b/frontend/components/Icon/CloseIcon.js new file mode 100644 index 00000000..c93cc6bd --- /dev/null +++ b/frontend/components/Icon/CloseIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/CodeIcon.js b/frontend/components/Icon/CodeIcon.js new file mode 100644 index 00000000..1116cb68 --- /dev/null +++ b/frontend/components/Icon/CodeIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/Heading1Icon.js b/frontend/components/Icon/Heading1Icon.js new file mode 100644 index 00000000..1dc44054 --- /dev/null +++ b/frontend/components/Icon/Heading1Icon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/Heading2Icon.js b/frontend/components/Icon/Heading2Icon.js new file mode 100644 index 00000000..46b11590 --- /dev/null +++ b/frontend/components/Icon/Heading2Icon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/Icon.js b/frontend/components/Icon/Icon.js new file mode 100644 index 00000000..11e26a2d --- /dev/null +++ b/frontend/components/Icon/Icon.js @@ -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, +}; + +export default function Icon({ children, ...rest }: Props & BaseProps) { + return ( + + {children} + + ); +} + +const Wrapper = styled.span` + svg { + fill: ${props => (props.light ? '#fff' : '#000')}; + } +`; diff --git a/frontend/components/Icon/Icon.scss b/frontend/components/Icon/Icon.scss new file mode 100644 index 00000000..7fd44174 --- /dev/null +++ b/frontend/components/Icon/Icon.scss @@ -0,0 +1,9 @@ +.icon { + +} + +.light { + svg { + fill: #fff; + } +} diff --git a/frontend/components/Icon/ItalicIcon.js b/frontend/components/Icon/ItalicIcon.js new file mode 100644 index 00000000..9b194b6b --- /dev/null +++ b/frontend/components/Icon/ItalicIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/LinkIcon.js b/frontend/components/Icon/LinkIcon.js new file mode 100644 index 00000000..5f4c80e2 --- /dev/null +++ b/frontend/components/Icon/LinkIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/OrderedListIcon.js b/frontend/components/Icon/OrderedListIcon.js new file mode 100644 index 00000000..89e51f22 --- /dev/null +++ b/frontend/components/Icon/OrderedListIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/QuoteIcon.js b/frontend/components/Icon/QuoteIcon.js new file mode 100644 index 00000000..6b80497a --- /dev/null +++ b/frontend/components/Icon/QuoteIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/StrikethroughIcon.js b/frontend/components/Icon/StrikethroughIcon.js new file mode 100644 index 00000000..27c8d1b2 --- /dev/null +++ b/frontend/components/Icon/StrikethroughIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/UnderlinedIcon.js b/frontend/components/Icon/UnderlinedIcon.js new file mode 100644 index 00000000..14379e98 --- /dev/null +++ b/frontend/components/Icon/UnderlinedIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Icon/index.js b/frontend/components/Icon/index.js new file mode 100644 index 00000000..8e7b103e --- /dev/null +++ b/frontend/components/Icon/index.js @@ -0,0 +1,3 @@ +// @flow +import Icon from './Icon'; +export default Icon; diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index 290d598a..4c204c95 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -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 (/)" > - Search + Search } }> diff --git a/frontend/components/MarkdownEditor/MarkdownEditor.js b/frontend/components/MarkdownEditor/MarkdownEditor.js deleted file mode 100644 index 2b670eba..00000000 --- a/frontend/components/MarkdownEditor/MarkdownEditor.js +++ /dev/null @@ -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 ( - - - - - - ); - }; -} - -export default MarkdownEditor; diff --git a/frontend/components/MarkdownEditor/MarkdownEditor.scss b/frontend/components/MarkdownEditor/MarkdownEditor.scss deleted file mode 100644 index 70216e31..00000000 --- a/frontend/components/MarkdownEditor/MarkdownEditor.scss +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/frontend/components/MarkdownEditor/codemirror.scss b/frontend/components/MarkdownEditor/codemirror.scss deleted file mode 100644 index e28d71e5..00000000 --- a/frontend/components/MarkdownEditor/codemirror.scss +++ /dev/null @@ -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; } -} diff --git a/frontend/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.js b/frontend/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.js deleted file mode 100644 index d4464f51..00000000 --- a/frontend/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.js +++ /dev/null @@ -1,14 +0,0 @@ -// @flow -import React from 'react'; - -import styles from './ClickablePadding.scss'; - -const ClickablePadding = (props: { onClick: Function }) => { - return
 
; -}; - -ClickablePadding.propTypes = { - onClick: React.PropTypes.func, -}; - -export default ClickablePadding; diff --git a/frontend/components/MarkdownEditor/index.js b/frontend/components/MarkdownEditor/index.js deleted file mode 100644 index cdd528e4..00000000 --- a/frontend/components/MarkdownEditor/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import MarkdownEditor from './MarkdownEditor'; -export default MarkdownEditor; diff --git a/frontend/scenes/DocumentScene/components/Sidebar/Sidebar.js b/frontend/components/Sidebar/Sidebar.js similarity index 100% rename from frontend/scenes/DocumentScene/components/Sidebar/Sidebar.js rename to frontend/components/Sidebar/Sidebar.js diff --git a/frontend/scenes/DocumentScene/components/Sidebar/Sidebar.scss b/frontend/components/Sidebar/Sidebar.scss similarity index 100% rename from frontend/scenes/DocumentScene/components/Sidebar/Sidebar.scss rename to frontend/components/Sidebar/Sidebar.scss diff --git a/frontend/scenes/DocumentScene/components/Sidebar/SidebarStore.js b/frontend/components/Sidebar/SidebarStore.js similarity index 100% rename from frontend/scenes/DocumentScene/components/Sidebar/SidebarStore.js rename to frontend/components/Sidebar/SidebarStore.js diff --git a/frontend/scenes/DocumentScene/components/Sidebar/components/Separator/Separator.js b/frontend/components/Sidebar/components/Separator/Separator.js similarity index 100% rename from frontend/scenes/DocumentScene/components/Sidebar/components/Separator/Separator.js rename to frontend/components/Sidebar/components/Separator/Separator.js diff --git a/frontend/scenes/DocumentScene/components/Sidebar/components/Separator/Separator.scss b/frontend/components/Sidebar/components/Separator/Separator.scss similarity index 100% rename from frontend/scenes/DocumentScene/components/Sidebar/components/Separator/Separator.scss rename to frontend/components/Sidebar/components/Separator/Separator.scss diff --git a/frontend/scenes/DocumentScene/components/Sidebar/components/Separator/index.js b/frontend/components/Sidebar/components/Separator/index.js similarity index 100% rename from frontend/scenes/DocumentScene/components/Sidebar/components/Separator/index.js rename to frontend/components/Sidebar/components/Separator/index.js diff --git a/frontend/scenes/DocumentScene/components/Sidebar/index.js b/frontend/components/Sidebar/index.js similarity index 100% rename from frontend/scenes/DocumentScene/components/Sidebar/index.js rename to frontend/components/Sidebar/index.js diff --git a/frontend/components/Tree/Node.js b/frontend/components/Tree/Node.js index d8fe634e..6b06f3c7 100644 --- a/frontend/components/Tree/Node.js +++ b/frontend/components/Tree/Node.js @@ -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); diff --git a/frontend/index.js b/frontend/index.js index 1546436c..2c4aff64 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -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( /> - + diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js new file mode 100644 index 00000000..2eea552b --- /dev/null +++ b/frontend/scenes/Document/Document.js @@ -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 = ( + + ); + const titleText = `${get(this.store, 'document.collection.name')} - ${get(this.store, 'document.title')}`; + + const actions = ( + + + {isEditing + ? + : Edit} + + + + ); + + return ( + + {this.store.isFetching && + + + } + {this.store.document && + } + + ); + } +} + +export default Document; diff --git a/frontend/scenes/Document/Document.scss b/frontend/scenes/Document/Document.scss new file mode 100644 index 00000000..c31a12f3 --- /dev/null +++ b/frontend/scenes/Document/Document.scss @@ -0,0 +1,11 @@ +@import '~styles/constants.scss'; + +.container { + display: flex; + position: fixed; + justify-content: center; + top: $headerHeight; + bottom: 0; + left: 0; + right: 0; +} diff --git a/frontend/scenes/Document/DocumentStore.js b/frontend/scenes/Document/DocumentStore.js new file mode 100644 index 00000000..608331c3 --- /dev/null +++ b/frontend/scenes/Document/DocumentStore.js @@ -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 { + 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; diff --git a/frontend/scenes/DocumentScene/components/Breadcrumbs/Breadcrumbs.js b/frontend/scenes/Document/components/Breadcrumbs/Breadcrumbs.js similarity index 76% rename from frontend/scenes/DocumentScene/components/Breadcrumbs/Breadcrumbs.js rename to frontend/scenes/Document/components/Breadcrumbs/Breadcrumbs.js index adec66f1..6e1f9dbd 100644 --- a/frontend/scenes/DocumentScene/components/Breadcrumbs/Breadcrumbs.js +++ b/frontend/scenes/Document/components/Breadcrumbs/Breadcrumbs.js @@ -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, }; -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) => { ); } + return null; }; export default Breadcrumbs; diff --git a/frontend/scenes/DocumentScene/components/Breadcrumbs/index.js b/frontend/scenes/Document/components/Breadcrumbs/index.js similarity index 100% rename from frontend/scenes/DocumentScene/components/Breadcrumbs/index.js rename to frontend/scenes/Document/components/Breadcrumbs/index.js diff --git a/frontend/scenes/Document/components/Editor/Editor.js b/frontend/scenes/Document/components/Editor/Editor.js new file mode 100644 index 00000000..f863ad2e --- /dev/null +++ b/frontend/scenes/Document/components/Editor/Editor.js @@ -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; + + 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 ( + + + + (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} + /> + + + ); + }; +} diff --git a/frontend/scenes/Document/components/Editor/Editor.scss b/frontend/scenes/Document/components/Editor/Editor.scss new file mode 100644 index 00000000..3949ebe1 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/Editor.scss @@ -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; + } +} diff --git a/frontend/scenes/Document/components/Editor/components/ClickablePadding/ClickablePadding.js b/frontend/scenes/Document/components/Editor/components/ClickablePadding/ClickablePadding.js new file mode 100644 index 00000000..970d0619 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/ClickablePadding/ClickablePadding.js @@ -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 ( +
+ ); +}; + +export default ClickablePadding; diff --git a/frontend/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.scss b/frontend/scenes/Document/components/Editor/components/ClickablePadding/ClickablePadding.scss similarity index 83% rename from frontend/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.scss rename to frontend/scenes/Document/components/Editor/components/ClickablePadding/ClickablePadding.scss index 7d9c74b4..f8728612 100644 --- a/frontend/components/MarkdownEditor/components/ClickablePadding/ClickablePadding.scss +++ b/frontend/scenes/Document/components/Editor/components/ClickablePadding/ClickablePadding.scss @@ -3,6 +3,10 @@ cursor: text; } +.grow { + flex-grow: 1; +} + @media all and (max-width: 960px) { .container { padding-top: 50px; diff --git a/frontend/components/MarkdownEditor/components/ClickablePadding/index.js b/frontend/scenes/Document/components/Editor/components/ClickablePadding/index.js similarity index 100% rename from frontend/components/MarkdownEditor/components/ClickablePadding/index.js rename to frontend/scenes/Document/components/Editor/components/ClickablePadding/index.js diff --git a/frontend/scenes/Document/components/Editor/components/Code.js b/frontend/scenes/Document/components/Editor/components/Code.js new file mode 100644 index 00000000..5ee78600 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Code.js @@ -0,0 +1,13 @@ +// @flow +import React from 'react'; +import type { Props } from '../types'; + +export default function Code({ children, attributes }: Props) { + return ( +
+      
+        {children}
+      
+    
+ ); +} diff --git a/frontend/scenes/Document/components/Editor/components/Heading.js b/frontend/scenes/Document/components/Editor/components/Heading.js new file mode 100644 index 00000000..15acb522 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Heading.js @@ -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, + 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 ( + + {children} + {showPlaceholder && + + {editor.props.placeholder} + } + {slugish && + #} + + ); +} diff --git a/frontend/scenes/Document/components/Editor/components/Image.js b/frontend/scenes/Document/components/Editor/components/Image.js new file mode 100644 index 00000000..d7841f37 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Image.js @@ -0,0 +1,13 @@ +// @flow +import React from 'react'; +import type { Props } from '../types'; + +export default function Image({ attributes, node }: Props) { + return ( + {node.data.get('alt')} + ); +} diff --git a/frontend/scenes/Document/components/Editor/components/Link.js b/frontend/scenes/Document/components/Editor/components/Link.js new file mode 100644 index 00000000..5cd0e03c --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Link.js @@ -0,0 +1,11 @@ +// @flow +import React from 'react'; +import type { Props } from '../types'; + +export default function Link({ attributes, node, children }: Props) { + return ( + + {children} + + ); +} diff --git a/frontend/scenes/Document/components/Editor/components/ListItem.js b/frontend/scenes/Document/components/Editor/components/ListItem.js new file mode 100644 index 00000000..11227c2d --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/ListItem.js @@ -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 ( + + {children} + + ); + } + return
  • {children}
  • ; +} diff --git a/frontend/scenes/Document/components/Editor/components/TodoItem.js b/frontend/scenes/Document/components/Editor/components/TodoItem.js new file mode 100644 index 00000000..13dfd365 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/TodoItem.js @@ -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 ( +
  • + + {' '} + + {children} + +
  • + ); + } +} diff --git a/frontend/scenes/Document/components/Editor/components/Toolbar/Toolbar.js b/frontend/scenes/Document/components/Editor/components/Toolbar/Toolbar.js new file mode 100644 index 00000000..4d2480b2 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Toolbar/Toolbar.js @@ -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, + 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 ( + +
    + {link && + } + {!link && + } +
    +
    + ); + } +} diff --git a/frontend/scenes/Document/components/Editor/components/Toolbar/Toolbar.scss b/frontend/scenes/Document/components/Editor/components/Toolbar/Toolbar.scss new file mode 100644 index 00000000..c433f5c6 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Toolbar/Toolbar.scss @@ -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; + } +} diff --git a/frontend/scenes/Document/components/Editor/components/Toolbar/components/FormattingToolbar.js b/frontend/scenes/Document/components/Editor/components/Toolbar/components/FormattingToolbar.js new file mode 100644 index 00000000..8b31506c --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Toolbar/components/FormattingToolbar.js @@ -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 ( + + ); + }; + + renderBlockButton = (type: string, IconClass: Function) => { + const isActive = this.isBlock(type); + const onMouseDown = ev => + this.onClickBlock(ev, isActive ? 'paragraph' : type); + + return ( + + ); + }; + + render() { + return ( + + {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)} + + + ); + } +} diff --git a/frontend/scenes/Document/components/Editor/components/Toolbar/components/LinkToolbar.js b/frontend/scenes/Document/components/Editor/components/Toolbar/components/LinkToolbar.js new file mode 100644 index 00000000..c02f727f --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Toolbar/components/LinkToolbar.js @@ -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 ( + + (this.input = ref)} + defaultValue={href} + placeholder="http://" + onBlur={this.props.onBlur} + onKeyDown={this.onKeyDown} + autoFocus + /> + + + ); + } +} diff --git a/frontend/scenes/Document/components/Editor/components/Toolbar/index.js b/frontend/scenes/Document/components/Editor/components/Toolbar/index.js new file mode 100644 index 00000000..8c7e3c39 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/components/Toolbar/index.js @@ -0,0 +1,3 @@ +// @flow +import Toolbar from './Toolbar'; +export default Toolbar; diff --git a/frontend/scenes/Document/components/Editor/index.js b/frontend/scenes/Document/components/Editor/index.js new file mode 100644 index 00000000..223d4abd --- /dev/null +++ b/frontend/scenes/Document/components/Editor/index.js @@ -0,0 +1,3 @@ +// @flow +import Editor from './Editor'; +export default Editor; diff --git a/frontend/scenes/Document/components/Editor/plugins.js b/frontend/scenes/Document/components/Editor/plugins.js new file mode 100644 index 00000000..cbdecbe7 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/plugins.js @@ -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; diff --git a/frontend/scenes/Document/components/Editor/plugins/KeyboardShortcuts.js b/frontend/scenes/Document/components/Editor/plugins/KeyboardShortcuts.js new file mode 100644 index 00000000..e9e15065 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/plugins/KeyboardShortcuts.js @@ -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; + }, + }; +} diff --git a/frontend/scenes/Document/components/Editor/plugins/MarkdownShortcuts.js b/frontend/scenes/Document/components/Editor/plugins/MarkdownShortcuts.js new file mode 100644 index 00000000..745bea0d --- /dev/null +++ b/frontend/scenes/Document/components/Editor/plugins/MarkdownShortcuts.js @@ -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; + } + }, + }; +} diff --git a/frontend/scenes/Document/components/Editor/schema.js b/frontend/scenes/Document/components/Editor/schema.js new file mode 100644 index 00000000..0db665d9 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/schema.js @@ -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) => {props.children}, + code: (props: Props) => {props.children}, + italic: (props: Props) => {props.children}, + underlined: (props: Props) => {props.children}, + deleted: (props: Props) => {props.children}, + added: (props: Props) => {props.children}, + }, + + nodes: { + paragraph: (props: Props) =>

    {props.children}

    , + 'block-quote': (props: Props) =>
    {props.children}
    , + 'horizontal-rule': (props: Props) =>
    , + 'bulleted-list': (props: Props) =>
      {props.children}
    , + 'ordered-list': (props: Props) =>
      {props.children}
    , + 'todo-list': (props: Props) => ( +
      {props.children}
    + ), + table: (props: Props) => {props.children}
    , + 'table-row': (props: Props) => {props.children}, + 'table-head': (props: Props) => {props.children}, + 'table-cell': (props: Props) => {props.children}, + code: Code, + image: Image, + link: Link, + 'list-item': ListItem, + heading1: (props: Props) => , + heading2: (props: Props) => , + heading3: (props: Props) => , + heading4: (props: Props) => , + heading5: (props: Props) => , + heading6: (props: 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; diff --git a/frontend/scenes/Document/components/Editor/serializer.js b/frontend/scenes/Document/components/Editor/serializer.js new file mode 100644 index 00000000..26285dd2 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/serializer.js @@ -0,0 +1,3 @@ +// @flow +import MarkdownSerializer from 'slate-markdown-serializer'; +export default new MarkdownSerializer(); diff --git a/frontend/scenes/Document/components/Editor/types.js b/frontend/scenes/Document/components/Editor/types.js new file mode 100644 index 00000000..79cad5d4 --- /dev/null +++ b/frontend/scenes/Document/components/Editor/types.js @@ -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, + 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, + nodes: List, + 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, +}; + +export type State = { + document: Document, + selection: Selection, + startBlock: Block, + endBlock: Block, + startText: Node, + endText: Node, + marks: Set<*>, + blocks: List, + fragment: Document, + lines: List, + tests: List, + startBlock: Block, + transform: Function, + isBlurred: Function, +}; diff --git a/frontend/scenes/Document/components/Menu.js b/frontend/scenes/Document/components/Menu.js new file mode 100644 index 00000000..665a941c --- /dev/null +++ b/frontend/scenes/Document/components/Menu.js @@ -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 ( + }> + {collection && +
    + + New document + + New child +
    } + Export + {allowDelete && Delete} +
    + ); + } +} + +export default Menu; diff --git a/frontend/scenes/Document/index.js b/frontend/scenes/Document/index.js new file mode 100644 index 00000000..8ec6fd12 --- /dev/null +++ b/frontend/scenes/Document/index.js @@ -0,0 +1,3 @@ +// @flow +import Document from './Document'; +export default Document; diff --git a/frontend/scenes/DocumentEdit/DocumentEdit.js b/frontend/scenes/DocumentEdit/DocumentEdit.js deleted file mode 100644 index 8a85fb7b..00000000 --- a/frontend/scenes/DocumentEdit/DocumentEdit.js +++ /dev/null @@ -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 = ( - - ); - - 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; diff --git a/frontend/scenes/DocumentEdit/DocumentEdit.scss b/frontend/scenes/DocumentEdit/DocumentEdit.scss deleted file mode 100644 index a068d1c6..00000000 --- a/frontend/scenes/DocumentEdit/DocumentEdit.scss +++ /dev/null @@ -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; - } -} diff --git a/frontend/scenes/DocumentEdit/DocumentEditStore.js b/frontend/scenes/DocumentEdit/DocumentEditStore.js deleted file mode 100644 index b5d13d4e..00000000 --- a/frontend/scenes/DocumentEdit/DocumentEditStore.js +++ /dev/null @@ -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 }; diff --git a/frontend/scenes/DocumentEdit/components/Editor.js b/frontend/scenes/DocumentEdit/components/Editor.js deleted file mode 100644 index 5634a8ff..00000000 --- a/frontend/scenes/DocumentEdit/components/Editor.js +++ /dev/null @@ -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; diff --git a/frontend/scenes/DocumentEdit/components/EditorLoader.js b/frontend/scenes/DocumentEdit/components/EditorLoader.js deleted file mode 100644 index 4305c2dc..00000000 --- a/frontend/scenes/DocumentEdit/components/EditorLoader.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow -export default () => { - return new Promise(resolve => { - // $FlowIssue this is available with webpack - require.ensure([], () => { - resolve({ - Editor: require('./Editor').default, - }); - }); - }); -}; diff --git a/frontend/scenes/DocumentEdit/components/EditorPane.js b/frontend/scenes/DocumentEdit/components/EditorPane.js deleted file mode 100644 index f001e2cc..00000000 --- a/frontend/scenes/DocumentEdit/components/EditorPane.js +++ /dev/null @@ -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; diff --git a/frontend/scenes/DocumentEdit/components/Preview/Preview.js b/frontend/scenes/DocumentEdit/components/Preview/Preview.js deleted file mode 100644 index 1d5168be..00000000 --- a/frontend/scenes/DocumentEdit/components/Preview/Preview.js +++ /dev/null @@ -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; diff --git a/frontend/scenes/DocumentEdit/components/Preview/Preview.scss b/frontend/scenes/DocumentEdit/components/Preview/Preview.scss deleted file mode 100644 index 6360c0e1..00000000 --- a/frontend/scenes/DocumentEdit/components/Preview/Preview.scss +++ /dev/null @@ -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; - } -} diff --git a/frontend/scenes/DocumentEdit/components/Preview/index.js b/frontend/scenes/DocumentEdit/components/Preview/index.js deleted file mode 100644 index f7604c3c..00000000 --- a/frontend/scenes/DocumentEdit/components/Preview/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Preview from './Preview'; -export default Preview; diff --git a/frontend/scenes/DocumentEdit/index.js b/frontend/scenes/DocumentEdit/index.js deleted file mode 100644 index 051a2430..00000000 --- a/frontend/scenes/DocumentEdit/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentEdit from './DocumentEdit'; -export default DocumentEdit; diff --git a/frontend/scenes/DocumentScene/DocumentScene.js b/frontend/scenes/DocumentScene/DocumentScene.js deleted file mode 100644 index 09021b02..00000000 --- a/frontend/scenes/DocumentScene/DocumentScene.js +++ /dev/null @@ -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; diff --git a/frontend/scenes/DocumentScene/DocumentScene.scss b/frontend/scenes/DocumentScene/DocumentScene.scss deleted file mode 100644 index d25774a1..00000000 --- a/frontend/scenes/DocumentScene/DocumentScene.scss +++ /dev/null @@ -1,13 +0,0 @@ -.actions { - display: flex; - flex-direction: row; -} - -.content { - position: relative; - overflow: scroll; -} - -.menuGroup { - border-bottom: 1px solid #eee; -} diff --git a/frontend/scenes/DocumentScene/DocumentSceneStore.js b/frontend/scenes/DocumentScene/DocumentSceneStore.js deleted file mode 100644 index 40ee36e6..00000000 --- a/frontend/scenes/DocumentScene/DocumentSceneStore.js +++ /dev/null @@ -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 }; diff --git a/frontend/scenes/DocumentScene/index.js b/frontend/scenes/DocumentScene/index.js deleted file mode 100644 index 54afd750..00000000 --- a/frontend/scenes/DocumentScene/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentScene from './DocumentScene'; -export default DocumentScene; diff --git a/frontend/scenes/Search/SearchStore.js b/frontend/scenes/Search/SearchStore.js index 99eeed53..b77a2934 100644 --- a/frontend/scenes/Search/SearchStore.js +++ b/frontend/scenes/Search/SearchStore.js @@ -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[]); diff --git a/frontend/scenes/Settings/SettingsStore.js b/frontend/scenes/Settings/SettingsStore.js index 317874e2..599f84a0 100644 --- a/frontend/scenes/Settings/SettingsStore.js +++ b/frontend/scenes/Settings/SettingsStore.js @@ -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); diff --git a/frontend/styles/base.scss b/frontend/styles/base.scss index 8bf0740a..10cd2225 100644 --- a/frontend/styles/base.scss +++ b/frontend/styles/base.scss @@ -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; diff --git a/frontend/styles/codemirror.scss b/frontend/styles/codemirror.scss deleted file mode 100644 index 57369455..00000000 --- a/frontend/styles/codemirror.scss +++ /dev/null @@ -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; } -} diff --git a/frontend/styles/prism-tomorrow.scss b/frontend/styles/prism-tomorrow.scss new file mode 100644 index 00000000..bdee94bb --- /dev/null +++ b/frontend/styles/prism-tomorrow.scss @@ -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; + } +} diff --git a/frontend/utils/ApiClient.js b/frontend/utils/ApiClient.js index a09b2633..841979a9 100644 --- a/frontend/utils/ApiClient.js +++ b/frontend/utils/ApiClient.js @@ -103,7 +103,6 @@ class ApiClient { }; // Helpers - constructQueryString = (data: Object) => { return _.map(data, (v, k) => { return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`; diff --git a/frontend/utils/uploadFile.js b/frontend/utils/uploadFile.js new file mode 100644 index 00000000..7b53e39e --- /dev/null +++ b/frontend/utils/uploadFile.js @@ -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; +} diff --git a/init.js b/init.js index c8624112..2905afe7 100644 --- a/init.js +++ b/init.js @@ -1,4 +1,6 @@ -require('safestart')(__dirname); +require('safestart')(__dirname, { + exclude: ['slate-markdown-serializer'], +}); require('babel-core/register'); require('babel-polyfill'); require('localenv'); diff --git a/package.json b/package.json index abc34f4e..da27c903 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 40354e2b..e55054f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"