WIP
This commit is contained in:
@ -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 { withRouter, Prompt } from 'react-router-dom';
|
||||||
import type { Location } from 'react-router-dom';
|
import type { Location } from 'react-router-dom';
|
||||||
import keydown from 'react-keydown';
|
import keydown from 'react-keydown';
|
||||||
|
import Editor from 'rich-markdown-editor';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import {
|
import {
|
||||||
collectionUrl,
|
collectionUrl,
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
matchDocumentMove,
|
matchDocumentMove,
|
||||||
} from 'utils/routeHelpers';
|
} from 'utils/routeHelpers';
|
||||||
|
|
||||||
|
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import Actions from './components/Actions';
|
import Actions from './components/Actions';
|
||||||
import DocumentMove from './components/DocumentMove';
|
import DocumentMove from './components/DocumentMove';
|
||||||
@ -128,8 +130,8 @@ class DocumentScene extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadEditor = async () => {
|
loadEditor = async () => {
|
||||||
const EditorImport = await import('components/Editor');
|
// const EditorImport = await import('rich-markdown-editor');
|
||||||
this.editorComponent = EditorImport.default;
|
// this.editorComponent = EditorImport.default;
|
||||||
};
|
};
|
||||||
|
|
||||||
get isEditing() {
|
get isEditing() {
|
||||||
@ -205,7 +207,7 @@ class DocumentScene extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const Editor = this.editorComponent;
|
// const Editor = this.editorComponent;
|
||||||
const isMoving = this.props.match.path === matchDocumentMove;
|
const isMoving = this.props.match.path === matchDocumentMove;
|
||||||
const document = this.document;
|
const document = this.document;
|
||||||
const titleText =
|
const titleText =
|
||||||
|
@ -162,6 +162,7 @@
|
|||||||
"react-waypoint": "^7.3.1",
|
"react-waypoint": "^7.3.1",
|
||||||
"redis": "^2.6.2",
|
"redis": "^2.6.2",
|
||||||
"redis-lock": "^0.1.0",
|
"redis-lock": "^0.1.0",
|
||||||
|
"rich-markdown-editor": "*",
|
||||||
"rimraf": "^2.5.4",
|
"rimraf": "^2.5.4",
|
||||||
"safestart": "1.1.0",
|
"safestart": "1.1.0",
|
||||||
"sequelize": "4.28.6",
|
"sequelize": "4.28.6",
|
||||||
|
@ -8517,6 +8517,10 @@ retry-as-promised@^2.3.1:
|
|||||||
bluebird "^3.4.6"
|
bluebird "^3.4.6"
|
||||||
debug "^2.6.9"
|
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:
|
right-align@^0.1.1:
|
||||||
version "0.1.3"
|
version "0.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"
|
resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"
|
||||||
|
Reference in New Issue
Block a user