Slate editor (#38)
* WIP: Slate editor
* WIP
* Focus at start / end working
* ah ha
* Super basic floating toolbar
* Nested list editing
* Pulling more logic into plugins
* inline code markdown
* Backspace at end of code block should remove mark
* Ensure there is always an empty line at editor end
* Add keyboard shortcuts for bold, italic, underline
* Add strikethrough shortcode and toolbar
* Toolbar to declarative
Fixed paragraph styling
Removed unused stuffs
* Super basic link editing
* Split Toolbar, now possible to edit and remove links
* Add new link to selection from toolbar working
* Ensure toolbar doesn't extend off screen
* Fix minor js issues, disable formatting of document title
* Boom, icons
* Remove codemirror, fix MD parsing issues
* CMD+S now saves inplace
* Add --- shortcut for horizontal rule
* Improved styling for link editor
* Add header anchors in readOnly
* More readable core text color
* Restored image file uploading 🎉
* Add support for inline md syntax, ** __ etc
* Centered
* Flooooow
* Checklist support
* Upgrade edit list plugin
* Finally. Allow keydown within rich textarea
* Update Markdown serializer
* Cleanup, remove async editor loading
* Editor > MarkdownEditor
Fixed unsaved changes warning triggered when all changes are saved
* MOAR typing
* Combine edit and view
* Fixed checkboxes still editable in readOnly
* wip
* Breadcrumb
Restored scroll
* Move document scene actions to menu
* Added: Support for code blocks, syntax highlighting
* Cleanup
* > styled component
* Prevent CMD+Enter from adding linebreak
* Show image uploading in layout activity indicator
* Upgrade editor deps
* Improve link toolbar. Only one scenario where it's not working now
This commit is contained in:
parent
7b16d3e5e2
commit
ff17047791
21
frontend/components/Icon/BoldIcon.js
Normal file
21
frontend/components/Icon/BoldIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function BoldIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/BulletedListIcon.js
Normal file
21
frontend/components/Icon/BulletedListIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function BulletedListIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/CloseIcon.js
Normal file
21
frontend/components/Icon/CloseIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function CloseIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/CodeIcon.js
Normal file
21
frontend/components/Icon/CodeIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function CodeIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/Heading1Icon.js
Normal file
21
frontend/components/Icon/Heading1Icon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function Heading1Icon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14h-2V9h-2V7h4v10z" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/Heading2Icon.js
Normal file
21
frontend/components/Icon/Heading2Icon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function Heading2Icon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 8c0 1.11-.9 2-2 2h-2v2h4v2H9v-4c0-1.11.9-2 2-2h2V9H9V7h4c1.1 0 2 .89 2 2v2z" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
26
frontend/components/Icon/Icon.js
Normal file
26
frontend/components/Icon/Icon.js
Normal file
@ -0,0 +1,26 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export type Props = {
|
||||
className?: string,
|
||||
light?: boolean,
|
||||
};
|
||||
|
||||
type BaseProps = {
|
||||
children?: React$Element<any>,
|
||||
};
|
||||
|
||||
export default function Icon({ children, ...rest }: Props & BaseProps) {
|
||||
return (
|
||||
<Wrapper {...rest}>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.span`
|
||||
svg {
|
||||
fill: ${props => (props.light ? '#fff' : '#000')};
|
||||
}
|
||||
`;
|
9
frontend/components/Icon/Icon.scss
Normal file
9
frontend/components/Icon/Icon.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.icon {
|
||||
|
||||
}
|
||||
|
||||
.light {
|
||||
svg {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
21
frontend/components/Icon/ItalicIcon.js
Normal file
21
frontend/components/Icon/ItalicIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function ItalicIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/LinkIcon.js
Normal file
21
frontend/components/Icon/LinkIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function LinkIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/OrderedListIcon.js
Normal file
21
frontend/components/Icon/OrderedListIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function OrderedListIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/QuoteIcon.js
Normal file
21
frontend/components/Icon/QuoteIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function QuoteIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/StrikethroughIcon.js
Normal file
21
frontend/components/Icon/StrikethroughIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function StrikethroughIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
21
frontend/components/Icon/UnderlinedIcon.js
Normal file
21
frontend/components/Icon/UnderlinedIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import type { Props } from './Icon';
|
||||
|
||||
export default function UnderlinedIcon(props: Props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" />
|
||||
</svg>
|
||||
</Icon>
|
||||
);
|
||||
}
|
3
frontend/components/Icon/index.js
Normal file
3
frontend/components/Icon/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Icon from './Icon';
|
||||
export default Icon;
|
@ -4,17 +4,16 @@ import { browserHistory, Link } from 'react-router';
|
||||
import Helmet from 'react-helmet';
|
||||
import styled from 'styled-components';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import keydown from 'react-keydown';
|
||||
import _ from 'lodash';
|
||||
import keydown from 'react-keydown';
|
||||
import classNames from 'classnames/bind';
|
||||
import searchIcon from 'assets/icons/search.svg';
|
||||
import { Flex } from 'reflexbox';
|
||||
|
||||
import styles from './Layout.scss';
|
||||
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
|
||||
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
import UserStore from 'stores/UserStore';
|
||||
|
||||
import styles from './Layout.scss';
|
||||
import classNames from 'classnames/bind';
|
||||
const cx = classNames.bind(styles);
|
||||
|
||||
type Props = {
|
||||
@ -89,10 +88,7 @@ type Props = {
|
||||
className={styles.search}
|
||||
title="Search (/)"
|
||||
>
|
||||
<img
|
||||
src={require('assets/icons/search.svg')}
|
||||
alt="Search"
|
||||
/>
|
||||
<img src={searchIcon} alt="Search" />
|
||||
</div>
|
||||
</Flex>}
|
||||
<DropdownMenu label={<Avatar src={user.user.avatarUrl} />}>
|
||||
|
@ -1,171 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import Codemirror from 'react-codemirror';
|
||||
import 'codemirror/mode/gfm/gfm';
|
||||
import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/addon/edit/continuelist';
|
||||
import 'codemirror/addon/display/placeholder.js';
|
||||
import Dropzone from 'react-dropzone';
|
||||
|
||||
import ClickablePadding from './components/ClickablePadding';
|
||||
|
||||
import styles from './MarkdownEditor.scss';
|
||||
import './codemirror.scss';
|
||||
|
||||
import { client } from 'utils/ApiClient';
|
||||
|
||||
@observer class MarkdownEditor extends React.Component {
|
||||
static propTypes = {
|
||||
text: React.PropTypes.string,
|
||||
onChange: React.PropTypes.func.isRequired,
|
||||
replaceText: React.PropTypes.func.isRequired,
|
||||
onSave: React.PropTypes.func.isRequired,
|
||||
onCancel: React.PropTypes.func.isRequired,
|
||||
|
||||
// This is actually not used but it triggers
|
||||
// re-render to help with CodeMirror focus issues
|
||||
preview: React.PropTypes.bool,
|
||||
toggleUploadingIndicator: React.PropTypes.func,
|
||||
};
|
||||
|
||||
onChange = (newText: string) => {
|
||||
if (newText !== this.props.text) {
|
||||
this.props.onChange(newText);
|
||||
}
|
||||
};
|
||||
|
||||
onDropAccepted = (files: Object[]) => {
|
||||
const file = files[0];
|
||||
const editor = this.getEditorInstance();
|
||||
|
||||
const cursorPosition = editor.getCursor();
|
||||
const insertOnNewLine = cursorPosition.ch !== 0;
|
||||
let newCursorPositionLine;
|
||||
|
||||
this.props.toggleUploadingIndicator();
|
||||
|
||||
// Lets set up the upload text
|
||||
const pendingUploadTag = `![${file.name}](Uploading...)`;
|
||||
if (insertOnNewLine) {
|
||||
editor.replaceSelection(`\n${pendingUploadTag}\n`);
|
||||
newCursorPositionLine = cursorPosition.line + 3;
|
||||
} else {
|
||||
editor.replaceSelection(`${pendingUploadTag}\n`);
|
||||
newCursorPositionLine = cursorPosition.line + 2;
|
||||
}
|
||||
editor.setCursor(newCursorPositionLine, 0);
|
||||
|
||||
client
|
||||
.post('/user.s3Upload', {
|
||||
kind: file.type,
|
||||
size: file.size,
|
||||
filename: file.name,
|
||||
})
|
||||
.then(response => {
|
||||
// $FlowFixMe need to augment ApiClient
|
||||
const data = response.data;
|
||||
// Upload using FormData API
|
||||
const formData = new FormData();
|
||||
|
||||
for (const key in data.form) {
|
||||
formData.append(key, data.form[key]);
|
||||
}
|
||||
|
||||
if (file.blob) {
|
||||
formData.append('file', file.file);
|
||||
} else {
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
fetch(data.uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(_s3Response => {
|
||||
this.props.toggleUploadingIndicator();
|
||||
this.props.replaceText({
|
||||
original: pendingUploadTag,
|
||||
new: `![${file.name}](${data.asset.url})`,
|
||||
});
|
||||
editor.setCursor(newCursorPositionLine, 0);
|
||||
})
|
||||
.catch(_err => {
|
||||
this.props.toggleUploadingIndicator();
|
||||
this.props.replaceText({
|
||||
original: pendingUploadTag,
|
||||
new: '',
|
||||
});
|
||||
editor.setCursor(newCursorPositionLine, 0);
|
||||
});
|
||||
})
|
||||
.catch(_err => {
|
||||
this.props.toggleUploadingIndicator();
|
||||
});
|
||||
};
|
||||
|
||||
onPaddingTopClick = () => {
|
||||
const cm = this.getEditorInstance();
|
||||
cm.setCursor(0, 0);
|
||||
cm.focus();
|
||||
};
|
||||
|
||||
onPaddingBottomClick = () => {
|
||||
const cm = this.getEditorInstance();
|
||||
cm.setCursor(cm.lineCount(), 0);
|
||||
cm.focus();
|
||||
};
|
||||
|
||||
getEditorInstance = () => {
|
||||
return this.refs.editor.getCodeMirror();
|
||||
};
|
||||
|
||||
render = () => {
|
||||
const options = {
|
||||
readOnly: false,
|
||||
lineNumbers: false,
|
||||
mode: 'gfm',
|
||||
matchBrackets: true,
|
||||
lineWrapping: true,
|
||||
viewportMargin: Infinity,
|
||||
scrollbarStyle: 'null',
|
||||
theme: 'atlas',
|
||||
autofocus: true,
|
||||
extraKeys: {
|
||||
Enter: 'newlineAndIndentContinueMarkdownList',
|
||||
|
||||
'Ctrl-Enter': this.props.onSave,
|
||||
'Cmd-Enter': this.props.onSave,
|
||||
|
||||
'Cmd-Esc': this.props.onCancel,
|
||||
'Ctrl-Esc': this.props.onCancel,
|
||||
|
||||
// 'Cmd-Shift-p': this.props.togglePreview,
|
||||
// 'Ctrl-Shift-p': this.props.togglePreview,
|
||||
},
|
||||
placeholder: '# Start with a title...',
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
disableClick
|
||||
multiple={false}
|
||||
accept="image/*"
|
||||
className={styles.container}
|
||||
>
|
||||
<ClickablePadding onClick={this.onPaddingTopClick} />
|
||||
<Codemirror
|
||||
value={this.props.text}
|
||||
onChange={this.onChange}
|
||||
options={options}
|
||||
ref="editor"
|
||||
className={styles.codeMirrorContainer}
|
||||
/>
|
||||
<ClickablePadding onClick={this.onPaddingBottomClick} />
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import styles from './ClickablePadding.scss';
|
||||
|
||||
const ClickablePadding = (props: { onClick: Function }) => {
|
||||
return <div className={styles.container} onClick={props.onClick}> </div>;
|
||||
};
|
||||
|
||||
ClickablePadding.propTypes = {
|
||||
onClick: React.PropTypes.func,
|
||||
};
|
||||
|
||||
export default ClickablePadding;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import MarkdownEditor from './MarkdownEditor';
|
||||
export default MarkdownEditor;
|
@ -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);
|
||||
|
@ -13,14 +13,13 @@ import 'normalize.css/normalize.css';
|
||||
import 'styles/base.scss';
|
||||
import 'styles/fonts.css';
|
||||
import 'styles/transitions.scss';
|
||||
import 'styles/prism-tomorrow.scss';
|
||||
import 'styles/hljs-github-gist.scss';
|
||||
import 'styles/codemirror.scss';
|
||||
|
||||
import Home from 'scenes/Home';
|
||||
import Dashboard from 'scenes/Dashboard';
|
||||
import Atlas from 'scenes/Atlas';
|
||||
import DocumentScene from 'scenes/DocumentScene';
|
||||
import DocumentEdit from 'scenes/DocumentEdit';
|
||||
import Document from 'scenes/Document';
|
||||
import Search from 'scenes/Search';
|
||||
import Settings from 'scenes/Settings';
|
||||
import SlackAuth from 'scenes/SlackAuth';
|
||||
@ -63,23 +62,20 @@ render(
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:id/new"
|
||||
component={DocumentEdit}
|
||||
component={Document}
|
||||
onEnter={requireAuth}
|
||||
newDocument
|
||||
/>
|
||||
<Route
|
||||
path="/d/:id"
|
||||
component={DocumentScene}
|
||||
onEnter={requireAuth}
|
||||
/>
|
||||
<Route path="/d/:id" component={Document} onEnter={requireAuth} />
|
||||
<Route
|
||||
path="/d/:id/edit"
|
||||
component={DocumentEdit}
|
||||
component={Document}
|
||||
onEnter={requireAuth}
|
||||
editDocument
|
||||
/>
|
||||
<Route
|
||||
path="/d/:id/new"
|
||||
component={DocumentEdit}
|
||||
component={Document}
|
||||
onEnter={requireAuth}
|
||||
newChildDocument
|
||||
/>
|
||||
|
150
frontend/scenes/Document/Document.js
Normal file
150
frontend/scenes/Document/Document.js
Normal file
@ -0,0 +1,150 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { observer } from 'mobx-react';
|
||||
import { browserHistory, withRouter } from 'react-router';
|
||||
import { Flex } from 'reflexbox';
|
||||
|
||||
import DocumentStore from './DocumentStore';
|
||||
import Breadcrumbs from './components/Breadcrumbs';
|
||||
import Editor from './components/Editor';
|
||||
import Menu from './components/Menu';
|
||||
import Layout, { HeaderAction, SaveAction } from 'components/Layout';
|
||||
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
|
||||
import CenteredContent from 'components/CenteredContent';
|
||||
|
||||
const DISCARD_CHANGES = `
|
||||
You have unsaved changes.
|
||||
Are you sure you want to discard them?
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
route: Object,
|
||||
router: Object,
|
||||
params: Object,
|
||||
keydown: Object,
|
||||
};
|
||||
|
||||
@withRouter
|
||||
@observer
|
||||
class Document extends Component {
|
||||
store: DocumentStore;
|
||||
props: Props;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.store = new DocumentStore({});
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
if (this.props.route.newDocument) {
|
||||
this.store.collectionId = this.props.params.id;
|
||||
this.store.newDocument = true;
|
||||
} else if (this.props.route.editDocument) {
|
||||
this.store.documentId = this.props.params.id;
|
||||
this.store.fetchDocument();
|
||||
} else if (this.props.route.newChildDocument) {
|
||||
this.store.documentId = this.props.params.id;
|
||||
this.store.newChildDocument = true;
|
||||
this.store.fetchDocument();
|
||||
} else {
|
||||
this.store.documentId = this.props.params.id;
|
||||
this.store.newDocument = false;
|
||||
this.store.fetchDocument();
|
||||
}
|
||||
|
||||
// Prevent user from accidentally leaving with unsaved changes
|
||||
const remove = this.props.router.setRouteLeaveHook(this.props.route, () => {
|
||||
if (this.store.hasPendingChanges) {
|
||||
return confirm(DISCARD_CHANGES);
|
||||
}
|
||||
remove();
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
onEdit = () => {
|
||||
const url = `${this.store.document.url}/edit`;
|
||||
browserHistory.push(url);
|
||||
};
|
||||
|
||||
onSave = (options: { redirect?: boolean } = {}) => {
|
||||
if (this.store.newDocument || this.store.newChildDocument) {
|
||||
this.store.saveDocument(options);
|
||||
} else {
|
||||
this.store.updateDocument(options);
|
||||
}
|
||||
};
|
||||
|
||||
onImageUploadStart = () => {
|
||||
this.store.updateUploading(true);
|
||||
};
|
||||
|
||||
onImageUploadStop = () => {
|
||||
this.store.updateUploading(false);
|
||||
};
|
||||
|
||||
onCancel = () => {
|
||||
browserHistory.goBack();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { route } = this.props;
|
||||
const isNew = route.newDocument || route.newChildDocument;
|
||||
const isEditing = route.editDocument;
|
||||
const title = (
|
||||
<Breadcrumbs
|
||||
document={this.store.document}
|
||||
pathToDocument={this.store.pathToDocument}
|
||||
/>
|
||||
);
|
||||
const titleText = `${get(this.store, 'document.collection.name')} - ${get(this.store, 'document.title')}`;
|
||||
|
||||
const actions = (
|
||||
<Flex>
|
||||
<HeaderAction>
|
||||
{isEditing
|
||||
? <SaveAction
|
||||
onClick={this.onSave}
|
||||
disabled={this.store.isSaving}
|
||||
isNew={isNew}
|
||||
/>
|
||||
: <a onClick={this.onEdit}>Edit</a>}
|
||||
</HeaderAction>
|
||||
<Menu
|
||||
store={this.store}
|
||||
document={this.store.document}
|
||||
collectionTree={this.store.collectionTree}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
actions={actions}
|
||||
title={title}
|
||||
titleText={titleText}
|
||||
loading={this.store.isSaving || this.store.isUploading}
|
||||
search={false}
|
||||
fixed
|
||||
>
|
||||
{this.store.isFetching &&
|
||||
<CenteredContent>
|
||||
<AtlasPreviewLoading />
|
||||
</CenteredContent>}
|
||||
{this.store.document &&
|
||||
<Editor
|
||||
text={this.store.document.text}
|
||||
onImageUploadStart={this.onImageUploadStart}
|
||||
onImageUploadStop={this.onImageUploadStop}
|
||||
onChange={this.store.updateText}
|
||||
onSave={this.onSave}
|
||||
onCancel={this.onCancel}
|
||||
readOnly={!this.props.route.editDocument}
|
||||
/>}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Document;
|
11
frontend/scenes/Document/Document.scss
Normal file
11
frontend/scenes/Document/Document.scss
Normal file
@ -0,0 +1,11 @@
|
||||
@import '~styles/constants.scss';
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
top: $headerHeight;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
197
frontend/scenes/Document/DocumentStore.js
Normal file
197
frontend/scenes/Document/DocumentStore.js
Normal file
@ -0,0 +1,197 @@
|
||||
// @flow
|
||||
import { observable, action, computed, toJS } from 'mobx';
|
||||
import { browserHistory } from 'react-router';
|
||||
import get from 'lodash/get';
|
||||
import invariant from 'invariant';
|
||||
import { client } from 'utils/ApiClient';
|
||||
import emojify from 'utils/emojify';
|
||||
import type { Document, NavigationNode } from 'types';
|
||||
|
||||
type SaveProps = { redirect?: boolean };
|
||||
|
||||
const parseHeader = text => {
|
||||
const firstLine = text.split(/\r?\n/)[0];
|
||||
if (firstLine) {
|
||||
const match = firstLine.match(/^#+ +(.*)$/);
|
||||
|
||||
if (match) {
|
||||
return emojify(match[1]);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
class DocumentStore {
|
||||
@observable collapsedNodes: string[] = [];
|
||||
@observable documentId = null;
|
||||
@observable collectionId = null;
|
||||
@observable document: Document;
|
||||
@observable parentDocument: Document;
|
||||
@observable hasPendingChanges = false;
|
||||
@observable newDocument: ?boolean;
|
||||
@observable newChildDocument: ?boolean;
|
||||
|
||||
@observable isEditing: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable isSaving: boolean = false;
|
||||
@observable isUploading: boolean = false;
|
||||
|
||||
/* Computed */
|
||||
|
||||
@computed get isCollection(): boolean {
|
||||
return !!this.document && this.document.collection.type === 'atlas';
|
||||
}
|
||||
|
||||
@computed get collectionTree(): ?Object {
|
||||
if (
|
||||
this.document &&
|
||||
this.document.collection &&
|
||||
this.document.collection.type === 'atlas'
|
||||
) {
|
||||
const tree = this.document.collection.navigationTree;
|
||||
const collapseNodes = node => {
|
||||
node.collapsed = this.collapsedNodes.includes(node.id);
|
||||
node.children = node.children.map(childNode => {
|
||||
return collapseNodes(childNode);
|
||||
});
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return collapseNodes(toJS(tree));
|
||||
}
|
||||
}
|
||||
|
||||
@computed get pathToDocument(): Array<NavigationNode> {
|
||||
let path;
|
||||
const traveler = (node, previousPath) => {
|
||||
if (this.document && node.id === this.document.id) {
|
||||
path = previousPath;
|
||||
return;
|
||||
} else {
|
||||
node.children.forEach(childNode => {
|
||||
const newPath = [...previousPath, node];
|
||||
return traveler(childNode, newPath);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (this.document && this.collectionTree) {
|
||||
traveler(this.collectionTree, []);
|
||||
invariant(path, 'Path is not available for collection, abort');
|
||||
return path.splice(1);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
@action fetchDocument = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.get(
|
||||
'/documents.info',
|
||||
{
|
||||
id: this.documentId,
|
||||
},
|
||||
{ cache: true }
|
||||
);
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
if (this.newChildDocument) {
|
||||
this.parentDocument = res.data;
|
||||
} else {
|
||||
this.document = res.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Something went wrong');
|
||||
}
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
@action saveDocument = async ({ redirect = true }: SaveProps) => {
|
||||
if (this.isSaving) return;
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(
|
||||
'/documents.create',
|
||||
{
|
||||
parentDocument: get(this.parentDocument, 'id'),
|
||||
collection: get(
|
||||
this.parentDocument,
|
||||
'collection.id',
|
||||
this.collectionId
|
||||
),
|
||||
title: get(this.document, 'title', 'Untitled document'),
|
||||
text: get(this.document, 'text'),
|
||||
},
|
||||
{ cache: true }
|
||||
);
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
const { url } = res.data;
|
||||
|
||||
this.hasPendingChanges = false;
|
||||
if (redirect) browserHistory.push(url);
|
||||
} catch (e) {
|
||||
console.error('Something went wrong');
|
||||
}
|
||||
this.isSaving = false;
|
||||
};
|
||||
|
||||
@action updateDocument = async ({ redirect = true }: SaveProps) => {
|
||||
if (this.isSaving) return;
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(
|
||||
'/documents.update',
|
||||
{
|
||||
id: this.documentId,
|
||||
title: get(this.document, 'title', 'Untitled document'),
|
||||
text: get(this.document, 'text'),
|
||||
},
|
||||
{ cache: true }
|
||||
);
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
const { url } = res.data;
|
||||
|
||||
this.hasPendingChanges = false;
|
||||
if (redirect) browserHistory.push(url);
|
||||
} catch (e) {
|
||||
console.error('Something went wrong');
|
||||
}
|
||||
this.isSaving = false;
|
||||
};
|
||||
|
||||
@action deleteDocument = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
await client.post('/documents.delete', { id: this.documentId });
|
||||
browserHistory.push(this.document.collection.id);
|
||||
} catch (e) {
|
||||
console.error('Something went wrong');
|
||||
}
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
@action updateText = (text: string) => {
|
||||
if (!this.document) return;
|
||||
|
||||
this.document.text = text;
|
||||
this.document.title = parseHeader(text);
|
||||
this.hasPendingChanges = true;
|
||||
};
|
||||
|
||||
@action updateUploading = (uploading: boolean) => {
|
||||
this.isUploading = uploading;
|
||||
};
|
||||
}
|
||||
|
||||
export default DocumentStore;
|
@ -1,16 +1,14 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Link } from 'react-router';
|
||||
import type { Document, NavigationNode } from 'types';
|
||||
import DocumentSceneStore from '../../DocumentSceneStore';
|
||||
|
||||
type Props = {
|
||||
store: DocumentSceneStore,
|
||||
document: Document,
|
||||
pathToDocument: Array<NavigationNode>,
|
||||
};
|
||||
|
||||
const Breadcrumbs = ({ store }: Props) => {
|
||||
const { document, pathToDocument } = store;
|
||||
const Breadcrumbs = ({ document, pathToDocument }: Props) => {
|
||||
if (document && document.collection) {
|
||||
const titleSections = pathToDocument
|
||||
? pathToDocument.map(node => (
|
||||
@ -31,6 +29,7 @@ const Breadcrumbs = ({ store }: Props) => {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Breadcrumbs;
|
121
frontend/scenes/Document/components/Editor/Editor.js
Normal file
121
frontend/scenes/Document/components/Editor/Editor.js
Normal file
@ -0,0 +1,121 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Editor, Plain } from 'slate';
|
||||
import classnames from 'classnames/bind';
|
||||
import type { Document, State, Editor as EditorType } from './types';
|
||||
import ClickablePadding from './components/ClickablePadding';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import schema from './schema';
|
||||
import Markdown from './serializer';
|
||||
import createPlugins from './plugins';
|
||||
import styles from './Editor.scss';
|
||||
|
||||
const cx = classnames.bind(styles);
|
||||
|
||||
type Props = {
|
||||
text: string,
|
||||
onChange: Function,
|
||||
onSave: Function,
|
||||
onCancel: Function,
|
||||
onImageUploadStart: Function,
|
||||
onImageUploadStop: Function,
|
||||
readOnly: boolean,
|
||||
};
|
||||
|
||||
type KeyData = {
|
||||
isMeta: boolean,
|
||||
key: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
export default class MarkdownEditor extends Component {
|
||||
props: Props;
|
||||
editor: EditorType;
|
||||
plugins: Array<Object>;
|
||||
|
||||
state: {
|
||||
state: State,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.plugins = createPlugins({
|
||||
onImageUploadStart: props.onImageUploadStart,
|
||||
onImageUploadStop: props.onImageUploadStop,
|
||||
});
|
||||
|
||||
if (props.text) {
|
||||
this.state = { state: Markdown.deserialize(props.text) };
|
||||
} else {
|
||||
this.state = { state: Plain.deserialize('') };
|
||||
}
|
||||
}
|
||||
|
||||
onChange = (state: State) => {
|
||||
this.setState({ state });
|
||||
};
|
||||
|
||||
onDocumentChange = (document: Document, state: State) => {
|
||||
this.props.onChange(Markdown.serialize(state));
|
||||
};
|
||||
|
||||
onKeyDown = (ev: SyntheticKeyboardEvent, data: KeyData, state: State) => {
|
||||
if (!data.isMeta) return;
|
||||
|
||||
switch (data.key) {
|
||||
case 's':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return this.props.onSave({ redirect: false });
|
||||
case 'enter':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onSave();
|
||||
return state;
|
||||
case 'escape':
|
||||
return this.props.onCancel();
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
focusAtStart = () => {
|
||||
const state = this.editor.getState();
|
||||
const transform = state.transform();
|
||||
transform.collapseToStartOf(state.document);
|
||||
transform.focus();
|
||||
this.setState({ state: transform.apply() });
|
||||
};
|
||||
|
||||
focusAtEnd = () => {
|
||||
const state = this.editor.getState();
|
||||
const transform = state.transform();
|
||||
transform.collapseToEndOf(state.document);
|
||||
transform.focus();
|
||||
this.setState({ state: transform.apply() });
|
||||
};
|
||||
|
||||
render = () => {
|
||||
return (
|
||||
<span className={styles.container}>
|
||||
<ClickablePadding onClick={this.focusAtStart} />
|
||||
<Toolbar state={this.state.state} onChange={this.onChange} />
|
||||
<Editor
|
||||
ref={ref => (this.editor = ref)}
|
||||
placeholder="Start with a title…"
|
||||
className={cx(styles.editor, { readOnly: this.props.readOnly })}
|
||||
schema={schema}
|
||||
plugins={this.plugins}
|
||||
state={this.state.state}
|
||||
onChange={this.onChange}
|
||||
onDocumentChange={this.onDocumentChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onSave={this.props.onSave}
|
||||
readOnly={this.props.readOnly}
|
||||
/>
|
||||
<ClickablePadding onClick={this.focusAtEnd} grow />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}
|
143
frontend/scenes/Document/components/Editor/Editor.scss
Normal file
143
frontend/scenes/Document/components/Editor/Editor.scss
Normal file
@ -0,0 +1,143 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
font-weight: 400;
|
||||
font-size: 1em;
|
||||
line-height: 1.5em;
|
||||
|
||||
padding: 0 3em;
|
||||
max-width: 50em;
|
||||
}
|
||||
|
||||
.editor {
|
||||
background: #fff;
|
||||
color: #1b2631;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 500;
|
||||
|
||||
.anchor {
|
||||
visibility: hidden;
|
||||
color: #dedede;
|
||||
padding-left: .25em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.anchor {
|
||||
visibility: visible;
|
||||
|
||||
&:hover {
|
||||
color: #cdcdcd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 1em .1em;
|
||||
padding-left: 1em;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: .1em;
|
||||
}
|
||||
}
|
||||
|
||||
li p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.todoList {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
|
||||
.todoList {
|
||||
padding-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.todo {
|
||||
span:last-child:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
background: #efefef;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #dedede;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0 .5em;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid #efefef;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 5px 20px 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
@media all and (max-width: 2000px) and (min-width: 960px) {
|
||||
.container {
|
||||
// margin-top: 48px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 960px) {
|
||||
.container {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import styles from './ClickablePadding.scss';
|
||||
|
||||
type Props = {
|
||||
onClick: Function,
|
||||
grow?: boolean,
|
||||
};
|
||||
|
||||
const ClickablePadding = (props: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={classnames(styles.container, { [styles.grow]: props.grow })}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClickablePadding;
|
@ -3,6 +3,10 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media all and (max-width: 960px) {
|
||||
.container {
|
||||
padding-top: 50px;
|
@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
|
||||
export default function Code({ children, attributes }: Props) {
|
||||
return (
|
||||
<pre>
|
||||
<code {...attributes}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import slug from 'slug';
|
||||
import type { Node, Editor } from '../types';
|
||||
import styles from '../Editor.scss';
|
||||
|
||||
type Props = {
|
||||
children: React$Element<any>,
|
||||
placeholder?: boolean,
|
||||
parent: Node,
|
||||
node: Node,
|
||||
editor: Editor,
|
||||
readOnly: boolean,
|
||||
component?: string,
|
||||
};
|
||||
|
||||
export default function Heading({
|
||||
parent,
|
||||
placeholder,
|
||||
node,
|
||||
editor,
|
||||
readOnly,
|
||||
children,
|
||||
component = 'h1',
|
||||
}: Props) {
|
||||
const firstHeading = parent.nodes.first() === node;
|
||||
const showPlaceholder = placeholder && firstHeading && !node.text;
|
||||
const slugish = readOnly && _.escape(`${component}-${slug(node.text)}`);
|
||||
const Component = component;
|
||||
|
||||
return (
|
||||
<Component className={styles.title}>
|
||||
{children}
|
||||
{showPlaceholder &&
|
||||
<span className={styles.placeholder}>
|
||||
{editor.props.placeholder}
|
||||
</span>}
|
||||
{slugish &&
|
||||
<a name={slugish} className={styles.anchor} href={`#${slugish}`}>#</a>}
|
||||
</Component>
|
||||
);
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
|
||||
export default function Image({ attributes, node }: Props) {
|
||||
return (
|
||||
<img
|
||||
{...attributes}
|
||||
src={node.data.get('src')}
|
||||
alt={node.data.get('alt')}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
|
||||
export default function Link({ attributes, node, children }: Props) {
|
||||
return (
|
||||
<a {...attributes} href={node.data.get('href')}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
import TodoItem from './TodoItem';
|
||||
|
||||
export default function ListItem({ children, node, ...props }: Props) {
|
||||
const checked = node.data.get('checked');
|
||||
if (checked !== undefined) {
|
||||
return (
|
||||
<TodoItem checked={checked} node={node} {...props}>
|
||||
{children}
|
||||
</TodoItem>
|
||||
);
|
||||
}
|
||||
return <li>{children}</li>;
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import type { Props } from '../types';
|
||||
import styles from '../Editor.scss';
|
||||
|
||||
export default class TodoItem extends Component {
|
||||
props: Props & { checked: boolean };
|
||||
|
||||
handleChange = (ev: SyntheticInputEvent) => {
|
||||
const checked = ev.target.checked;
|
||||
const { editor, node } = this.props;
|
||||
const state = editor
|
||||
.getState()
|
||||
.transform()
|
||||
.setNodeByKey(node.key, { data: { checked } })
|
||||
.apply();
|
||||
|
||||
editor.onChange(state);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, checked, readOnly } = this.props;
|
||||
|
||||
return (
|
||||
<li contentEditable={false} className={styles.todo}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={this.handleChange}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{' '}
|
||||
<span contentEditable={!readOnly} suppressContentEditableWarning>
|
||||
{children}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import Portal from 'react-portal';
|
||||
import classnames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
import type { State } from '../../types';
|
||||
import FormattingToolbar from './components/FormattingToolbar';
|
||||
import LinkToolbar from './components/LinkToolbar';
|
||||
import styles from './Toolbar.scss';
|
||||
|
||||
export default class Toolbar extends Component {
|
||||
props: {
|
||||
state: State,
|
||||
onChange: Function,
|
||||
};
|
||||
|
||||
menu: HTMLElement;
|
||||
state: {
|
||||
active: boolean,
|
||||
focused: boolean,
|
||||
link: React$Element<any>,
|
||||
top: string,
|
||||
left: string,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
focused: false,
|
||||
link: null,
|
||||
top: '',
|
||||
left: '',
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
this.update();
|
||||
};
|
||||
|
||||
componentDidUpdate = () => {
|
||||
this.update();
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ focused: false });
|
||||
};
|
||||
|
||||
get linkInSelection(): any {
|
||||
const { state } = this.props;
|
||||
|
||||
try {
|
||||
const selectedLinks = state.startBlock
|
||||
.getInlinesAtRange(state.selection)
|
||||
.filter(node => node.type === 'link');
|
||||
if (selectedLinks.size) {
|
||||
return selectedLinks.first();
|
||||
}
|
||||
} catch (err) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
update = () => {
|
||||
const { state } = this.props;
|
||||
const link = this.linkInSelection;
|
||||
|
||||
if (state.isBlurred || (state.isCollapsed && !link)) {
|
||||
if (this.state.active && !this.state.focused) {
|
||||
this.setState({ active: false, link: null, top: '', left: '' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// don't display toolbar for document title
|
||||
const firstNode = state.document.nodes.first();
|
||||
if (firstNode === state.startBlock) return;
|
||||
|
||||
// don't display toolbar for code blocks
|
||||
if (state.startBlock.type === 'code') return;
|
||||
|
||||
const data = {
|
||||
...this.state,
|
||||
active: true,
|
||||
link,
|
||||
focused: !!link,
|
||||
};
|
||||
|
||||
if (!_.isEqual(data, this.state)) {
|
||||
const padding = 16;
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
if (rect.top === 0 && rect.left === 0) {
|
||||
this.setState(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const left =
|
||||
rect.left + window.scrollX - this.menu.offsetWidth / 2 + rect.width / 2;
|
||||
data.top = `${Math.round(rect.top + window.scrollY - this.menu.offsetHeight)}px`;
|
||||
data.left = `${Math.round(Math.max(padding, left))}px`;
|
||||
this.setState(data);
|
||||
}
|
||||
};
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.menu = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const link = this.state.link;
|
||||
const classes = classnames(styles.menu, {
|
||||
[styles.active]: this.state.active,
|
||||
});
|
||||
|
||||
const style = {
|
||||
top: this.state.top,
|
||||
left: this.state.left,
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal isOpened>
|
||||
<div className={classes} style={style} ref={this.setRef}>
|
||||
{link &&
|
||||
<LinkToolbar
|
||||
{...this.props}
|
||||
link={link}
|
||||
onBlur={this.handleBlur}
|
||||
/>}
|
||||
{!link &&
|
||||
<FormattingToolbar
|
||||
onCreateLink={this.handleFocus}
|
||||
{...this.props}
|
||||
/>}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,112 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styles from '../Toolbar.scss';
|
||||
import type { State } from '../../../types';
|
||||
import BoldIcon from 'components/Icon/BoldIcon';
|
||||
import CodeIcon from 'components/Icon/CodeIcon';
|
||||
import Heading1Icon from 'components/Icon/Heading1Icon';
|
||||
import Heading2Icon from 'components/Icon/Heading2Icon';
|
||||
import LinkIcon from 'components/Icon/LinkIcon';
|
||||
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
|
||||
import BulletedListIcon from 'components/Icon/BulletedListIcon';
|
||||
|
||||
export default class FormattingToolbar extends Component {
|
||||
props: {
|
||||
state: State,
|
||||
onChange: Function,
|
||||
onCreateLink: Function,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current selection has a mark with `type` in it.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {Boolean}
|
||||
*/
|
||||
hasMark = (type: string) => {
|
||||
return this.props.state.marks.some(mark => mark.type === type);
|
||||
};
|
||||
|
||||
isBlock = (type: string) => {
|
||||
return this.props.state.startBlock.type === type;
|
||||
};
|
||||
|
||||
/**
|
||||
* When a mark button is clicked, toggle the current mark.
|
||||
*
|
||||
* @param {Event} ev
|
||||
* @param {String} type
|
||||
*/
|
||||
onClickMark = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
let { state } = this.props;
|
||||
|
||||
state = state.transform().toggleMark(type).apply();
|
||||
this.props.onChange(state);
|
||||
};
|
||||
|
||||
onClickBlock = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
let { state } = this.props;
|
||||
|
||||
state = state.transform().setBlock(type).apply();
|
||||
this.props.onChange(state);
|
||||
};
|
||||
|
||||
onCreateLink = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
let { state } = this.props;
|
||||
const data = { href: '' };
|
||||
state = state.transform().wrapInline({ type: 'link', data }).apply();
|
||||
this.props.onChange(state);
|
||||
this.props.onCreateLink();
|
||||
};
|
||||
|
||||
renderMarkButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.hasMark(type);
|
||||
const onMouseDown = ev => this.onClickMark(ev, type);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={styles.button}
|
||||
onMouseDown={onMouseDown}
|
||||
data-active={isActive}
|
||||
>
|
||||
<IconClass light />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderBlockButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.isBlock(type);
|
||||
const onMouseDown = ev =>
|
||||
this.onClickBlock(ev, isActive ? 'paragraph' : type);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={styles.button}
|
||||
onMouseDown={onMouseDown}
|
||||
data-active={isActive}
|
||||
>
|
||||
<IconClass light />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span>
|
||||
{this.renderMarkButton('bold', BoldIcon)}
|
||||
{this.renderMarkButton('deleted', StrikethroughIcon)}
|
||||
{this.renderBlockButton('heading1', Heading1Icon)}
|
||||
{this.renderBlockButton('heading2', Heading2Icon)}
|
||||
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
|
||||
{this.renderMarkButton('code', CodeIcon)}
|
||||
<button className={styles.button} onMouseDown={this.onCreateLink}>
|
||||
<LinkIcon light />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import type { State } from '../../../types';
|
||||
import keydown from 'react-keydown';
|
||||
import styles from '../Toolbar.scss';
|
||||
import CloseIcon from 'components/Icon/CloseIcon';
|
||||
|
||||
@keydown
|
||||
export default class LinkToolbar extends Component {
|
||||
input: HTMLElement;
|
||||
props: {
|
||||
state: State,
|
||||
link: Object,
|
||||
onBlur: Function,
|
||||
onChange: Function,
|
||||
};
|
||||
|
||||
onKeyDown = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
|
||||
switch (ev.keyCode) {
|
||||
case 13: // enter
|
||||
ev.preventDefault();
|
||||
return this.save(ev.target.value);
|
||||
case 26: // escape
|
||||
return this.input.blur();
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
removeLink = () => {
|
||||
this.save('');
|
||||
};
|
||||
|
||||
save = (href: string) => {
|
||||
href = href.trim();
|
||||
const transform = this.props.state.transform();
|
||||
transform.unwrapInline('link');
|
||||
|
||||
if (href) {
|
||||
const data = { href };
|
||||
transform.wrapInline({ type: 'link', data });
|
||||
}
|
||||
|
||||
const state = transform.apply();
|
||||
this.props.onChange(state);
|
||||
this.input.blur();
|
||||
};
|
||||
|
||||
render() {
|
||||
const href = this.props.link.data.get('href');
|
||||
return (
|
||||
<span className={styles.linkEditor}>
|
||||
<input
|
||||
ref={ref => (this.input = ref)}
|
||||
defaultValue={href}
|
||||
placeholder="http://"
|
||||
onBlur={this.props.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button className={styles.button} onMouseDown={this.removeLink}>
|
||||
<CloseIcon light />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Toolbar from './Toolbar';
|
||||
export default Toolbar;
|
3
frontend/scenes/Document/components/Editor/index.js
Normal file
3
frontend/scenes/Document/components/Editor/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Editor from './Editor';
|
||||
export default Editor;
|
67
frontend/scenes/Document/components/Editor/plugins.js
Normal file
67
frontend/scenes/Document/components/Editor/plugins.js
Normal file
@ -0,0 +1,67 @@
|
||||
// @flow
|
||||
import DropOrPasteImages from 'slate-drop-or-paste-images';
|
||||
import PasteLinkify from 'slate-paste-linkify';
|
||||
import EditList from 'slate-edit-list';
|
||||
import CollapseOnEscape from 'slate-collapse-on-escape';
|
||||
import TrailingBlock from 'slate-trailing-block';
|
||||
import EditCode from 'slate-edit-code';
|
||||
import Prism from 'slate-prism';
|
||||
import uploadFile from 'utils/uploadFile';
|
||||
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
|
||||
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
|
||||
|
||||
const onlyInCode = node => node.type === 'code';
|
||||
|
||||
const createPlugins = ({
|
||||
onImageUploadStart,
|
||||
onImageUploadStop,
|
||||
}: { onImageUploadStart: Function, onImageUploadStop: Function }) => {
|
||||
return [
|
||||
PasteLinkify({
|
||||
type: 'link',
|
||||
collapseTo: 'end',
|
||||
}),
|
||||
DropOrPasteImages({
|
||||
extensions: ['png', 'jpg', 'gif'],
|
||||
applyTransform: async (transform, file) => {
|
||||
try {
|
||||
onImageUploadStart();
|
||||
const asset = await uploadFile(file);
|
||||
const alt = file.name;
|
||||
const src = asset.url;
|
||||
|
||||
return transform.insertBlock({
|
||||
type: 'image',
|
||||
isVoid: true,
|
||||
data: { src, alt },
|
||||
});
|
||||
} catch (err) {
|
||||
// TODO: Show a failure alert
|
||||
} finally {
|
||||
onImageUploadStop();
|
||||
}
|
||||
},
|
||||
}),
|
||||
EditList({
|
||||
types: ['ordered-list', 'bulleted-list', 'todo-list'],
|
||||
typeItem: 'list-item',
|
||||
}),
|
||||
EditCode({
|
||||
onlyIn: onlyInCode,
|
||||
containerType: 'code',
|
||||
lineType: 'code-line',
|
||||
exitBlocktype: 'paragraph',
|
||||
selectAll: true,
|
||||
}),
|
||||
Prism({
|
||||
onlyIn: onlyInCode,
|
||||
getSyntax: node => 'javascript',
|
||||
}),
|
||||
CollapseOnEscape({ toEdge: 'end' }),
|
||||
TrailingBlock({ type: 'paragraph' }),
|
||||
KeyboardShortcuts(),
|
||||
MarkdownShortcuts(),
|
||||
];
|
||||
};
|
||||
|
||||
export default createPlugins;
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
94
frontend/scenes/Document/components/Editor/schema.js
Normal file
94
frontend/scenes/Document/components/Editor/schema.js
Normal file
@ -0,0 +1,94 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Code from './components/Code';
|
||||
import Image from './components/Image';
|
||||
import Link from './components/Link';
|
||||
import ListItem from './components/ListItem';
|
||||
import Heading from './components/Heading';
|
||||
import type { Props, Node, Transform } from './types';
|
||||
import styles from './Editor.scss';
|
||||
|
||||
const schema = {
|
||||
marks: {
|
||||
bold: (props: Props) => <strong>{props.children}</strong>,
|
||||
code: (props: Props) => <code>{props.children}</code>,
|
||||
italic: (props: Props) => <em>{props.children}</em>,
|
||||
underlined: (props: Props) => <u>{props.children}</u>,
|
||||
deleted: (props: Props) => <del>{props.children}</del>,
|
||||
added: (props: Props) => <mark>{props.children}</mark>,
|
||||
},
|
||||
|
||||
nodes: {
|
||||
paragraph: (props: Props) => <p>{props.children}</p>,
|
||||
'block-quote': (props: Props) => <blockquote>{props.children}</blockquote>,
|
||||
'horizontal-rule': (props: Props) => <hr />,
|
||||
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
|
||||
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
|
||||
'todo-list': (props: Props) => (
|
||||
<ul className={styles.todoList}>{props.children}</ul>
|
||||
),
|
||||
table: (props: Props) => <table>{props.children}</table>,
|
||||
'table-row': (props: Props) => <tr>{props.children}</tr>,
|
||||
'table-head': (props: Props) => <th>{props.children}</th>,
|
||||
'table-cell': (props: Props) => <td>{props.children}</td>,
|
||||
code: Code,
|
||||
image: Image,
|
||||
link: Link,
|
||||
'list-item': ListItem,
|
||||
heading1: (props: Props) => <Heading placeholder {...props} />,
|
||||
heading2: (props: Props) => <Heading component="h2" {...props} />,
|
||||
heading3: (props: Props) => <Heading component="h3" {...props} />,
|
||||
heading4: (props: Props) => <Heading component="h4" {...props} />,
|
||||
heading5: (props: Props) => <Heading component="h5" {...props} />,
|
||||
heading6: (props: Props) => <Heading component="h6" {...props} />,
|
||||
},
|
||||
|
||||
rules: [
|
||||
// ensure first node is a heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'document';
|
||||
},
|
||||
validate: (document: Node) => {
|
||||
const firstNode = document.nodes.first();
|
||||
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
|
||||
},
|
||||
normalize: (transform: Transform, document: Node, firstNode: Node) => {
|
||||
transform.setBlock({ type: 'heading1' });
|
||||
},
|
||||
},
|
||||
|
||||
// remove any marks in first heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'heading1';
|
||||
},
|
||||
validate: (heading: Node) => {
|
||||
const hasMarks = heading.getMarks().isEmpty();
|
||||
const hasInlines = heading.getInlines().isEmpty();
|
||||
|
||||
return !(hasMarks && hasInlines);
|
||||
},
|
||||
normalize: (transform: Transform, heading: Node) => {
|
||||
transform.unwrapInlineByKey(heading.key);
|
||||
|
||||
heading.getMarks().forEach(mark => {
|
||||
heading.nodes.forEach(textNode => {
|
||||
if (textNode.kind === 'text') {
|
||||
transform.removeMarkByKey(
|
||||
textNode.key,
|
||||
0,
|
||||
textNode.text.length,
|
||||
mark
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return transform;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default schema;
|
3
frontend/scenes/Document/components/Editor/serializer.js
Normal file
3
frontend/scenes/Document/components/Editor/serializer.js
Normal file
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import MarkdownSerializer from 'slate-markdown-serializer';
|
||||
export default new MarkdownSerializer();
|
110
frontend/scenes/Document/components/Editor/types.js
Normal file
110
frontend/scenes/Document/components/Editor/types.js
Normal file
@ -0,0 +1,110 @@
|
||||
// @flow
|
||||
import { List, Set, Map } from 'immutable';
|
||||
|
||||
export type NodeTransform = {
|
||||
addMarkByKey: Function,
|
||||
insertNodeByKey: Function,
|
||||
insertTextByKey: Function,
|
||||
moveNodeByKey: Function,
|
||||
removeMarkByKey: Function,
|
||||
removeNodeByKey: Function,
|
||||
removeTextByKey: Function,
|
||||
setMarkByKey: Function,
|
||||
setNodeByKey: Function,
|
||||
splitNodeByKey: Function,
|
||||
unwrapInlineByKey: Function,
|
||||
unwrapBlockByKey: Function,
|
||||
unwrapNodeByKey: Function,
|
||||
wrapBlockByKey: Function,
|
||||
wrapInlineByKey: Function,
|
||||
};
|
||||
|
||||
export type StateTransform = {
|
||||
deleteBackward: Function,
|
||||
deleteForward: Function,
|
||||
delete: Function,
|
||||
insertBlock: Function,
|
||||
insertFragment: Function,
|
||||
insertInline: Function,
|
||||
insertText: Function,
|
||||
addMark: Function,
|
||||
setBlock: Function,
|
||||
setInline: Function,
|
||||
splitBlock: Function,
|
||||
splitInline: Function,
|
||||
removeMark: Function,
|
||||
toggleMark: Function,
|
||||
unwrapBlock: Function,
|
||||
unwrapInline: Function,
|
||||
wrapBlock: Function,
|
||||
wrapInline: Function,
|
||||
wrapText: Function,
|
||||
};
|
||||
|
||||
export type Transform = NodeTransform & StateTransform;
|
||||
|
||||
export type Editor = {
|
||||
props: Object,
|
||||
className: string,
|
||||
onChange: Function,
|
||||
onDocumentChange: Function,
|
||||
onSelectionChange: Function,
|
||||
plugins: Array<Object>,
|
||||
readOnly: boolean,
|
||||
state: Object,
|
||||
style: Object,
|
||||
placeholder?: string,
|
||||
placeholderClassName?: string,
|
||||
placeholderStyle?: string,
|
||||
blur: Function,
|
||||
focus: Function,
|
||||
getSchema: Function,
|
||||
getState: Function,
|
||||
};
|
||||
|
||||
export type Node = {
|
||||
key: string,
|
||||
kind: string,
|
||||
length: number,
|
||||
text: string,
|
||||
data: Map<string, any>,
|
||||
nodes: List<Node>,
|
||||
getMarks: Function,
|
||||
getBlocks: Function,
|
||||
getParent: Function,
|
||||
getInlines: Function,
|
||||
getInlinesAtRange: Function,
|
||||
setBlock: Function,
|
||||
};
|
||||
|
||||
export type Block = Node & {
|
||||
type: string,
|
||||
};
|
||||
|
||||
export type Document = Node;
|
||||
|
||||
export type Props = {
|
||||
node: Node,
|
||||
parent?: Node,
|
||||
attributes?: Object,
|
||||
editor: Editor,
|
||||
readOnly?: boolean,
|
||||
children?: React$Element<any>,
|
||||
};
|
||||
|
||||
export type State = {
|
||||
document: Document,
|
||||
selection: Selection,
|
||||
startBlock: Block,
|
||||
endBlock: Block,
|
||||
startText: Node,
|
||||
endText: Node,
|
||||
marks: Set<*>,
|
||||
blocks: List<Block>,
|
||||
fragment: Document,
|
||||
lines: List<Node>,
|
||||
tests: List<Node>,
|
||||
startBlock: Block,
|
||||
transform: Function,
|
||||
isBlurred: Function,
|
||||
};
|
78
frontend/scenes/Document/components/Menu.js
Normal file
78
frontend/scenes/Document/components/Menu.js
Normal file
@ -0,0 +1,78 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import invariant from 'invariant';
|
||||
import get from 'lodash/get';
|
||||
import { browserHistory } from 'react-router';
|
||||
import { observer } from 'mobx-react';
|
||||
import type { Document as DocumentType } from 'types';
|
||||
import DropdownMenu, { MenuItem, MoreIcon } from 'components/DropdownMenu';
|
||||
import DocumentStore from '../DocumentStore';
|
||||
|
||||
type Props = {
|
||||
document: DocumentType,
|
||||
collectionTree: ?Object,
|
||||
store: DocumentStore,
|
||||
};
|
||||
|
||||
@observer class Menu extends Component {
|
||||
props: Props;
|
||||
|
||||
onCreateDocument = () => {
|
||||
invariant(this.props.collectionTree, 'collectionTree is not available');
|
||||
browserHistory.push(`${this.props.collectionTree.url}/new`);
|
||||
};
|
||||
|
||||
onCreateChild = () => {
|
||||
invariant(this.props.document, 'Document is not available');
|
||||
browserHistory.push(`${this.props.document.url}/new`);
|
||||
};
|
||||
|
||||
onDelete = () => {
|
||||
let msg;
|
||||
if (get(this.props, 'document.collection.type') === 'atlas') {
|
||||
msg =
|
||||
"Are you sure you want to delete this document and all it's child documents (if any)?";
|
||||
} else {
|
||||
msg = 'Are you sure you want to delete this document?';
|
||||
}
|
||||
|
||||
if (confirm(msg)) {
|
||||
this.props.store.deleteDocument();
|
||||
}
|
||||
};
|
||||
|
||||
onExport = () => {
|
||||
const doc = this.props.document;
|
||||
if (doc) {
|
||||
const a = document.createElement('a');
|
||||
a.textContent = 'download';
|
||||
a.download = `${doc.title}.md`;
|
||||
a.href = `data:text/markdown;charset=UTF-8,${encodeURIComponent(doc.text)}`;
|
||||
a.click();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const document = get(this.props, 'document');
|
||||
const collection = get(document, 'collection.type') === 'atlas';
|
||||
const allowDelete =
|
||||
collection &&
|
||||
document.id !== get(document, 'collection.navigationTree.id');
|
||||
|
||||
return (
|
||||
<DropdownMenu label={<MoreIcon />}>
|
||||
{collection &&
|
||||
<div>
|
||||
<MenuItem onClick={this.onCreateDocument}>
|
||||
New document
|
||||
</MenuItem>
|
||||
<MenuItem onClick={this.onCreateChild}>New child</MenuItem>
|
||||
</div>}
|
||||
<MenuItem onClick={this.onExport}>Export</MenuItem>
|
||||
{allowDelete && <MenuItem onClick={this.onDelete}>Delete</MenuItem>}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Menu;
|
3
frontend/scenes/Document/index.js
Normal file
3
frontend/scenes/Document/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Document from './Document';
|
||||
export default Document;
|
@ -1,183 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { browserHistory, withRouter } from 'react-router';
|
||||
import keydown from 'react-keydown';
|
||||
import { Flex } from 'reflexbox';
|
||||
|
||||
import DocumentEditStore, { DOCUMENT_EDIT_SETTINGS } from './DocumentEditStore';
|
||||
import EditorLoader from './components/EditorLoader';
|
||||
|
||||
import Layout, { Title, HeaderAction, SaveAction } from 'components/Layout';
|
||||
|
||||
import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
|
||||
import CenteredContent from 'components/CenteredContent';
|
||||
import DropdownMenu, { MenuItem, MoreIcon } from 'components/DropdownMenu';
|
||||
|
||||
const DISREGARD_CHANGES = `You have unsaved changes.
|
||||
Are you sure you want to disgard them?`;
|
||||
|
||||
type Props = {
|
||||
route: Object,
|
||||
router: Object,
|
||||
params: Object,
|
||||
keydown: Object,
|
||||
};
|
||||
|
||||
@keydown([
|
||||
'cmd+enter',
|
||||
'ctrl+enter',
|
||||
'cmd+esc',
|
||||
'ctrl+esc',
|
||||
'cmd+shift+p',
|
||||
'ctrl+shift+p',
|
||||
])
|
||||
@withRouter
|
||||
@observer
|
||||
class DocumentEdit extends Component {
|
||||
store: DocumentEditStore;
|
||||
props: Props;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.store = new DocumentEditStore(
|
||||
JSON.parse(localStorage[DOCUMENT_EDIT_SETTINGS] || '{}')
|
||||
);
|
||||
}
|
||||
|
||||
state = {
|
||||
scrollTop: 0,
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
if (this.props.route.newDocument) {
|
||||
this.store.collectionId = this.props.params.id;
|
||||
this.store.newDocument = true;
|
||||
} else if (this.props.route.newChildDocument) {
|
||||
this.store.documentId = this.props.params.id;
|
||||
this.store.newChildDocument = true;
|
||||
this.store.fetchDocument();
|
||||
} else {
|
||||
this.store.documentId = this.props.params.id;
|
||||
this.store.newDocument = false;
|
||||
this.store.fetchDocument();
|
||||
}
|
||||
|
||||
// Load editor async
|
||||
EditorLoader().then(({ Editor }) => {
|
||||
// $FlowIssue we can remove after moving to new editor
|
||||
this.setState({ Editor });
|
||||
});
|
||||
|
||||
// Set onLeave hook
|
||||
this.props.router.setRouteLeaveHook(this.props.route, () => {
|
||||
if (this.store.hasPendingChanges) {
|
||||
return confirm(DISREGARD_CHANGES);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
componentWillReceiveProps = (nextProps: Props) => {
|
||||
const key = nextProps.keydown.event;
|
||||
|
||||
if (key) {
|
||||
// Cmd + Enter
|
||||
if (key.key === 'Enter' && (key.metaKey || key.ctrl.Key)) {
|
||||
this.onSave();
|
||||
}
|
||||
|
||||
// Cmd + Esc
|
||||
if (key.key === 'Escape' && (key.metaKey || key.ctrl.Key)) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
// Cmd + m
|
||||
if (key.key === 'P' && key.shiftKey && (key.metaKey || key.ctrl.Key)) {
|
||||
this.store.togglePreview();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onSave = () => {
|
||||
// if (this.props.title.length === 0) {
|
||||
// alert("Please add a title before saving (hint: Write a markdown header)");
|
||||
// return
|
||||
// }
|
||||
if (this.store.newDocument || this.store.newChildDocument) {
|
||||
this.store.saveDocument();
|
||||
} else {
|
||||
this.store.updateDocument();
|
||||
}
|
||||
};
|
||||
|
||||
onCancel = () => {
|
||||
browserHistory.goBack();
|
||||
};
|
||||
|
||||
onScroll = (scrollTop: number) => {
|
||||
this.setState({
|
||||
scrollTop,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const title = (
|
||||
<Title
|
||||
truncate={60}
|
||||
placeholder={!this.store.isFetching ? 'Untitled document' : null}
|
||||
content={this.store.title}
|
||||
/>
|
||||
);
|
||||
|
||||
const titleText = this.store.title;
|
||||
const isNew =
|
||||
this.props.route.newDocument || this.props.route.newChildDocument;
|
||||
|
||||
const actions = (
|
||||
<Flex>
|
||||
<HeaderAction>
|
||||
<SaveAction
|
||||
onClick={this.onSave}
|
||||
disabled={this.store.isSaving}
|
||||
isNew={isNew}
|
||||
/>
|
||||
</HeaderAction>
|
||||
<DropdownMenu label={<MoreIcon />}>
|
||||
<MenuItem onClick={this.store.togglePreview}>
|
||||
Toggle Preview
|
||||
</MenuItem>
|
||||
<MenuItem onClick={this.onCancel}>
|
||||
Cancel
|
||||
</MenuItem>
|
||||
</DropdownMenu>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
actions={actions}
|
||||
title={title}
|
||||
titleText={titleText}
|
||||
fixed
|
||||
loading={this.store.isSaving || this.store.isUploading}
|
||||
search={false}
|
||||
>
|
||||
{this.store.isFetching || !('Editor' in this.state)
|
||||
? <CenteredContent>
|
||||
<AtlasPreviewLoading />
|
||||
</CenteredContent>
|
||||
: <this.state.Editor
|
||||
store={this.store}
|
||||
scrollTop={this.state.scrollTop}
|
||||
onScroll={this.onScroll}
|
||||
onSave={this.onSave}
|
||||
onCancel={this.onCancel}
|
||||
togglePreview={this.togglePreview}
|
||||
/>}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentEdit;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 };
|
@ -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;
|
@ -1,11 +0,0 @@
|
||||
// @flow
|
||||
export default () => {
|
||||
return new Promise(resolve => {
|
||||
// $FlowIssue this is available with webpack
|
||||
require.ensure([], () => {
|
||||
resolve({
|
||||
Editor: require('./Editor').default,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Preview from './Preview';
|
||||
export default Preview;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentEdit from './DocumentEdit';
|
||||
export default DocumentEdit;
|
@ -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;
|
@ -1,13 +0,0 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.menuGroup {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
@ -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 };
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentScene from './DocumentScene';
|
||||
export default DocumentScene;
|
@ -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[]);
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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; }
|
||||
}
|
123
frontend/styles/prism-tomorrow.scss
Normal file
123
frontend/styles/prism-tomorrow.scss
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML
|
||||
* Based on https://github.com/chriskempson/tomorrow-theme
|
||||
* @author Rose Pritchard
|
||||
*/
|
||||
|
||||
:global {
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: #ccc;
|
||||
background: none;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.attr-name,
|
||||
.token.namespace,
|
||||
.token.deleted {
|
||||
color: #e2777a;
|
||||
}
|
||||
|
||||
.token.function-name {
|
||||
color: #6196cc;
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.function {
|
||||
color: #f08d49;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.class-name,
|
||||
.token.constant,
|
||||
.token.symbol {
|
||||
color: #f8c555;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.important,
|
||||
.token.atrule,
|
||||
.token.keyword,
|
||||
.token.builtin {
|
||||
color: #cc99cd;
|
||||
}
|
||||
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.attr-value,
|
||||
.token.regex,
|
||||
.token.variable {
|
||||
color: #7ec699;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: #67cdcc;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.token.inserted {
|
||||
color: green;
|
||||
}
|
||||
}
|
@ -103,7 +103,6 @@ class ApiClient {
|
||||
};
|
||||
|
||||
// Helpers
|
||||
|
||||
constructQueryString = (data: Object) => {
|
||||
return _.map(data, (v, k) => {
|
||||
return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
|
||||
|
44
frontend/utils/uploadFile.js
Normal file
44
frontend/utils/uploadFile.js
Normal file
@ -0,0 +1,44 @@
|
||||
// @flow
|
||||
import { client } from './ApiClient';
|
||||
import invariant from 'invariant';
|
||||
|
||||
type File = {
|
||||
blob: boolean,
|
||||
type: string,
|
||||
size: number,
|
||||
name: string,
|
||||
file: string,
|
||||
};
|
||||
|
||||
export default async function uploadFile(file: File) {
|
||||
const response = await client.post('/user.s3Upload', {
|
||||
kind: file.type,
|
||||
size: file.size,
|
||||
filename: file.name,
|
||||
});
|
||||
|
||||
invariant(response, 'Response should be available');
|
||||
|
||||
const data = response.data;
|
||||
const asset = data.asset;
|
||||
const formData = new FormData();
|
||||
|
||||
for (const key in data.form) {
|
||||
formData.append(key, data.form[key]);
|
||||
}
|
||||
|
||||
if (file.blob) {
|
||||
formData.append('file', file.file);
|
||||
} else {
|
||||
// $FlowFixMe
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
const options: Object = {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
};
|
||||
await fetch(data.uploadUrl, options);
|
||||
|
||||
return asset;
|
||||
}
|
4
init.js
4
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');
|
||||
|
16
package.json
16
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",
|
||||
|
273
yarn.lock
273
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"
|
||||
|
Reference in New Issue
Block a user