This commit is contained in:
Tom Moor 2018-04-03 20:20:30 -07:00
parent 17900c6a11
commit f72fa40a0f
39 changed files with 10 additions and 2778 deletions

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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;
`};
`;

View File

@ -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;

View File

@ -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;
}
}
`;

View File

@ -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;

View File

@ -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;

View File

@ -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} />
);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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;
`;

View File

@ -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;
`;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
`;

View File

@ -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;

View File

@ -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;

View File

@ -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));

View File

@ -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;'};
`;

View File

@ -1,3 +0,0 @@
// @flow
import Toolbar from './Toolbar';
export default Toolbar;

View File

@ -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}`;
}

View File

@ -1,3 +0,0 @@
// @flow
import Editor from './Editor';
export default Editor;

View File

@ -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:
}
}

View File

@ -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:
}
};
}

View File

@ -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;

View File

@ -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',
});

View File

@ -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);
},
};
}

View File

@ -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;
}
},
};
}

View File

@ -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;

View File

@ -1,3 +0,0 @@
// @flow
import MarkdownSerializer from 'slate-md-serializer';
export default new MarkdownSerializer();

View File

@ -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) => *,
};

View File

@ -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;
}

View File

@ -7,6 +7,7 @@ import { observer, inject } from 'mobx-react';
import { withRouter, Prompt } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import keydown from 'react-keydown';
import Editor from 'rich-markdown-editor';
import Flex from 'shared/components/Flex';
import {
collectionUrl,
@ -17,6 +18,7 @@ import {
matchDocumentMove,
} from 'utils/routeHelpers';
import Document from 'models/Document';
import Actions from './components/Actions';
import DocumentMove from './components/DocumentMove';
@ -128,8 +130,8 @@ class DocumentScene extends React.Component {
};
loadEditor = async () => {
const EditorImport = await import('components/Editor');
this.editorComponent = EditorImport.default;
// const EditorImport = await import('rich-markdown-editor');
// this.editorComponent = EditorImport.default;
};
get isEditing() {
@ -205,7 +207,7 @@ class DocumentScene extends React.Component {
}
render() {
const Editor = this.editorComponent;
// const Editor = this.editorComponent;
const isMoving = this.props.match.path === matchDocumentMove;
const document = this.document;
const titleText =

View File

@ -162,6 +162,7 @@
"react-waypoint": "^7.3.1",
"redis": "^2.6.2",
"redis-lock": "^0.1.0",
"rich-markdown-editor": "*",
"rimraf": "^2.5.4",
"safestart": "1.1.0",
"sequelize": "4.28.6",

View File

@ -8517,6 +8517,10 @@ retry-as-promised@^2.3.1:
bluebird "^3.4.6"
debug "^2.6.9"
rich-markdown-editor@*:
version "0.0.1"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-0.0.1.tgz#e6c22cc8d0bff7304912773372fcc8ab8f44b461"
right-align@^0.1.1:
version "0.1.3"
resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"