WIP
This commit is contained in:
parent
17900c6a11
commit
f72fa40a0f
@ -1,351 +0,0 @@
|
||||
// @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;
|
@ -1,93 +0,0 @@
|
||||
// @flow
|
||||
import { Change } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import uuid from 'uuid';
|
||||
import EditList from './plugins/EditList';
|
||||
import { uploadFile } from 'utils/uploadFile';
|
||||
|
||||
const { changes } = EditList;
|
||||
|
||||
type Options = {
|
||||
type: string | Object,
|
||||
wrapper?: string | Object,
|
||||
};
|
||||
|
||||
export function splitAndInsertBlock(change: Change, options: Options) {
|
||||
const { type, wrapper } = options;
|
||||
const parent = change.value.document.getParent(change.value.startBlock.key);
|
||||
|
||||
// lists get some special treatment
|
||||
if (parent && parent.type === 'list-item') {
|
||||
change
|
||||
.collapseToStart()
|
||||
.call(changes.splitListItem)
|
||||
.collapseToEndOfPreviousBlock()
|
||||
.call(changes.unwrapList);
|
||||
}
|
||||
|
||||
if (wrapper) change.collapseToStartOfNextBlock();
|
||||
|
||||
// this is a hack as insertBlock with normalize: false does not appear to work
|
||||
change.insertBlock('paragraph').setBlock(type, { normalize: false });
|
||||
|
||||
if (wrapper) change.wrapBlock(wrapper);
|
||||
return change;
|
||||
}
|
||||
|
||||
export async function insertImageFile(
|
||||
change: Change,
|
||||
file: window.File,
|
||||
editor: Editor,
|
||||
onImageUploadStart: () => void,
|
||||
onImageUploadStop: () => void
|
||||
) {
|
||||
onImageUploadStart();
|
||||
try {
|
||||
// load the file as a data URL
|
||||
const id = uuid.v4();
|
||||
const alt = '';
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => {
|
||||
const src = reader.result;
|
||||
const node = {
|
||||
type: 'image',
|
||||
isVoid: true,
|
||||
data: { src, id, alt, loading: true },
|
||||
};
|
||||
|
||||
// insert / replace into document as uploading placeholder replacing
|
||||
// empty paragraphs if available.
|
||||
if (
|
||||
!change.value.startBlock.text &&
|
||||
change.value.startBlock.type === 'paragraph'
|
||||
) {
|
||||
change.setBlock(node);
|
||||
} else {
|
||||
change.insertBlock(node);
|
||||
}
|
||||
|
||||
editor.onChange(change);
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// now we have a placeholder, start the upload
|
||||
const asset = await uploadFile(file);
|
||||
const src = asset.url;
|
||||
|
||||
// we dont use the original change provided to the callback here
|
||||
// as the state may have changed significantly in the time it took to
|
||||
// upload the file.
|
||||
const placeholder = editor.value.document.findDescendant(
|
||||
node => node.data && node.data.get('id') === id
|
||||
);
|
||||
|
||||
change.setNodeByKey(placeholder.key, {
|
||||
data: { src, alt, loading: false },
|
||||
});
|
||||
editor.onChange(change);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
onImageUploadStop();
|
||||
}
|
||||
}
|
@ -1,166 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { Portal } from 'react-portal';
|
||||
import { Node } from 'slate';
|
||||
import { Editor, findDOMNode } from 'slate-react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import PlusIcon from 'components/Icon/PlusIcon';
|
||||
|
||||
type Props = {
|
||||
editor: Editor,
|
||||
};
|
||||
|
||||
function findClosestRootNode(value, ev) {
|
||||
let previous;
|
||||
|
||||
for (const node of value.document.nodes) {
|
||||
const element = findDOMNode(node);
|
||||
const bounds = element.getBoundingClientRect();
|
||||
if (bounds.top > ev.clientY) return previous;
|
||||
previous = { node, element, bounds };
|
||||
}
|
||||
|
||||
return previous;
|
||||
}
|
||||
|
||||
@observer
|
||||
export default class BlockInsert extends Component {
|
||||
props: Props;
|
||||
mouseMoveTimeout: number;
|
||||
mouseMovementSinceClick: number = 0;
|
||||
lastClientX: number = 0;
|
||||
lastClientY: number = 0;
|
||||
|
||||
@observable closestRootNode: Node;
|
||||
@observable active: boolean = false;
|
||||
@observable top: number;
|
||||
@observable left: number;
|
||||
|
||||
componentDidMount = () => {
|
||||
window.addEventListener('mousemove', this.handleMouseMove);
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener('mousemove', this.handleMouseMove);
|
||||
};
|
||||
|
||||
setInactive = () => {
|
||||
this.active = false;
|
||||
};
|
||||
|
||||
handleMouseMove = (ev: SyntheticMouseEvent) => {
|
||||
const windowWidth = window.innerWidth / 2.5;
|
||||
const result = findClosestRootNode(this.props.editor.value, ev);
|
||||
const movementThreshold = 200;
|
||||
|
||||
this.mouseMovementSinceClick +=
|
||||
Math.abs(this.lastClientX - ev.clientX) +
|
||||
Math.abs(this.lastClientY - ev.clientY);
|
||||
this.lastClientX = ev.clientX;
|
||||
this.lastClientY = ev.clientY;
|
||||
|
||||
this.active =
|
||||
ev.clientX < windowWidth &&
|
||||
this.mouseMovementSinceClick > movementThreshold;
|
||||
|
||||
if (result) {
|
||||
this.closestRootNode = result.node;
|
||||
|
||||
// do not show block menu on title heading or editor
|
||||
const firstNode = this.props.editor.value.document.nodes.first();
|
||||
if (
|
||||
result.node === firstNode ||
|
||||
result.node.type === 'block-toolbar' ||
|
||||
!!result.node.text.trim()
|
||||
) {
|
||||
this.left = -1000;
|
||||
} else {
|
||||
this.left = Math.round(result.bounds.left - 20);
|
||||
this.top = Math.round(result.bounds.top + window.scrollY);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.active) {
|
||||
clearTimeout(this.mouseMoveTimeout);
|
||||
this.mouseMoveTimeout = setTimeout(this.setInactive, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = (ev: SyntheticMouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.mouseMovementSinceClick = 0;
|
||||
this.active = false;
|
||||
|
||||
const { editor } = this.props;
|
||||
|
||||
editor.change(change => {
|
||||
// remove any existing toolbars in the document as a fail safe
|
||||
editor.value.document.nodes.forEach(node => {
|
||||
if (node.type === 'block-toolbar') {
|
||||
change.removeNodeByKey(node.key);
|
||||
}
|
||||
});
|
||||
|
||||
change.collapseToStartOf(this.closestRootNode);
|
||||
|
||||
// if we're on an empty paragraph then just replace it with the block
|
||||
// toolbar. Otherwise insert the toolbar as an extra Node.
|
||||
if (
|
||||
!this.closestRootNode.text.trim() &&
|
||||
this.closestRootNode.type === 'paragraph'
|
||||
) {
|
||||
change.setNodeByKey(this.closestRootNode.key, {
|
||||
type: 'block-toolbar',
|
||||
isVoid: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const style = { top: `${this.top}px`, left: `${this.left}px` };
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Trigger active={this.active} style={style}>
|
||||
<PlusIcon onClick={this.handleClick} color={color.slate} />
|
||||
</Trigger>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Trigger = styled.div`
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
background-color: ${color.white};
|
||||
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
line-height: 0;
|
||||
margin-left: -10px;
|
||||
box-shadow: inset 0 0 0 2px ${color.slate};
|
||||
border-radius: 100%;
|
||||
transform: scale(0.9);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${color.slate};
|
||||
|
||||
svg {
|
||||
fill: ${color.white};
|
||||
}
|
||||
}
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
`
|
||||
transform: scale(1);
|
||||
opacity: .9;
|
||||
`};
|
||||
`;
|
@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
onClick?: ?Function,
|
||||
grow?: boolean,
|
||||
};
|
||||
|
||||
const ClickablePadding = (props: Props) => {
|
||||
return <Container grow={props.grow} onClick={props.onClick} />;
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
min-height: 50vh;
|
||||
padding-top: 50px;
|
||||
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
||||
|
||||
${({ grow }) => grow && `flex-grow: 1;`};
|
||||
`;
|
||||
|
||||
export default ClickablePadding;
|
@ -1,52 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import CopyButton from './CopyButton';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
function getCopyText(node) {
|
||||
return node.nodes.reduce((memo, line) => `${memo}${line.text}\n`, '');
|
||||
}
|
||||
|
||||
export default function Code({
|
||||
children,
|
||||
node,
|
||||
readOnly,
|
||||
attributes,
|
||||
}: SlateNodeProps) {
|
||||
// TODO: There is a currently a bug in slate-prism that prevents code elements
|
||||
// with a language class name from formatting correctly on first load.
|
||||
// const language = node.data.get('language') || 'javascript';
|
||||
|
||||
return (
|
||||
<Container {...attributes} spellCheck={false}>
|
||||
{readOnly && <CopyButton text={getCopyText(node)} />}
|
||||
<code>{children}</code>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
position: relative;
|
||||
background: ${color.smokeLight};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.smokeDark};
|
||||
|
||||
code {
|
||||
display: block;
|
||||
overflow-x: scroll;
|
||||
padding: 0.5em 1em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
@ -1,163 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Editor } from 'slate-react';
|
||||
import { Block } from 'slate';
|
||||
import { List } from 'immutable';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import headingToSlug from '../headingToSlug';
|
||||
|
||||
type Props = {
|
||||
editor: Editor,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Contents extends Component {
|
||||
props: Props;
|
||||
@observable activeHeading: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('scroll', this.updateActiveHeading);
|
||||
this.updateActiveHeading();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('scroll', this.updateActiveHeading);
|
||||
}
|
||||
|
||||
updateActiveHeading = () => {
|
||||
const elements = this.headingElements;
|
||||
if (!elements.length) return;
|
||||
|
||||
let activeHeading = elements[0].id;
|
||||
|
||||
for (const element of elements) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
if (bounds.top <= 0) activeHeading = element.id;
|
||||
}
|
||||
|
||||
this.activeHeading = activeHeading;
|
||||
};
|
||||
|
||||
get headingElements(): HTMLElement[] {
|
||||
const elements = [];
|
||||
const tagNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
||||
|
||||
for (const tagName of tagNames) {
|
||||
for (const ele of document.getElementsByTagName(tagName)) {
|
||||
elements.push(ele);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
get headings(): List<Block> {
|
||||
const { editor } = this.props;
|
||||
|
||||
return editor.value.document.nodes.filter((node: Block) => {
|
||||
if (!node.text) return false;
|
||||
return node.type.match(/^heading/);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { editor } = this.props;
|
||||
|
||||
// If there are one or less headings in the document no need for a minimap
|
||||
if (this.headings.size <= 1) return null;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Sections>
|
||||
{this.headings.map(heading => {
|
||||
const slug = headingToSlug(editor.value.document, heading);
|
||||
const active = this.activeHeading === slug;
|
||||
|
||||
return (
|
||||
<ListItem type={heading.type} active={active} key={slug}>
|
||||
<Anchor href={`#${slug}`} active={active}>
|
||||
{heading.text}
|
||||
</Anchor>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</Sections>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: none;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 150px;
|
||||
z-index: 100;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint('tablet')`
|
||||
display: block;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Anchor = styled.a`
|
||||
color: ${props => (props.active ? color.slateDark : color.slate)};
|
||||
font-weight: ${props => (props.active ? 500 : 400)};
|
||||
opacity: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
margin-right: -5px;
|
||||
padding: 2px 0;
|
||||
pointer-events: none;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
color: ${color.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
position: relative;
|
||||
margin-left: ${props => (props.type.match(/heading[12]/) ? '8px' : '16px')};
|
||||
text-align: right;
|
||||
color: ${color.slate};
|
||||
padding-right: 16px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:after {
|
||||
color: ${props => (props.active ? color.slateDark : color.slate)};
|
||||
content: "${props => (props.type.match(/heading[12]/) ? '—' : '–')}";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Sections = styled.ol`
|
||||
margin: 0 0 0 -8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
width: 100px;
|
||||
transition-delay: 1s;
|
||||
transition: width 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
width: 300px;
|
||||
transition-delay: 0s;
|
||||
|
||||
${Anchor} {
|
||||
opacity: 1;
|
||||
margin-right: 0;
|
||||
background: ${color.white};
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Contents;
|
@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import styled from 'styled-components';
|
||||
import CopyToClipboard from 'components/CopyToClipboard';
|
||||
|
||||
@observer
|
||||
class CopyButton extends Component {
|
||||
@observable copied: boolean = false;
|
||||
copiedTimeout: ?number;
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.copiedTimeout);
|
||||
}
|
||||
|
||||
handleCopy = () => {
|
||||
this.copied = true;
|
||||
this.copiedTimeout = setTimeout(() => (this.copied = false), 3000);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<StyledCopyToClipboard onCopy={this.handleCopy} {...this.props}>
|
||||
<span>{this.copied ? 'Copied!' : 'Copy'}</span>
|
||||
</StyledCopyToClipboard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledCopyToClipboard = styled(CopyToClipboard)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 50ms ease-in-out;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
background: ${color.smoke};
|
||||
border-radius: 0 2px 0 2px;
|
||||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${color.smokeDark};
|
||||
}
|
||||
`;
|
||||
|
||||
export default CopyButton;
|
@ -1,97 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Document } from 'slate';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import styled from 'styled-components';
|
||||
import headingToSlug from '../headingToSlug';
|
||||
import Placeholder from './Placeholder';
|
||||
|
||||
type Props = SlateNodeProps & {
|
||||
component: string,
|
||||
className: string,
|
||||
placeholder: string,
|
||||
};
|
||||
|
||||
function Heading(props: Props) {
|
||||
const {
|
||||
parent,
|
||||
placeholder,
|
||||
node,
|
||||
editor,
|
||||
readOnly,
|
||||
children,
|
||||
component = 'h1',
|
||||
className,
|
||||
attributes,
|
||||
} = props;
|
||||
const parentIsDocument = parent instanceof Document;
|
||||
const firstHeading = parentIsDocument && parent.nodes.first() === node;
|
||||
const showPlaceholder = placeholder && firstHeading && !node.text;
|
||||
const slugish = headingToSlug(editor.value.document, node);
|
||||
const showHash = readOnly && !!slugish;
|
||||
const Component = component;
|
||||
const emoji = editor.props.emoji || '';
|
||||
const title = node.text.trim();
|
||||
const startsWithEmojiAndSpace =
|
||||
emoji && title.match(new RegExp(`^${emoji}\\s`));
|
||||
|
||||
return (
|
||||
<Component {...attributes} id={slugish} className={className}>
|
||||
<Wrapper hasEmoji={startsWithEmojiAndSpace}>{children}</Wrapper>
|
||||
{showPlaceholder && (
|
||||
<Placeholder contentEditable={false}>
|
||||
{editor.props.placeholder}
|
||||
</Placeholder>
|
||||
)}
|
||||
{showHash && (
|
||||
<Anchor name={slugish} href={`#${slugish}`}>
|
||||
#
|
||||
</Anchor>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: inline;
|
||||
margin-left: ${(props: SlateNodeProps) => (props.hasEmoji ? '-1.2em' : 0)};
|
||||
`;
|
||||
|
||||
const Anchor = styled.a`
|
||||
visibility: hidden;
|
||||
padding-left: 0.25em;
|
||||
color: #dedede;
|
||||
|
||||
&:hover {
|
||||
color: #cdcdcd;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledHeading = styled(Heading)`
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
${Anchor} {
|
||||
visibility: visible;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const Heading1 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h1" {...props} />
|
||||
);
|
||||
export const Heading2 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h2" {...props} />
|
||||
);
|
||||
export const Heading3 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h3" {...props} />
|
||||
);
|
||||
export const Heading4 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h4" {...props} />
|
||||
);
|
||||
export const Heading5 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h5" {...props} />
|
||||
);
|
||||
export const Heading6 = (props: SlateNodeProps) => (
|
||||
<StyledHeading component="h6" {...props} />
|
||||
);
|
@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
function HorizontalRule(props: SlateNodeProps) {
|
||||
const { editor, node, attributes } = props;
|
||||
const active =
|
||||
editor.value.isFocused && editor.value.selection.hasEdgeIn(node);
|
||||
return <StyledHr active={active} {...attributes} />;
|
||||
}
|
||||
|
||||
const StyledHr = styled.hr`
|
||||
padding-top: 0.75em;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid
|
||||
${props => (props.active ? color.slate : color.slateLight)};
|
||||
`;
|
||||
|
||||
export default HorizontalRule;
|
@ -1,104 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import ImageZoom from 'react-medium-image-zoom';
|
||||
import styled from 'styled-components';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
class Image extends Component {
|
||||
props: SlateNodeProps;
|
||||
|
||||
handleChange = (ev: SyntheticInputEvent) => {
|
||||
const alt = ev.target.value;
|
||||
const { editor, node } = this.props;
|
||||
const data = node.data.toObject();
|
||||
|
||||
editor.change(change =>
|
||||
change.setNodeByKey(node.key, { data: { ...data, alt } })
|
||||
);
|
||||
};
|
||||
|
||||
handleClick = (ev: SyntheticInputEvent) => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { attributes, editor, node, readOnly } = this.props;
|
||||
const loading = node.data.get('loading');
|
||||
const caption = node.data.get('alt');
|
||||
const src = node.data.get('src');
|
||||
const active =
|
||||
editor.value.isFocused && editor.value.selection.hasEdgeIn(node);
|
||||
const showCaption = !readOnly || caption;
|
||||
|
||||
return (
|
||||
<CenteredImage>
|
||||
{!readOnly ? (
|
||||
<StyledImg
|
||||
{...attributes}
|
||||
src={src}
|
||||
alt={caption}
|
||||
active={active}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<ImageZoom
|
||||
image={{
|
||||
src,
|
||||
alt: caption,
|
||||
style: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
...attributes,
|
||||
}}
|
||||
shouldRespectMaxDimension
|
||||
/>
|
||||
)}
|
||||
{showCaption && (
|
||||
<Caption
|
||||
type="text"
|
||||
placeholder="Write a caption"
|
||||
onChange={this.handleChange}
|
||||
onClick={this.handleClick}
|
||||
defaultValue={caption}
|
||||
contentEditable={false}
|
||||
disabled={readOnly}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
</CenteredImage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledImg = styled.img`
|
||||
max-width: 100%;
|
||||
box-shadow: ${props => (props.active ? `0 0 0 2px ${color.slate}` : '0')};
|
||||
border-radius: ${props => (props.active ? `2px` : '0')};
|
||||
opacity: ${props => (props.loading ? 0.5 : 1)};
|
||||
`;
|
||||
|
||||
const CenteredImage = styled.span`
|
||||
display: block;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Caption = styled.input`
|
||||
border: 0;
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: ${color.slate};
|
||||
padding: 2px 0;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
background: none;
|
||||
|
||||
&::placeholder {
|
||||
color: ${color.slate};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Image;
|
@ -1,14 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
|
||||
const InlineCode = styled.code.attrs({
|
||||
spellCheck: false,
|
||||
})`
|
||||
padding: 0.25em;
|
||||
background: ${color.smoke};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.smokeDark};
|
||||
`;
|
||||
|
||||
export default InlineCode;
|
@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Link as InternalLink } from 'react-router-dom';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
|
||||
function getPathFromUrl(href: string) {
|
||||
if (href[0] === '/') return href;
|
||||
|
||||
try {
|
||||
const parsed = new URL(href);
|
||||
return parsed.pathname;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalUrl(href: string) {
|
||||
if (href[0] === '/') return true;
|
||||
|
||||
try {
|
||||
const outline = new URL(BASE_URL);
|
||||
const parsed = new URL(href);
|
||||
return parsed.hostname === outline.hostname;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Link({
|
||||
attributes,
|
||||
node,
|
||||
children,
|
||||
readOnly,
|
||||
}: SlateNodeProps) {
|
||||
const href = node.data.get('href');
|
||||
const path = getPathFromUrl(href);
|
||||
|
||||
if (isInternalUrl(href) && readOnly) {
|
||||
return (
|
||||
<InternalLink {...attributes} to={path}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<a {...attributes} href={readOnly ? href : undefined} target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import TodoItem from './TodoItem';
|
||||
|
||||
export default function ListItem({
|
||||
children,
|
||||
node,
|
||||
attributes,
|
||||
...props
|
||||
}: SlateNodeProps) {
|
||||
const checked = node.data.get('checked');
|
||||
|
||||
if (checked !== undefined) {
|
||||
return (
|
||||
<TodoItem node={node} attributes={attributes} {...props}>
|
||||
{children}
|
||||
</TodoItem>
|
||||
);
|
||||
}
|
||||
return <li {...attributes}>{children}</li>;
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Document } from 'slate';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
import Placeholder from './Placeholder';
|
||||
|
||||
export default function Link({
|
||||
attributes,
|
||||
editor,
|
||||
node,
|
||||
parent,
|
||||
children,
|
||||
readOnly,
|
||||
}: SlateNodeProps) {
|
||||
const parentIsDocument = parent instanceof Document;
|
||||
const firstParagraph = parent && parent.nodes.get(1) === node;
|
||||
const lastParagraph = parent && parent.nodes.last() === node;
|
||||
const showPlaceholder =
|
||||
!readOnly &&
|
||||
parentIsDocument &&
|
||||
firstParagraph &&
|
||||
lastParagraph &&
|
||||
!node.text;
|
||||
|
||||
return (
|
||||
<p {...attributes}>
|
||||
{children}
|
||||
{showPlaceholder && (
|
||||
<Placeholder contentEditable={false}>
|
||||
{editor.props.bodyPlaceholder}
|
||||
</Placeholder>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
export default styled.span`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
color: #b1becc;
|
||||
`;
|
@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import type { SlateNodeProps } from '../types';
|
||||
|
||||
export default class TodoItem extends Component {
|
||||
props: SlateNodeProps;
|
||||
|
||||
handleChange = (ev: SyntheticInputEvent) => {
|
||||
const checked = ev.target.checked;
|
||||
const { editor, node } = this.props;
|
||||
editor.change(change =>
|
||||
change.setNodeByKey(node.key, { data: { checked } })
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, node, attributes, readOnly } = this.props;
|
||||
const checked = node.data.get('checked');
|
||||
|
||||
return (
|
||||
<ListItem checked={checked} {...attributes}>
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={this.handleChange}
|
||||
disabled={readOnly}
|
||||
contentEditable={false}
|
||||
/>
|
||||
{children}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ListItem = styled.li`
|
||||
padding-left: 1.4em;
|
||||
position: relative;
|
||||
|
||||
> p > span {
|
||||
color: ${props => (props.checked ? color.slateDark : 'inherit')};
|
||||
text-decoration: ${props => (props.checked ? 'line-through' : 'none')};
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.4em;
|
||||
`;
|
@ -1,13 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
const TodoList = styled.ul`
|
||||
list-style: none;
|
||||
padding: 0 !important;
|
||||
|
||||
ul {
|
||||
padding-left: 1em;
|
||||
}
|
||||
`;
|
||||
|
||||
export default TodoList;
|
@ -1,220 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import keydown from 'react-keydown';
|
||||
import styled from 'styled-components';
|
||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||
import Heading1Icon from 'components/Icon/Heading1Icon';
|
||||
import Heading2Icon from 'components/Icon/Heading2Icon';
|
||||
import BlockQuoteIcon from 'components/Icon/BlockQuoteIcon';
|
||||
import ImageIcon from 'components/Icon/ImageIcon';
|
||||
import CodeIcon from 'components/Icon/CodeIcon';
|
||||
import BulletedListIcon from 'components/Icon/BulletedListIcon';
|
||||
import OrderedListIcon from 'components/Icon/OrderedListIcon';
|
||||
import HorizontalRuleIcon from 'components/Icon/HorizontalRuleIcon';
|
||||
import TodoListIcon from 'components/Icon/TodoListIcon';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import ToolbarButton from './components/ToolbarButton';
|
||||
import type { SlateNodeProps } from '../../types';
|
||||
import { color } from 'shared/styles/constants';
|
||||
import { fadeIn } from 'shared/styles/animations';
|
||||
import { splitAndInsertBlock } from '../../changes';
|
||||
|
||||
type Props = SlateNodeProps & {
|
||||
onInsertImage: *,
|
||||
};
|
||||
|
||||
type Options = {
|
||||
type: string | Object,
|
||||
wrapper?: string | Object,
|
||||
};
|
||||
|
||||
class BlockToolbar extends Component {
|
||||
props: Props;
|
||||
bar: HTMLDivElement;
|
||||
file: HTMLInputElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('click', this.handleOutsideMouseClick);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.handleOutsideMouseClick);
|
||||
}
|
||||
|
||||
handleOutsideMouseClick = (ev: SyntheticMouseEvent) => {
|
||||
const element = findDOMNode(this.bar);
|
||||
|
||||
if (
|
||||
!element ||
|
||||
(ev.target instanceof Node && element.contains(ev.target)) ||
|
||||
(ev.button && ev.button !== 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.removeSelf(ev);
|
||||
};
|
||||
|
||||
@keydown('esc')
|
||||
removeSelf(ev: SyntheticEvent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.props.editor.change(change =>
|
||||
change.setNodeByKey(this.props.node.key, {
|
||||
type: 'paragraph',
|
||||
text: '',
|
||||
isVoid: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
insertBlock = (
|
||||
options: Options,
|
||||
cursorPosition: 'before' | 'on' | 'after' = 'on'
|
||||
) => {
|
||||
const { editor } = this.props;
|
||||
|
||||
editor.change(change => {
|
||||
change
|
||||
.collapseToEndOf(this.props.node)
|
||||
.call(splitAndInsertBlock, options)
|
||||
.removeNodeByKey(this.props.node.key)
|
||||
.collapseToEnd();
|
||||
|
||||
if (cursorPosition === 'before') change.collapseToStartOfPreviousBlock();
|
||||
if (cursorPosition === 'after') change.collapseToStartOfNextBlock();
|
||||
return change.focus();
|
||||
});
|
||||
};
|
||||
|
||||
handleClickBlock = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
|
||||
switch (type) {
|
||||
case 'heading1':
|
||||
case 'heading2':
|
||||
case 'block-quote':
|
||||
case 'code':
|
||||
return this.insertBlock({ type });
|
||||
case 'horizontal-rule':
|
||||
return this.insertBlock(
|
||||
{
|
||||
type: { type: 'horizontal-rule', isVoid: true },
|
||||
},
|
||||
'after'
|
||||
);
|
||||
case 'bulleted-list':
|
||||
return this.insertBlock({
|
||||
type: 'list-item',
|
||||
wrapper: 'bulleted-list',
|
||||
});
|
||||
case 'ordered-list':
|
||||
return this.insertBlock({
|
||||
type: 'list-item',
|
||||
wrapper: 'ordered-list',
|
||||
});
|
||||
case 'todo-list':
|
||||
return this.insertBlock({
|
||||
type: { type: 'list-item', data: { checked: false } },
|
||||
wrapper: 'todo-list',
|
||||
});
|
||||
case 'image':
|
||||
return this.onPickImage();
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
onPickImage = () => {
|
||||
// simulate a click on the file upload input element
|
||||
this.file.click();
|
||||
};
|
||||
|
||||
onImagePicked = async (ev: SyntheticEvent) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
for (const file of files) {
|
||||
await this.props.onInsertImage(file);
|
||||
}
|
||||
};
|
||||
|
||||
renderBlockButton = (type: string, IconClass: Function) => {
|
||||
return (
|
||||
<ToolbarButton onMouseDown={ev => this.handleClickBlock(ev, type)}>
|
||||
<IconClass color={color.text} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { editor, attributes, node } = this.props;
|
||||
const active =
|
||||
editor.value.isFocused && editor.value.selection.hasEdgeIn(node);
|
||||
|
||||
return (
|
||||
<Bar active={active} {...attributes} ref={ref => (this.bar = ref)}>
|
||||
<HiddenInput
|
||||
type="file"
|
||||
innerRef={ref => (this.file = ref)}
|
||||
onChange={this.onImagePicked}
|
||||
accept="image/*"
|
||||
/>
|
||||
{this.renderBlockButton('heading1', Heading1Icon)}
|
||||
{this.renderBlockButton('heading2', Heading2Icon)}
|
||||
<Separator />
|
||||
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
|
||||
{this.renderBlockButton('ordered-list', OrderedListIcon)}
|
||||
{this.renderBlockButton('todo-list', TodoListIcon)}
|
||||
<Separator />
|
||||
{this.renderBlockButton('block-quote', BlockQuoteIcon)}
|
||||
{this.renderBlockButton('code', CodeIcon)}
|
||||
{this.renderBlockButton('horizontal-rule', HorizontalRuleIcon)}
|
||||
{this.renderBlockButton('image', ImageIcon)}
|
||||
</Bar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Separator = styled.div`
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: ${color.smokeDark};
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const Bar = styled(Flex)`
|
||||
z-index: 100;
|
||||
animation: ${fadeIn} 150ms ease-in-out;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
background: ${color.smoke};
|
||||
height: 44px;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: ${color.smoke};
|
||||
}
|
||||
|
||||
&:after {
|
||||
left: auto;
|
||||
right: -100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const HiddenInput = styled.input`
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export default BlockToolbar;
|
@ -1,186 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Portal } from 'react-portal';
|
||||
import { Editor, findDOMNode } from 'slate-react';
|
||||
import { Node, Value } from 'slate';
|
||||
import styled from 'styled-components';
|
||||
import FormattingToolbar from './components/FormattingToolbar';
|
||||
import LinkToolbar from './components/LinkToolbar';
|
||||
|
||||
function getLinkInSelection(value): any {
|
||||
try {
|
||||
const selectedLinks = value.document
|
||||
.getInlinesAtRange(value.selection)
|
||||
.filter(node => node.type === 'link');
|
||||
|
||||
if (selectedLinks.size) {
|
||||
const link = selectedLinks.first();
|
||||
if (value.selection.hasEdgeIn(link)) return link;
|
||||
}
|
||||
} catch (err) {
|
||||
// It's okay.
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
export default class Toolbar extends Component {
|
||||
@observable active: boolean = false;
|
||||
@observable link: ?Node;
|
||||
@observable top: string = '';
|
||||
@observable left: string = '';
|
||||
@observable mouseDown: boolean = false;
|
||||
|
||||
props: {
|
||||
editor: Editor,
|
||||
value: Value,
|
||||
};
|
||||
|
||||
menu: HTMLElement;
|
||||
|
||||
componentDidMount = () => {
|
||||
this.update();
|
||||
window.addEventListener('mousedown', this.handleMouseDown);
|
||||
window.addEventListener('mouseup', this.handleMouseUp);
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener('mousedown', this.handleMouseDown);
|
||||
window.removeEventListener('mouseup', this.handleMouseUp);
|
||||
};
|
||||
|
||||
componentDidUpdate = () => {
|
||||
this.update();
|
||||
};
|
||||
|
||||
hideLinkToolbar = () => {
|
||||
this.link = undefined;
|
||||
};
|
||||
|
||||
handleMouseDown = (e: SyntheticMouseEvent) => {
|
||||
this.mouseDown = true;
|
||||
};
|
||||
|
||||
handleMouseUp = (e: SyntheticMouseEvent) => {
|
||||
this.mouseDown = false;
|
||||
this.update();
|
||||
};
|
||||
|
||||
showLinkToolbar = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const link = getLinkInSelection(this.props.value);
|
||||
this.link = link;
|
||||
};
|
||||
|
||||
update = () => {
|
||||
const { value } = this.props;
|
||||
const link = getLinkInSelection(value);
|
||||
|
||||
if (value.isBlurred || (value.isCollapsed && !link)) {
|
||||
if (this.active && !this.link) {
|
||||
this.active = false;
|
||||
this.link = undefined;
|
||||
this.top = '';
|
||||
this.left = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// don't display toolbar for document title
|
||||
const firstNode = value.document.nodes.first();
|
||||
if (firstNode === value.startBlock) return;
|
||||
|
||||
// don't display toolbar for code blocks, code-lines inline code.
|
||||
if (value.startBlock.type.match(/code/)) return;
|
||||
|
||||
// don't show until user has released pointing device button
|
||||
if (this.mouseDown) return;
|
||||
|
||||
this.active = true;
|
||||
this.link = this.link || link;
|
||||
|
||||
const padding = 16;
|
||||
const selection = window.getSelection();
|
||||
let rect;
|
||||
|
||||
if (link) {
|
||||
rect = findDOMNode(link).getBoundingClientRect();
|
||||
} else if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
rect = range.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (!rect || (rect.top === 0 && rect.left === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const left =
|
||||
rect.left + window.scrollX - this.menu.offsetWidth / 2 + rect.width / 2;
|
||||
this.top = `${Math.round(
|
||||
rect.top + window.scrollY - this.menu.offsetHeight
|
||||
)}px`;
|
||||
this.left = `${Math.round(Math.max(padding, left))}px`;
|
||||
};
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.menu = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
top: this.top,
|
||||
left: this.left,
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Menu active={this.active} innerRef={this.setRef} style={style}>
|
||||
{this.link ? (
|
||||
<LinkToolbar
|
||||
{...this.props}
|
||||
link={this.link}
|
||||
onBlur={this.hideLinkToolbar}
|
||||
/>
|
||||
) : (
|
||||
<FormattingToolbar
|
||||
onCreateLink={this.showLinkToolbar}
|
||||
{...this.props}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Menu = styled.div`
|
||||
padding: 8px 16px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
background-color: #2f3336;
|
||||
border-radius: 4px;
|
||||
transform: scale(0.95);
|
||||
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 250ms;
|
||||
line-height: 0;
|
||||
height: 40px;
|
||||
min-width: 300px;
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
`
|
||||
transform: translateY(-6px) scale(1);
|
||||
opacity: 1;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
@ -1,51 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { fontWeight, color } from 'shared/styles/constants';
|
||||
import Document from 'models/Document';
|
||||
import NextIcon from 'components/Icon/NextIcon';
|
||||
|
||||
type Props = {
|
||||
innerRef?: Function,
|
||||
onClick: SyntheticEvent => void,
|
||||
document: Document,
|
||||
};
|
||||
|
||||
function DocumentResult({ document, ...rest }: Props) {
|
||||
return (
|
||||
<ListItem {...rest} href="">
|
||||
<i>
|
||||
<NextIcon light />
|
||||
</i>
|
||||
{document.title}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const ListItem = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 6px 8px 6px 0;
|
||||
color: ${color.white};
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
font-weight: ${fontWeight.medium};
|
||||
outline: none;
|
||||
|
||||
i {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentResult;
|
@ -1,115 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Editor } from 'slate-react';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
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 ItalicIcon from 'components/Icon/ItalicIcon';
|
||||
import BlockQuoteIcon from 'components/Icon/BlockQuoteIcon';
|
||||
import LinkIcon from 'components/Icon/LinkIcon';
|
||||
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
|
||||
|
||||
class FormattingToolbar extends Component {
|
||||
props: {
|
||||
editor: Editor,
|
||||
onCreateLink: SyntheticEvent => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current selection has a mark with `type` in it.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {Boolean}
|
||||
*/
|
||||
hasMark = (type: string) => {
|
||||
return this.props.editor.value.marks.some(mark => mark.type === type);
|
||||
};
|
||||
|
||||
isBlock = (type: string) => {
|
||||
const startBlock = this.props.editor.value.startBlock;
|
||||
return startBlock && 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();
|
||||
this.props.editor.change(change => change.toggleMark(type));
|
||||
};
|
||||
|
||||
onClickBlock = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
this.props.editor.change(change => change.setBlock(type));
|
||||
};
|
||||
|
||||
handleCreateLink = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const data = { href: '' };
|
||||
this.props.editor.change(change => {
|
||||
change.wrapInline({ type: 'link', data });
|
||||
this.props.onCreateLink(ev);
|
||||
});
|
||||
};
|
||||
|
||||
renderMarkButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.hasMark(type);
|
||||
const onMouseDown = ev => this.onClickMark(ev, type);
|
||||
|
||||
return (
|
||||
<ToolbarButton onMouseDown={onMouseDown} active={isActive}>
|
||||
<IconClass light />
|
||||
</ToolbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
renderBlockButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.isBlock(type);
|
||||
const onMouseDown = ev =>
|
||||
this.onClickBlock(ev, isActive ? 'paragraph' : type);
|
||||
|
||||
return (
|
||||
<ToolbarButton onMouseDown={onMouseDown} active={isActive}>
|
||||
<IconClass light />
|
||||
</ToolbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span>
|
||||
{this.renderMarkButton('bold', BoldIcon)}
|
||||
{this.renderMarkButton('italic', ItalicIcon)}
|
||||
{this.renderMarkButton('deleted', StrikethroughIcon)}
|
||||
{this.renderMarkButton('code', CodeIcon)}
|
||||
<Separator />
|
||||
{this.renderBlockButton('heading1', Heading1Icon)}
|
||||
{this.renderBlockButton('heading2', Heading2Icon)}
|
||||
{this.renderBlockButton('block-quote', BlockQuoteIcon)}
|
||||
<Separator />
|
||||
<ToolbarButton onMouseDown={this.handleCreateLink}>
|
||||
<LinkIcon light />
|
||||
</ToolbarButton>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Separator = styled.div`
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: #fff;
|
||||
opacity: 0.2;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
export default FormattingToolbar;
|
@ -1,231 +0,0 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { observable, action } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Node } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import styled from 'styled-components';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import DocumentResult from './DocumentResult';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import keydown from 'react-keydown';
|
||||
import CloseIcon from 'components/Icon/CloseIcon';
|
||||
import OpenIcon from 'components/Icon/OpenIcon';
|
||||
import TrashIcon from 'components/Icon/TrashIcon';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
@keydown
|
||||
@observer
|
||||
class LinkToolbar extends Component {
|
||||
wrapper: HTMLSpanElement;
|
||||
input: HTMLInputElement;
|
||||
firstDocument: HTMLElement;
|
||||
|
||||
props: {
|
||||
editor: Editor,
|
||||
link: Node,
|
||||
documents: DocumentsStore,
|
||||
onBlur: () => *,
|
||||
};
|
||||
|
||||
originalValue: string = '';
|
||||
@observable isEditing: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable resultIds: string[] = [];
|
||||
@observable searchTerm: ?string = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.originalValue = this.props.link.data.get('href');
|
||||
this.isEditing = !!this.originalValue;
|
||||
|
||||
setImmediate(() =>
|
||||
window.addEventListener('click', this.handleOutsideMouseClick)
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.handleOutsideMouseClick);
|
||||
}
|
||||
|
||||
handleOutsideMouseClick = (ev: SyntheticMouseEvent) => {
|
||||
const element = findDOMNode(this.wrapper);
|
||||
|
||||
if (
|
||||
!element ||
|
||||
(ev.target instanceof HTMLElement && element.contains(ev.target)) ||
|
||||
(ev.button && ev.button !== 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
this.save(this.input.value);
|
||||
};
|
||||
|
||||
@action
|
||||
search = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
if (this.searchTerm) {
|
||||
try {
|
||||
this.resultIds = await this.props.documents.search(this.searchTerm);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
} else {
|
||||
this.resultIds = [];
|
||||
}
|
||||
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
selectDocument = (ev, document) => {
|
||||
ev.preventDefault();
|
||||
this.save(document.url);
|
||||
};
|
||||
|
||||
onKeyDown = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
|
||||
switch (ev.keyCode) {
|
||||
case 13: // enter
|
||||
ev.preventDefault();
|
||||
return this.save(ev.target.value);
|
||||
case 27: // escape
|
||||
return this.save(this.originalValue);
|
||||
case 40: // down
|
||||
ev.preventDefault();
|
||||
if (this.firstDocument) {
|
||||
const element = findDOMNode(this.firstDocument);
|
||||
if (element instanceof HTMLElement) element.focus();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
|
||||
try {
|
||||
new URL(ev.target.value);
|
||||
} catch (err) {
|
||||
// this is not a valid url, show search suggestions
|
||||
this.searchTerm = ev.target.value;
|
||||
this.search();
|
||||
return;
|
||||
}
|
||||
this.resultIds = [];
|
||||
};
|
||||
|
||||
removeLink = () => {
|
||||
this.save('');
|
||||
};
|
||||
|
||||
openLink = () => {
|
||||
const href = this.props.link.data.get('href');
|
||||
window.open(href, '_blank');
|
||||
};
|
||||
|
||||
save = (href: string) => {
|
||||
const { editor, link } = this.props;
|
||||
href = href.trim();
|
||||
|
||||
editor.change(change => {
|
||||
if (href) {
|
||||
change.setNodeByKey(link.key, { type: 'link', data: { href } });
|
||||
} else if (link) {
|
||||
change.unwrapInlineByKey(link.key);
|
||||
}
|
||||
change.deselect();
|
||||
this.props.onBlur();
|
||||
});
|
||||
};
|
||||
|
||||
setFirstDocumentRef = ref => {
|
||||
this.firstDocument = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const href = this.props.link.data.get('href');
|
||||
const hasResults = this.resultIds.length > 0;
|
||||
|
||||
return (
|
||||
<span ref={ref => (this.wrapper = ref)}>
|
||||
<LinkEditor>
|
||||
<Input
|
||||
innerRef={ref => (this.input = ref)}
|
||||
defaultValue={href}
|
||||
placeholder="Search or paste a link…"
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
autoFocus={href === ''}
|
||||
/>
|
||||
{this.isEditing && (
|
||||
<ToolbarButton onMouseDown={this.openLink}>
|
||||
<OpenIcon light />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
<ToolbarButton onMouseDown={this.removeLink}>
|
||||
{this.isEditing ? <TrashIcon light /> : <CloseIcon light />}
|
||||
</ToolbarButton>
|
||||
</LinkEditor>
|
||||
{hasResults && (
|
||||
<SearchResults>
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.resultIds.map((id, index) => {
|
||||
const document = this.props.documents.getById(id);
|
||||
if (!document) return null;
|
||||
|
||||
return (
|
||||
<DocumentResult
|
||||
innerRef={ref =>
|
||||
index === 0 && this.setFirstDocumentRef(ref)
|
||||
}
|
||||
document={document}
|
||||
key={document.id}
|
||||
onClick={ev => this.selectDocument(ev, document)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ArrowKeyNavigation>
|
||||
</SearchResults>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SearchResults = styled.div`
|
||||
background: #2f3336;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
left: 0;
|
||||
padding: 8px;
|
||||
margin-top: -3px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0 0 4px 4px;
|
||||
`;
|
||||
|
||||
const LinkEditor = styled(Flex)`
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
font-size: 15px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
export default withRouter(inject('documents')(LinkToolbar));
|
@ -1,26 +0,0 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
export default styled.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: 0.7;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${({ active }) => active && 'opacity: 1;'};
|
||||
`;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Toolbar from './Toolbar';
|
||||
export default Toolbar;
|
@ -1,25 +0,0 @@
|
||||
// @flow
|
||||
import { escape } from 'lodash';
|
||||
import { Document, Block, Node } from 'slate';
|
||||
import slug from 'slug';
|
||||
|
||||
// finds the index of this heading in the document compared to other headings
|
||||
// with the same slugified text
|
||||
function indexOfType(document, heading) {
|
||||
const slugified = escape(slug(heading.text));
|
||||
const headings = document.nodes.filter((node: Block) => {
|
||||
if (!node.text) return false;
|
||||
return node.type.match(/^heading/) && slugified === escape(slug(node.text));
|
||||
});
|
||||
|
||||
return headings.indexOf(heading);
|
||||
}
|
||||
|
||||
// calculates a unique slug for this heading based on it's text and position
|
||||
// in the document that is as stable as possible
|
||||
export default function headingToSlug(document: Document, node: Node) {
|
||||
const slugified = escape(slug(node.text));
|
||||
const index = indexOfType(document, node);
|
||||
if (index === 0) return slugified;
|
||||
return `${slugified}-${index}`;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Editor from './Editor';
|
||||
export default Editor;
|
@ -1,27 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import InlineCode from './components/InlineCode';
|
||||
import { Mark } from 'slate';
|
||||
|
||||
type Props = {
|
||||
children: React$Element<*>,
|
||||
mark: Mark,
|
||||
};
|
||||
|
||||
export default function renderMark(props: Props) {
|
||||
switch (props.mark.type) {
|
||||
case 'bold':
|
||||
return <strong>{props.children}</strong>;
|
||||
case 'code':
|
||||
return <InlineCode>{props.children}</InlineCode>;
|
||||
case 'italic':
|
||||
return <em>{props.children}</em>;
|
||||
case 'underlined':
|
||||
return <u>{props.children}</u>;
|
||||
case 'deleted':
|
||||
return <del>{props.children}</del>;
|
||||
case 'added':
|
||||
return <mark>{props.children}</mark>;
|
||||
default:
|
||||
}
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Code from './components/Code';
|
||||
import BlockToolbar from './components/Toolbar/BlockToolbar';
|
||||
import HorizontalRule from './components/HorizontalRule';
|
||||
import Image from './components/Image';
|
||||
import Link from './components/Link';
|
||||
import ListItem from './components/ListItem';
|
||||
import TodoList from './components/TodoList';
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
} from './components/Heading';
|
||||
import Paragraph from './components/Paragraph';
|
||||
import type { SlateNodeProps } from './types';
|
||||
|
||||
type Options = {
|
||||
onInsertImage: *,
|
||||
};
|
||||
|
||||
export default function createRenderNode({ onInsertImage }: Options) {
|
||||
return function renderNode(props: SlateNodeProps) {
|
||||
const { attributes } = props;
|
||||
|
||||
switch (props.node.type) {
|
||||
case 'paragraph':
|
||||
return <Paragraph {...props} />;
|
||||
case 'block-toolbar':
|
||||
return <BlockToolbar onInsertImage={onInsertImage} {...props} />;
|
||||
case 'block-quote':
|
||||
return <blockquote {...attributes}>{props.children}</blockquote>;
|
||||
case 'bulleted-list':
|
||||
return <ul {...attributes}>{props.children}</ul>;
|
||||
case 'ordered-list':
|
||||
return <ol {...attributes}>{props.children}</ol>;
|
||||
case 'todo-list':
|
||||
return <TodoList {...attributes}>{props.children}</TodoList>;
|
||||
case 'table':
|
||||
return <table {...attributes}>{props.children}</table>;
|
||||
case 'table-row':
|
||||
return <tr {...attributes}>{props.children}</tr>;
|
||||
case 'table-head':
|
||||
return <th {...attributes}>{props.children}</th>;
|
||||
case 'table-cell':
|
||||
return <td {...attributes}>{props.children}</td>;
|
||||
case 'list-item':
|
||||
return <ListItem {...props} />;
|
||||
case 'horizontal-rule':
|
||||
return <HorizontalRule {...props} />;
|
||||
case 'code':
|
||||
return <Code {...props} />;
|
||||
case 'code-line':
|
||||
return <pre {...attributes}>{props.children}</pre>;
|
||||
case 'image':
|
||||
return <Image {...props} />;
|
||||
case 'link':
|
||||
return <Link {...props} />;
|
||||
case 'heading1':
|
||||
return <Heading1 placeholder {...props} />;
|
||||
case 'heading2':
|
||||
return <Heading2 {...props} />;
|
||||
case 'heading3':
|
||||
return <Heading3 {...props} />;
|
||||
case 'heading4':
|
||||
return <Heading4 {...props} />;
|
||||
case 'heading5':
|
||||
return <Heading5 {...props} />;
|
||||
case 'heading6':
|
||||
return <Heading6 {...props} />;
|
||||
default:
|
||||
}
|
||||
};
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
// @flow
|
||||
import InsertImages from '@tommoor/slate-drop-or-paste-images';
|
||||
import PasteLinkify from 'slate-paste-linkify';
|
||||
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 EditList from './plugins/EditList';
|
||||
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
|
||||
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
|
||||
import { insertImageFile } from './changes';
|
||||
|
||||
type Options = {
|
||||
onImageUploadStart: () => void,
|
||||
onImageUploadStop: () => void,
|
||||
};
|
||||
|
||||
const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => {
|
||||
return [
|
||||
PasteLinkify({
|
||||
type: 'link',
|
||||
collapseTo: 'end',
|
||||
}),
|
||||
InsertImages({
|
||||
extensions: ['png', 'jpg', 'gif', 'webp'],
|
||||
insertImage: async (change, file, editor) => {
|
||||
return change.call(
|
||||
insertImageFile,
|
||||
file,
|
||||
editor,
|
||||
onImageUploadStart,
|
||||
onImageUploadStop
|
||||
);
|
||||
},
|
||||
}),
|
||||
EditList,
|
||||
EditCode({
|
||||
containerType: 'code',
|
||||
lineType: 'code-line',
|
||||
exitBlocktype: 'paragraph',
|
||||
allowMarks: false,
|
||||
selectAll: true,
|
||||
}),
|
||||
Prism({
|
||||
onlyIn: node => node.type === 'code',
|
||||
getSyntax: node => 'javascript',
|
||||
}),
|
||||
CollapseOnEscape({ toEdge: 'end' }),
|
||||
TrailingBlock({ type: 'paragraph' }),
|
||||
KeyboardShortcuts(),
|
||||
MarkdownShortcuts(),
|
||||
];
|
||||
};
|
||||
|
||||
export default createPlugins;
|
@ -1,8 +0,0 @@
|
||||
// @flow
|
||||
import EditList from 'slate-edit-list';
|
||||
|
||||
export default EditList({
|
||||
types: ['ordered-list', 'bulleted-list', 'todo-list'],
|
||||
typeItem: 'list-item',
|
||||
typeDefault: 'paragraph',
|
||||
});
|
@ -1,35 +0,0 @@
|
||||
// @flow
|
||||
import { Change } from 'slate';
|
||||
import { isModKey } from '../utils';
|
||||
|
||||
export default function KeyboardShortcuts() {
|
||||
return {
|
||||
onKeyDown(ev: SyntheticKeyboardEvent, change: Change) {
|
||||
if (!isModKey(ev)) return null;
|
||||
|
||||
switch (ev.key) {
|
||||
case 'b':
|
||||
return this.toggleMark(change, 'bold');
|
||||
case 'i':
|
||||
return this.toggleMark(change, 'italic');
|
||||
case 'u':
|
||||
return this.toggleMark(change, 'underlined');
|
||||
case 'd':
|
||||
return this.toggleMark(change, 'deleted');
|
||||
case 'k':
|
||||
return change.wrapInline({ type: 'link', data: { href: '' } });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
toggleMark(change: Change, type: string) {
|
||||
const { value } = change;
|
||||
// don't allow formatting of document title
|
||||
const firstNode = value.document.nodes.first();
|
||||
if (firstNode === value.startBlock) return;
|
||||
|
||||
change.toggleMark(type);
|
||||
},
|
||||
};
|
||||
}
|
@ -1,287 +0,0 @@
|
||||
// @flow
|
||||
import { Change } from 'slate';
|
||||
|
||||
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 {
|
||||
onKeyDown(ev: SyntheticKeyboardEvent, change: Change) {
|
||||
switch (ev.key) {
|
||||
case '-':
|
||||
return this.onDash(ev, change);
|
||||
case '`':
|
||||
return this.onBacktick(ev, change);
|
||||
case 'Tab':
|
||||
return this.onTab(ev, change);
|
||||
case ' ':
|
||||
return this.onSpace(ev, change);
|
||||
case 'Backspace':
|
||||
return this.onBackspace(ev, change);
|
||||
case 'Enter':
|
||||
return this.onEnter(ev, change);
|
||||
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: SyntheticKeyboardEvent, change: Change) {
|
||||
const { value } = change;
|
||||
if (value.isExpanded) return;
|
||||
const { startBlock, startOffset } = value;
|
||||
|
||||
// no markdown shortcuts work in headings
|
||||
if (startBlock.type.match(/heading/)) return;
|
||||
|
||||
const chars = startBlock.text.slice(0, startOffset).trim();
|
||||
const type = this.getType(chars);
|
||||
|
||||
if (type) {
|
||||
if (type === 'list-item' && startBlock.type === 'list-item') return;
|
||||
ev.preventDefault();
|
||||
|
||||
let checked;
|
||||
if (chars === '[x]') checked = true;
|
||||
if (chars === '[ ]') checked = false;
|
||||
|
||||
change
|
||||
.extendToStartOf(startBlock)
|
||||
.delete()
|
||||
.setBlock(
|
||||
{
|
||||
type,
|
||||
data: { checked },
|
||||
},
|
||||
{ normalize: false }
|
||||
);
|
||||
|
||||
if (type === 'list-item') {
|
||||
if (checked !== undefined) {
|
||||
change.wrapBlock('todo-list');
|
||||
} else if (chars === '1.') {
|
||||
change.wrapBlock('ordered-list');
|
||||
} else {
|
||||
change.wrapBlock('bulleted-list');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const key of inlineShortcuts) {
|
||||
// find all inline characters
|
||||
let { mark, shortcut } = key;
|
||||
let inlineTags = [];
|
||||
|
||||
// only add tags if they have spaces around them or the tag is beginning
|
||||
// or the end of the block
|
||||
for (let i = 0; i < startBlock.text.length; i++) {
|
||||
const { text } = startBlock;
|
||||
const start = i;
|
||||
const end = i + shortcut.length;
|
||||
const beginningOfBlock = start === 0;
|
||||
const endOfBlock = end === text.length;
|
||||
const surroundedByWhitespaces = [
|
||||
text.slice(start - 1, start),
|
||||
text.slice(end, end + 1),
|
||||
].includes(' ');
|
||||
|
||||
if (
|
||||
text.slice(start, end) === shortcut &&
|
||||
(beginningOfBlock || endOfBlock || surroundedByWhitespaces)
|
||||
) {
|
||||
inlineTags.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// if we have multiple tags then mark the text between as inline code
|
||||
if (inlineTags.length > 1) {
|
||||
const firstText = startBlock.getFirstText();
|
||||
const firstCodeTagIndex = inlineTags[0];
|
||||
const lastCodeTagIndex = inlineTags[inlineTags.length - 1];
|
||||
return change
|
||||
.removeTextByKey(firstText.key, lastCodeTagIndex, shortcut.length)
|
||||
.removeTextByKey(firstText.key, firstCodeTagIndex, shortcut.length)
|
||||
.moveOffsetsTo(
|
||||
firstCodeTagIndex,
|
||||
lastCodeTagIndex - shortcut.length
|
||||
)
|
||||
.addMark(mark)
|
||||
.collapseToEnd()
|
||||
.removeMark(mark);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDash(ev: SyntheticKeyboardEvent, change: Change) {
|
||||
const { value } = change;
|
||||
if (value.isExpanded) return;
|
||||
const { startBlock, startOffset } = value;
|
||||
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
|
||||
|
||||
if (chars === '--') {
|
||||
ev.preventDefault();
|
||||
return change
|
||||
.extendToStartOf(startBlock)
|
||||
.delete()
|
||||
.setBlock(
|
||||
{
|
||||
type: 'horizontal-rule',
|
||||
isVoid: true,
|
||||
},
|
||||
{ normalize: false }
|
||||
)
|
||||
.insertBlock('paragraph')
|
||||
.collapseToStart();
|
||||
}
|
||||
},
|
||||
|
||||
onBacktick(ev: SyntheticKeyboardEvent, change: Change) {
|
||||
const { value } = change;
|
||||
if (value.isExpanded) return;
|
||||
const { startBlock, startOffset } = value;
|
||||
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
|
||||
|
||||
if (chars === '``') {
|
||||
ev.preventDefault();
|
||||
return change
|
||||
.extendToStartOf(startBlock)
|
||||
.delete()
|
||||
.setBlock({ type: 'code' });
|
||||
}
|
||||
},
|
||||
|
||||
onBackspace(ev: SyntheticKeyboardEvent, change: Change) {
|
||||
const { value } = change;
|
||||
if (value.isExpanded) return;
|
||||
const { startBlock, selection, startOffset } = value;
|
||||
|
||||
// If at the start of a non-paragraph, convert it back into a paragraph
|
||||
if (startOffset === 0) {
|
||||
if (startBlock.type === 'paragraph') return;
|
||||
ev.preventDefault();
|
||||
|
||||
change.setBlock('paragraph');
|
||||
|
||||
if (startBlock.type === 'list-item') {
|
||||
change.unwrapBlock('bulleted-list');
|
||||
}
|
||||
|
||||
return change;
|
||||
}
|
||||
|
||||
// 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'));
|
||||
|
||||
change.removeMarkByKey(
|
||||
textNode.key,
|
||||
startOffset - charsInCodeBlock.size,
|
||||
startOffset,
|
||||
'code'
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On tab, if at the end of the heading jump to the main body content
|
||||
* as if it is another input field (act the same as enter).
|
||||
*/
|
||||
onTab(ev: SyntheticKeyboardEvent, change: Change) {
|
||||
const { value } = change;
|
||||
|
||||
if (value.startBlock.type === 'heading1') {
|
||||
ev.preventDefault();
|
||||
change.splitBlock().setBlock('paragraph');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On return, if at the end of a node type that should not be extended,
|
||||
* create a new paragraph below it.
|
||||
*/
|
||||
onEnter(ev: SyntheticKeyboardEvent, change: Change) {
|
||||
const { value } = change;
|
||||
if (value.isExpanded) return;
|
||||
|
||||
const { startBlock, startOffset, endOffset } = value;
|
||||
if (startOffset === 0 && startBlock.length === 0)
|
||||
return this.onBackspace(ev, change);
|
||||
|
||||
// Hitting enter at the end of the line reverts to standard behavior
|
||||
if (endOffset === startBlock.length) return;
|
||||
|
||||
// Hitting enter while an image is selected should jump caret below and
|
||||
// insert a new paragraph
|
||||
if (startBlock.type === 'image') {
|
||||
ev.preventDefault();
|
||||
return change.collapseToEnd().insertBlock('paragraph');
|
||||
}
|
||||
|
||||
// Hitting enter in a heading or blockquote will split the node at that
|
||||
// point and make the new node a paragraph
|
||||
if (
|
||||
startBlock.type.startsWith('heading') ||
|
||||
startBlock.type === 'block-quote'
|
||||
) {
|
||||
ev.preventDefault();
|
||||
return change.splitBlock().setBlock('paragraph');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the block type for a series of auto-markdown shortcut `chars`.
|
||||
*/
|
||||
getType(chars: string) {
|
||||
switch (chars) {
|
||||
case '*':
|
||||
case '-':
|
||||
case '+':
|
||||
case '1.':
|
||||
case '[ ]':
|
||||
case '[x]':
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
// @flow
|
||||
import { Block, Change, Node, Mark } from 'slate';
|
||||
|
||||
const schema = {
|
||||
blocks: {
|
||||
heading1: { nodes: [{ objects: ['text'] }], marks: [''] },
|
||||
heading2: { nodes: [{ objects: ['text'] }], marks: [''] },
|
||||
heading3: { nodes: [{ objects: ['text'] }], marks: [''] },
|
||||
heading4: { nodes: [{ objects: ['text'] }], marks: [''] },
|
||||
heading5: { nodes: [{ objects: ['text'] }], marks: [''] },
|
||||
heading6: { nodes: [{ objects: ['text'] }], marks: [''] },
|
||||
'block-quote': { marks: [''] },
|
||||
table: {
|
||||
nodes: [{ types: ['table-row', 'table-head', 'table-cell'] }],
|
||||
},
|
||||
'horizontal-rule': {
|
||||
isVoid: true,
|
||||
},
|
||||
'block-toolbar': {
|
||||
isVoid: true,
|
||||
},
|
||||
},
|
||||
document: {
|
||||
nodes: [
|
||||
{ types: ['heading1'], min: 1, max: 1 },
|
||||
{
|
||||
types: [
|
||||
'paragraph',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'heading5',
|
||||
'heading6',
|
||||
'block-quote',
|
||||
'code',
|
||||
'horizontal-rule',
|
||||
'image',
|
||||
'bulleted-list',
|
||||
'ordered-list',
|
||||
'todo-list',
|
||||
'block-toolbar',
|
||||
'table',
|
||||
],
|
||||
min: 1,
|
||||
},
|
||||
],
|
||||
normalize: (
|
||||
change: Change,
|
||||
reason: string,
|
||||
{
|
||||
node,
|
||||
child,
|
||||
mark,
|
||||
index,
|
||||
}: { node: Node, mark?: Mark, child: Node, index: number }
|
||||
) => {
|
||||
switch (reason) {
|
||||
case 'child_type_invalid': {
|
||||
return change.setNodeByKey(
|
||||
child.key,
|
||||
index === 0 ? 'heading1' : 'paragraph'
|
||||
);
|
||||
}
|
||||
case 'child_required': {
|
||||
const block = Block.create(index === 0 ? 'heading1' : 'paragraph');
|
||||
return change.insertNodeByKey(node.key, index, block);
|
||||
}
|
||||
default:
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default schema;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import MarkdownSerializer from 'slate-md-serializer';
|
||||
export default new MarkdownSerializer();
|
@ -1,19 +0,0 @@
|
||||
// @flow
|
||||
import { Value, Change, Node } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
|
||||
export type SlateNodeProps = {
|
||||
children: React$Element<*>,
|
||||
readOnly: boolean,
|
||||
attributes: Object,
|
||||
value: Value,
|
||||
editor: Editor,
|
||||
node: Node,
|
||||
parent: Node,
|
||||
};
|
||||
|
||||
export type Plugin = {
|
||||
validateNode?: Node => *,
|
||||
onClick?: SyntheticEvent => *,
|
||||
onKeyDown?: (SyntheticKeyboardEvent, Change) => *,
|
||||
};
|
@ -1,11 +0,0 @@
|
||||
// @flow
|
||||
|
||||
/**
|
||||
* Detect Cmd or Ctrl by platform for keyboard shortcuts
|
||||
*/
|
||||
export function isModKey(event: SyntheticKeyboardEvent) {
|
||||
const isMac =
|
||||
typeof window !== 'undefined' &&
|
||||
/Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
|
||||
return isMac ? event.metaKey : event.ctrlKey;
|
||||
}
|
@ -7,6 +7,7 @@ import { observer, inject } from 'mobx-react';
|
||||
import { withRouter, Prompt } from 'react-router-dom';
|
||||
import type { Location } from 'react-router-dom';
|
||||
import keydown from 'react-keydown';
|
||||
import Editor from 'rich-markdown-editor';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import {
|
||||
collectionUrl,
|
||||
@ -17,6 +18,7 @@ import {
|
||||
matchDocumentMove,
|
||||
} from 'utils/routeHelpers';
|
||||
|
||||
|
||||
import Document from 'models/Document';
|
||||
import Actions from './components/Actions';
|
||||
import DocumentMove from './components/DocumentMove';
|
||||
@ -128,8 +130,8 @@ class DocumentScene extends React.Component {
|
||||
};
|
||||
|
||||
loadEditor = async () => {
|
||||
const EditorImport = await import('components/Editor');
|
||||
this.editorComponent = EditorImport.default;
|
||||
// const EditorImport = await import('rich-markdown-editor');
|
||||
// this.editorComponent = EditorImport.default;
|
||||
};
|
||||
|
||||
get isEditing() {
|
||||
@ -205,7 +207,7 @@ class DocumentScene extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const Editor = this.editorComponent;
|
||||
// const Editor = this.editorComponent;
|
||||
const isMoving = this.props.match.path === matchDocumentMove;
|
||||
const document = this.document;
|
||||
const titleText =
|
||||
|
@ -162,6 +162,7 @@
|
||||
"react-waypoint": "^7.3.1",
|
||||
"redis": "^2.6.2",
|
||||
"redis-lock": "^0.1.0",
|
||||
"rich-markdown-editor": "*",
|
||||
"rimraf": "^2.5.4",
|
||||
"safestart": "1.1.0",
|
||||
"sequelize": "4.28.6",
|
||||
|
@ -8517,6 +8517,10 @@ retry-as-promised@^2.3.1:
|
||||
bluebird "^3.4.6"
|
||||
debug "^2.6.9"
|
||||
|
||||
rich-markdown-editor@*:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-0.0.1.tgz#e6c22cc8d0bff7304912773372fcc8ab8f44b461"
|
||||
|
||||
right-align@^0.1.1:
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"
|
||||
|
Reference in New Issue
Block a user