This repository has been archived on 2022-08-14. You can view files and clone it, but cannot push or open issues or pull requests.
outline/app/components/Editor/Editor.js

352 lines
7.4 KiB
JavaScript

// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { Value, Change } from 'slate';
import { Editor } from 'slate-react';
import styled from 'styled-components';
import breakpoint from 'styled-components-breakpoint';
import keydown from 'react-keydown';
import type { SlateNodeProps, Plugin } from './types';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Flex from 'shared/components/Flex';
import ClickablePadding from './components/ClickablePadding';
import Toolbar from './components/Toolbar';
import BlockInsert from './components/BlockInsert';
import Placeholder from './components/Placeholder';
import Contents from './components/Contents';
import Markdown from './serializer';
import createPlugins from './plugins';
import { insertImageFile } from './changes';
import renderMark from './marks';
import createRenderNode from './nodes';
import schema from './schema';
import { isModKey } from './utils';
type Props = {
text: string,
onChange: Change => *,
onSave: ({ redirect?: boolean, publish?: boolean }) => *,
onCancel: () => void,
onImageUploadStart: () => void,
onImageUploadStop: () => void,
emoji?: string,
readOnly: boolean,
};
@observer
class MarkdownEditor extends Component {
props: Props;
editor: Editor;
renderNode: SlateNodeProps => *;
plugins: Plugin[];
@observable editorValue: Value;
@observable editorLoaded: boolean = false;
constructor(props: Props) {
super(props);
this.renderNode = createRenderNode({
onInsertImage: this.insertImageFile,
});
this.plugins = createPlugins({
onImageUploadStart: props.onImageUploadStart,
onImageUploadStop: props.onImageUploadStop,
});
this.editorValue = Markdown.deserialize(props.text);
}
componentDidMount() {
if (this.props.readOnly) return;
if (this.props.text) {
this.focusAtEnd();
} else {
this.focusAtStart();
}
}
componentDidUpdate(prevProps: Props) {
if (prevProps.readOnly && !this.props.readOnly) {
this.focusAtEnd();
}
}
setEditorRef = (ref: Editor) => {
this.editor = ref;
// Force re-render to show ToC (<Content />)
this.editorLoaded = true;
};
onChange = (change: Change) => {
if (this.editorValue !== change.value) {
this.props.onChange(Markdown.serialize(change.value));
this.editorValue = change.value;
}
};
handleDrop = async (ev: SyntheticEvent) => {
if (this.props.readOnly) return;
// check if this event was already handled by the Editor
if (ev.isDefaultPrevented()) return;
// otherwise we'll handle this
ev.preventDefault();
ev.stopPropagation();
const files = getDataTransferFiles(ev);
for (const file of files) {
if (file.type.startsWith('image/')) {
await this.insertImageFile(file);
}
}
};
insertImageFile = (file: window.File) => {
this.editor.change(change =>
change.call(
insertImageFile,
file,
this.editor,
this.props.onImageUploadStart,
this.props.onImageUploadStop
)
);
};
cancelEvent = (ev: SyntheticEvent) => {
ev.preventDefault();
};
// Handling of keyboard shortcuts outside of editor focus
@keydown('meta+s')
onSave(ev: SyntheticKeyboardEvent) {
if (this.props.readOnly) return;
ev.preventDefault();
ev.stopPropagation();
this.props.onSave({ redirect: false });
}
@keydown('meta+enter')
onSaveAndExit(ev: SyntheticKeyboardEvent) {
if (this.props.readOnly) return;
ev.preventDefault();
ev.stopPropagation();
this.props.onSave({ redirect: true });
}
@keydown('esc')
onCancel() {
if (this.props.readOnly) return;
this.props.onCancel();
}
// Handling of keyboard shortcuts within editor focus
onKeyDown = (ev: SyntheticKeyboardEvent, change: Change) => {
if (!isModKey(ev)) return;
switch (ev.key) {
case 's':
this.onSave(ev);
return change;
case 'Enter':
this.onSaveAndExit(ev);
return change;
case 'Escape':
this.onCancel();
return change;
default:
}
};
focusAtStart = () => {
this.editor.change(change =>
change.collapseToStartOf(change.value.document).focus()
);
};
focusAtEnd = () => {
this.editor.change(change =>
change.collapseToEndOf(change.value.document).focus()
);
};
render = () => {
const { readOnly, emoji, onSave } = this.props;
return (
<Flex
onDrop={this.handleDrop}
onDragOver={this.cancelEvent}
onDragEnter={this.cancelEvent}
align="flex-start"
justify="center"
auto
>
<MaxWidth column auto>
<Header onClick={this.focusAtStart} readOnly={readOnly} />
{readOnly &&
this.editorLoaded &&
this.editor && <Contents editor={this.editor} />}
{!readOnly &&
this.editor && (
<Toolbar value={this.editorValue} editor={this.editor} />
)}
{!readOnly &&
this.editor && (
<BlockInsert
editor={this.editor}
onInsertImage={this.insertImageFile}
/>
)}
<StyledEditor
innerRef={this.setEditorRef}
placeholder="Start with a title…"
bodyPlaceholder="…the rest is your canvas"
plugins={this.plugins}
emoji={emoji}
value={this.editorValue}
renderNode={this.renderNode}
renderMark={renderMark}
schema={schema}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onSave={onSave}
readOnly={readOnly}
spellCheck={!readOnly}
/>
<ClickablePadding
onClick={!readOnly ? this.focusAtEnd : undefined}
grow
/>
</MaxWidth>
</Flex>
);
};
}
const MaxWidth = styled(Flex)`
padding: 0 20px;
max-width: 100vw;
height: 100%;
${breakpoint('tablet')`
padding: 0;
margin: 0 60px;
max-width: 46em;
`};
`;
const Header = styled(Flex)`
height: 60px;
flex-shrink: 0;
align-items: flex-end;
${({ readOnly }) => !readOnly && 'cursor: text;'};
@media print {
display: none;
}
`;
const StyledEditor = styled(Editor)`
font-weight: 400;
font-size: 1em;
line-height: 1.7em;
width: 100%;
color: #1b2830;
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 500;
}
h1:first-of-type {
${Placeholder} {
visibility: visible;
}
}
p:nth-child(2) {
${Placeholder} {
visibility: visible;
}
}
ul,
ol {
margin: 0 0.1em;
padding-left: 1em;
ul,
ol {
margin: 0.1em;
}
}
p {
position: relative;
margin: 0;
}
a:hover {
text-decoration: ${({ readOnly }) => (readOnly ? 'underline' : 'none')};
}
li p {
display: inline;
margin: 0;
}
.todoList {
list-style: none;
padding-left: 0;
.todoList {
padding-left: 1em;
}
}
.todo {
span:last-child:focus {
outline: none;
}
}
blockquote {
border-left: 3px solid #efefef;
margin: 0;
padding-left: 10px;
font-style: italic;
}
table {
border-collapse: collapse;
}
tr {
border-bottom: 1px solid #eee;
}
th {
font-weight: bold;
}
th,
td {
padding: 5px 20px 5px 0;
}
b,
strong {
font-weight: 600;
}
`;
export default MarkdownEditor;