frontend > app

This commit is contained in:
Tom Moor
2017-10-25 22:49:04 -07:00
parent aa34db8318
commit 4863680d86
239 changed files with 11 additions and 11 deletions

View File

@@ -0,0 +1,331 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { Editor, Plain } from 'slate';
import keydown from 'react-keydown';
import type { State, Editor as EditorType } from './types';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Flex from '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 createSchema from './schema';
import createPlugins from './plugins';
import insertImage from './insertImage';
import styled from 'styled-components';
type Props = {
text: string,
onChange: Function,
onSave: Function,
onCancel: Function,
onImageUploadStart: Function,
onImageUploadStop: Function,
emoji?: string,
readOnly: boolean,
};
type KeyData = {
isMeta: boolean,
key: string,
};
@observer class MarkdownEditor extends Component {
props: Props;
editor: EditorType;
schema: Object;
plugins: Array<Object>;
@observable editorState: State;
constructor(props: Props) {
super(props);
this.schema = createSchema();
this.plugins = createPlugins({
onImageUploadStart: props.onImageUploadStart,
onImageUploadStop: props.onImageUploadStop,
});
if (props.text.trim().length) {
this.editorState = Markdown.deserialize(props.text);
} else {
this.editorState = Plain.deserialize('');
}
}
componentDidMount() {
if (!this.props.readOnly) {
if (this.props.text) {
this.focusAtEnd();
} else {
this.focusAtStart();
}
}
}
componentDidUpdate(prevProps: Props) {
if (prevProps.readOnly && !this.props.readOnly) {
this.focusAtEnd();
}
}
onChange = (editorState: State) => {
if (this.editorState !== editorState) {
this.props.onChange(Markdown.serialize(editorState));
}
this.editorState = editorState;
};
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 = async (file: window.File) => {
const state = this.editor.getState();
let transform = state.transform();
transform = await insertImage(
transform,
file,
this.editor,
this.props.onImageUploadStart,
this.props.onImageUploadStop
);
this.editor.onChange(transform.apply());
};
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();
}
@keydown('meta+enter')
onSaveAndExit(ev: SyntheticKeyboardEvent) {
if (this.props.readOnly) return;
ev.preventDefault();
ev.stopPropagation();
this.props.onSave({ redirect: false });
}
@keydown('esc')
onCancel() {
if (this.props.readOnly) return;
this.props.onCancel();
}
// Handling of keyboard shortcuts within editor focus
onKeyDown = (ev: SyntheticKeyboardEvent, data: KeyData, state: State) => {
if (!data.isMeta) return;
switch (data.key) {
case 's':
this.onSave(ev);
return state;
case 'enter':
this.onSaveAndExit(ev);
return state;
case 'escape':
this.onCancel();
return state;
default:
}
};
focusAtStart = () => {
const state = this.editor.getState();
const transform = state.transform();
transform.collapseToStartOf(state.document);
transform.focus();
this.editorState = transform.apply();
};
focusAtEnd = () => {
const state = this.editor.getState();
const transform = state.transform();
transform.collapseToEndOf(state.document);
transform.focus();
this.editorState = transform.apply();
};
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} />
<Contents state={this.editorState} />
{!readOnly &&
<Toolbar state={this.editorState} onChange={this.onChange} />}
{!readOnly &&
<BlockInsert
state={this.editorState}
onChange={this.onChange}
onInsertImage={this.insertImageFile}
/>}
<StyledEditor
innerRef={ref => (this.editor = ref)}
placeholder="Start with a title…"
bodyPlaceholder="…the rest is your canvas"
schema={this.schema}
plugins={this.plugins}
emoji={emoji}
state={this.editorState}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onSave={onSave}
readOnly={readOnly}
/>
<ClickablePadding
onClick={!readOnly ? this.focusAtEnd : undefined}
grow
/>
</MaxWidth>
</Flex>
);
};
}
const MaxWidth = styled(Flex)`
padding: 0 60px;
max-width: 50em;
height: 100%;
`;
const Header = styled(Flex)`
height: 60px;
flex-shrink: 0;
align-items: flex-end;
${({ readOnly }) => !readOnly && 'cursor: text;'}
`;
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: 1em 0.1em;
padding-left: 1em;
ul,
ol {
margin: 0.1em;
}
}
p {
position: relative;
}
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;
padding-left: 10px;
}
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

@@ -0,0 +1,196 @@
// @flow
import React, { Component } from 'react';
import EditList from '../plugins/EditList';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Portal from 'react-portal';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import { color } from 'styles/constants';
import PlusIcon from 'components/Icon/PlusIcon';
import BlockMenu from 'menus/BlockMenu';
import type { State } from '../types';
const { transforms } = EditList;
type Props = {
state: State,
onChange: Function,
onInsertImage: File => Promise<*>,
};
@observer
export default class BlockInsert extends Component {
props: Props;
mouseMoveTimeout: number;
file: HTMLInputElement;
@observable active: boolean = false;
@observable menuOpen: boolean = false;
@observable top: number;
@observable left: number;
@observable mouseX: number;
componentDidMount = () => {
this.update();
window.addEventListener('mousemove', this.handleMouseMove);
};
componentWillUpdate = (nextProps: Props) => {
this.update(nextProps);
};
componentWillUnmount = () => {
window.removeEventListener('mousemove', this.handleMouseMove);
};
setInactive = () => {
if (this.menuOpen) return;
this.active = false;
};
handleMouseMove = (ev: SyntheticMouseEvent) => {
const windowWidth = window.innerWidth / 3;
let active = ev.clientX < windowWidth;
if (active !== this.active) {
this.active = active || this.menuOpen;
}
if (active) {
clearTimeout(this.mouseMoveTimeout);
this.mouseMoveTimeout = setTimeout(this.setInactive, 2000);
}
};
handleMenuOpen = () => {
this.menuOpen = true;
};
handleMenuClose = () => {
this.menuOpen = false;
};
update = (props?: Props) => {
if (!document.activeElement) return;
const { state } = props || this.props;
const boxRect = document.activeElement.getBoundingClientRect();
const selection = window.getSelection();
if (!selection.focusNode) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.top <= 0 || boxRect.left <= 0) return;
if (state.startBlock.type === 'heading1') {
this.active = false;
}
this.top = Math.round(rect.top + window.scrollY);
this.left = Math.round(boxRect.left + window.scrollX - 20);
};
insertBlock = (
ev: SyntheticEvent,
options: {
type: string | Object,
wrapper?: string | Object,
append?: string | Object,
}
) => {
ev.preventDefault();
const { type, wrapper, append } = options;
let { state } = this.props;
let transform = state.transform();
const { document } = state;
const parent = document.getParent(state.startBlock.key);
// lists get some special treatment
if (parent && parent.type === 'list-item') {
transform = transforms.unwrapList(
transforms
.splitListItem(transform.collapseToStart())
.collapseToEndOfPreviousBlock()
);
}
transform = transform.insertBlock(type);
if (wrapper) transform = transform.wrapBlock(wrapper);
if (append) transform = transform.insertBlock(append);
state = transform.focus().apply();
this.props.onChange(state);
this.active = false;
};
onPickImage = (ev: SyntheticEvent) => {
// simulate a click on the file upload input element
this.file.click();
};
onChooseImage = async (ev: SyntheticEvent) => {
const files = getDataTransferFiles(ev);
for (const file of files) {
await this.props.onInsertImage(file);
}
};
render() {
const style = { top: `${this.top}px`, left: `${this.left}px` };
const todo = { type: 'list-item', data: { checked: false } };
const rule = { type: 'horizontal-rule', isVoid: true };
return (
<Portal isOpened>
<Trigger active={this.active} style={style}>
<HiddenInput
type="file"
innerRef={ref => (this.file = ref)}
onChange={this.onChooseImage}
accept="image/*"
/>
<BlockMenu
label={<PlusIcon />}
onPickImage={this.onPickImage}
onInsertList={ev =>
this.insertBlock(ev, {
type: 'list-item',
wrapper: 'bulleted-list',
})}
onInsertTodoList={ev =>
this.insertBlock(ev, { type: todo, wrapper: 'todo-list' })}
onInsertBreak={ev =>
this.insertBlock(ev, { type: rule, append: 'paragraph' })}
onOpen={this.handleMenuOpen}
onClose={this.handleMenuClose}
/>
</Trigger>
</Portal>
);
}
}
const HiddenInput = styled.input`
position: absolute;
top: -100px;
left: -100px;
visibility: hidden;
`;
const Trigger = styled.div`
position: absolute;
z-index: 1;
opacity: 0;
background-color: ${color.white};
transition: opacity 250ms ease-in-out, transform 250ms ease-in-out;
line-height: 0;
margin-top: -2px;
margin-left: -4px;
transform: scale(.9);
${({ active }) => active && `
transform: scale(1);
opacity: .9;
`}
`;

View File

@@ -0,0 +1,22 @@
// @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: 150px;
padding-top: 50px;
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
${({ grow }) => grow && `flex-grow: 1;`}
`;
export default ClickablePadding;

View File

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

View File

@@ -0,0 +1,42 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import CopyButton from './CopyButton';
import { color } from 'styles/constants';
import type { Props } from '../types';
export default function Code({ children, node, readOnly, attributes }: Props) {
const language = node.data.get('language') || 'javascript';
return (
<Container>
{readOnly && <CopyButton text={node.text} />}
<Pre className={`language-${language}`}>
<code {...attributes} className={`language-${language}`}>
{children}
</code>
</Pre>
</Container>
);
}
const Pre = styled.pre`
padding: .5em 1em;
background: ${color.smokeLight};
border-radius: 4px;
border: 1px solid ${color.smokeDark};
code {
padding: 0;
}
`;
const Container = styled.div`
position: relative;
&:hover {
> span {
opacity: 1;
}
}
`;

View File

@@ -0,0 +1,149 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { List } from 'immutable';
import { color } from 'styles/constants';
import headingToSlug from '../headingToSlug';
import type { State, Block } from '../types';
import styled from 'styled-components';
type Props = {
state: State,
};
@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 { state } = this.props;
return state.document.nodes.filter((node: Block) => {
if (!node.text) return false;
return node.type.match(/^heading/);
});
}
render() {
// 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(heading);
const active = this.activeHeading === slug;
return (
<ListItem type={heading.type} active={active}>
<Anchor href={`#${slug}`} active={active}>
{heading.text}
</Anchor>
</ListItem>
);
})}
</Sections>
</Wrapper>
);
}
}
const Wrapper = styled.div`
position: fixed;
right: 0;
top: 150px;
z-index: 100;
`;
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

@@ -0,0 +1,49 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { color } from '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 to clipboard'}</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.slateLight};
border-radius: 2px;
padding: 1px 6px;
&:hover {
background: ${color.slate};
}
`;
export default CopyButton;

View File

@@ -0,0 +1,97 @@
// @flow
import React from 'react';
import { Document } from 'slate';
import styled from 'styled-components';
import headingToSlug from '../headingToSlug';
import type { Node, Editor } from '../types';
import Placeholder from './Placeholder';
type Props = {
children: React$Element<*>,
placeholder?: boolean,
parent: Node,
node: Node,
editor: Editor,
readOnly: boolean,
component?: string,
};
function Heading(props: Props) {
const {
parent,
placeholder,
node,
editor,
readOnly,
children,
component = 'h1',
...rest
} = props;
const parentIsDocument = parent instanceof Document;
const firstHeading = parentIsDocument && parent.nodes.first() === node;
const showPlaceholder = placeholder && firstHeading && !node.text;
const slugish = headingToSlug(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 {...rest} id={slugish}>
<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: Props) => (props.hasEmoji ? '-1.2em' : 0)}
`;
const Anchor = styled.a`
visibility: hidden;
padding-left: .25em;
color: #dedede;
&:hover {
color: #cdcdcd;
}
`;
export const StyledHeading = styled(Heading)`
position: relative;
&:hover {
${Anchor} {
visibility: visible;
text-decoration: none;
}
}
`;
export const Heading1 = (props: Props) => (
<StyledHeading component="h1" {...props} />
);
export const Heading2 = (props: Props) => (
<StyledHeading component="h2" {...props} />
);
export const Heading3 = (props: Props) => (
<StyledHeading component="h3" {...props} />
);
export const Heading4 = (props: Props) => (
<StyledHeading component="h4" {...props} />
);
export const Heading5 = (props: Props) => (
<StyledHeading component="h5" {...props} />
);
export const Heading6 = (props: Props) => (
<StyledHeading component="h6" {...props} />
);

View File

@@ -0,0 +1,17 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import type { Props } from '../types';
import { color } from 'styles/constants';
function HorizontalRule(props: Props) {
const { state, node } = props;
const active = state.isFocused && state.selection.hasEdgeIn(node);
return <StyledHr active={active} />;
}
const StyledHr = styled.hr`
border-bottom: 1px solid ${props => (props.active ? color.slate : color.slateLight)};
`;
export default HorizontalRule;

View File

@@ -0,0 +1,87 @@
// @flow
import React, { Component } from 'react';
import styled from 'styled-components';
import type { Props } from '../types';
import { color } from 'styles/constants';
class Image extends Component {
props: Props;
handleChange = (ev: SyntheticInputEvent) => {
const alt = ev.target.value;
const { editor, node } = this.props;
const data = node.data.toObject();
const state = editor
.getState()
.transform()
.setNodeByKey(node.key, { data: { ...data, alt } })
.apply();
editor.onChange(state);
};
handleClick = (ev: SyntheticInputEvent) => {
ev.stopPropagation();
};
render() {
const { attributes, state, node, readOnly } = this.props;
const loading = node.data.get('loading');
const caption = node.data.get('alt');
const src = node.data.get('src');
const active = state.isFocused && state.selection.hasEdgeIn(node);
const showCaption = !readOnly || caption;
return (
<CenteredImage>
<StyledImg
{...attributes}
src={src}
alt={caption}
active={active}
loading={loading}
/>
{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`
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.div`
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;
&::placeholder {
color: ${color.slate};
}
`;
export default Image;

View File

@@ -0,0 +1,12 @@
// @flow
import styled from 'styled-components';
import { color } from 'styles/constants';
const InlineCode = styled.code`
padding: .25em;
background: ${color.smoke};
border-radius: 4px;
border: 1px solid ${color.smokeDark};
`;
export default InlineCode;

View File

@@ -0,0 +1,38 @@
// @flow
import React from 'react';
import { Link as InternalLink } from 'react-router-dom';
import type { Props } from '../types';
function getPathFromUrl(href: string) {
if (href[0] === '/') return href;
try {
const parsed = new URL(href);
return parsed.pathname;
} catch (err) {
return '';
}
}
function isAtlasUrl(href: string) {
if (href[0] === '/') return true;
try {
const atlas = new URL(BASE_URL);
const parsed = new URL(href);
return parsed.hostname === atlas.hostname;
} catch (err) {
return false;
}
}
export default function Link({ attributes, node, children, readOnly }: Props) {
const href = node.data.get('href');
const path = getPathFromUrl(href);
if (isAtlasUrl(href) && readOnly) {
return <InternalLink {...attributes} to={path}>{children}</InternalLink>;
} else {
return <a {...attributes} href={href} target="_blank">{children}</a>;
}
}

View File

@@ -0,0 +1,16 @@
// @flow
import React from 'react';
import type { Props } from '../types';
import TodoItem from './TodoItem';
export default function ListItem({ children, node, ...props }: Props) {
const checked = node.data.get('checked');
if (checked !== undefined) {
return (
<TodoItem checked={checked} node={node} {...props}>
{children}
</TodoItem>
);
}
return <li>{children}</li>;
}

View File

@@ -0,0 +1,34 @@
// @flow
import React from 'react';
import { Document } from 'slate';
import type { Props } from '../types';
import Placeholder from './Placeholder';
export default function Link({
attributes,
editor,
node,
parent,
children,
readOnly,
}: Props) {
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>
{children}
{showPlaceholder &&
<Placeholder contentEditable={false}>
{editor.props.bodyPlaceholder}
</Placeholder>}
</p>
);
}

View File

@@ -0,0 +1,11 @@
// @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

@@ -0,0 +1,51 @@
// @flow
import React, { Component } from 'react';
import styled from 'styled-components';
import { color } from 'styles/constants';
import type { Props } from '../types';
export default class TodoItem extends Component {
props: Props & { checked: boolean };
handleChange = (ev: SyntheticInputEvent) => {
const checked = ev.target.checked;
const { editor, node } = this.props;
const state = editor
.getState()
.transform()
.setNodeByKey(node.key, { data: { checked } })
.apply();
editor.onChange(state);
};
render() {
const { children, checked, readOnly } = this.props;
return (
<ListItem checked={checked}>
<Input
type="checkbox"
checked={checked}
onChange={this.handleChange}
disabled={readOnly}
contentEditable={false}
/>
{children}
</ListItem>
);
}
}
const ListItem = styled.li`
padding-left: 1.4em;
position: relative;
text-decoration: ${props => (props.checked ? 'line-through' : 'none')};
color: ${props => (props.checked ? color.slateDark : 'inherit')};
`;
const Input = styled.input`
position: absolute;
left: 0;
top: 0.4em;
`;

View File

@@ -0,0 +1,13 @@
// @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

@@ -0,0 +1,159 @@
// @flow
import React, { Component } from 'react';
import Portal from 'react-portal';
import styled from 'styled-components';
import _ from 'lodash';
import type { State } from '../../types';
import FormattingToolbar from './components/FormattingToolbar';
import LinkToolbar from './components/LinkToolbar';
export default class Toolbar extends Component {
props: {
state: State,
onChange: Function,
};
menu: HTMLElement;
state: {
active: boolean,
focused: boolean,
link: React$Element<any>,
top: string,
left: string,
};
state = {
active: false,
focused: false,
link: null,
top: '',
left: '',
};
componentDidMount = () => {
this.update();
};
componentDidUpdate = () => {
this.update();
};
handleFocus = () => {
this.setState({ focused: true });
};
handleBlur = () => {
this.setState({ focused: false });
};
get linkInSelection(): any {
const { state } = this.props;
try {
const selectedLinks = state.startBlock
.getInlinesAtRange(state.selection)
.filter(node => node.type === 'link');
if (selectedLinks.size) {
return selectedLinks.first();
}
} catch (err) {
//
}
}
update = () => {
const { state } = this.props;
const link = this.linkInSelection;
if (state.isBlurred || (state.isCollapsed && !link)) {
if (this.state.active && !this.state.focused) {
this.setState({ active: false, link: null, top: '', left: '' });
}
return;
}
// don't display toolbar for document title
const firstNode = state.document.nodes.first();
if (firstNode === state.startBlock) return;
// don't display toolbar for code blocks
if (state.startBlock.type === 'code') return;
const data = {
...this.state,
active: true,
link,
focused: !!link,
};
if (!_.isEqual(data, this.state)) {
const padding = 16;
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.top === 0 && rect.left === 0) {
this.setState(data);
return;
}
const left =
rect.left + window.scrollX - this.menu.offsetWidth / 2 + rect.width / 2;
data.top = `${Math.round(rect.top + window.scrollY - this.menu.offsetHeight)}px`;
data.left = `${Math.round(Math.max(padding, left))}px`;
this.setState(data);
}
};
setRef = (ref: HTMLElement) => {
this.menu = ref;
};
render() {
const link = this.state.link;
const style = {
top: this.state.top,
left: this.state.left,
};
return (
<Portal isOpened>
<Menu active={this.state.active} innerRef={this.setRef} style={style}>
{link &&
<LinkToolbar
{...this.props}
link={link}
onBlur={this.handleBlur}
/>}
{!link &&
<FormattingToolbar
onCreateLink={this.handleFocus}
{...this.props}
/>}
</Menu>
</Portal>
);
}
}
const Menu = styled.div`
padding: 8px 16px;
position: absolute;
z-index: 1;
top: -10000px;
left: -10000px;
opacity: 0;
background-color: #2F3336;
border-radius: 4px;
transform: scale(.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);
line-height: 0;
height: 40px;
min-width: 260px;
${({ active }) => active && `
transform: translateY(-6px) scale(1);
opacity: 1;
`}
`;

View File

@@ -0,0 +1,47 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import { fontWeight, color } from '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: 24px;
padding: 4px 8px 4px 0;
color: ${color.white};
font-size: 15px;
i {
visibility: hidden;
}
&:hover,
&:focus,
&:active {
font-weight: ${fontWeight.medium};
outline: none;
i {
visibility: visible;
}
}
`;
export default DocumentResult;

View File

@@ -0,0 +1,118 @@
// @flow
import React, { Component } from 'react';
import styled from 'styled-components';
import type { State } from '../../../types';
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 LinkIcon from 'components/Icon/LinkIcon';
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
class FormattingToolbar extends Component {
props: {
state: State,
onChange: Function,
onCreateLink: Function,
};
/**
* Check if the current selection has a mark with `type` in it.
*
* @param {String} type
* @return {Boolean}
*/
hasMark = (type: string) => {
return this.props.state.marks.some(mark => mark.type === type);
};
isBlock = (type: string) => {
return this.props.state.startBlock.type === type;
};
/**
* When a mark button is clicked, toggle the current mark.
*
* @param {Event} ev
* @param {String} type
*/
onClickMark = (ev: SyntheticEvent, type: string) => {
ev.preventDefault();
let { state } = this.props;
state = state.transform().toggleMark(type).apply();
this.props.onChange(state);
};
onClickBlock = (ev: SyntheticEvent, type: string) => {
ev.preventDefault();
let { state } = this.props;
state = state.transform().setBlock(type).apply();
this.props.onChange(state);
};
onCreateLink = (ev: SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
let { state } = this.props;
const data = { href: '' };
state = state.transform().wrapInline({ type: 'link', data }).apply();
this.props.onChange(state);
this.props.onCreateLink();
};
renderMarkButton = (type: string, IconClass: Function) => {
const isActive = this.hasMark(type);
const onMouseDown = ev => this.onClickMark(ev, type);
return (
<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)}
<Separator />
<ToolbarButton onMouseDown={this.onCreateLink}>
<LinkIcon light />
</ToolbarButton>
</span>
);
}
}
const Separator = styled.div`
height: 100%;
width: 1px;
background: #FFF;
opacity: .2;
display: inline-block;
margin-left: 10px;
`;
export default FormattingToolbar;

View File

@@ -0,0 +1,212 @@
// @flow
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { observable, action } from 'mobx';
import { observer, inject } from 'mobx-react';
import { withRouter } from 'react-router';
import styled from 'styled-components';
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
import ToolbarButton from './ToolbarButton';
import DocumentResult from './DocumentResult';
import type { State } from '../../../types';
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 'components/Flex';
@keydown
@observer
class LinkToolbar extends Component {
input: HTMLElement;
firstDocument: HTMLElement;
props: {
state: State,
link: Object,
documents: DocumentsStore,
onBlur: () => void,
onChange: State => void,
};
@observable isEditing: boolean = false;
@observable isFetching: boolean = false;
@observable resultIds: string[] = [];
@observable searchTerm: ?string = null;
componentWillMount() {
this.isEditing = !!this.props.link.data.get('href');
}
@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.input.blur();
case 40: // down
ev.preventDefault();
if (this.firstDocument) {
const element = ReactDOM.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 = [];
};
onBlur = () => {
if (!this.resultIds.length) {
if (this.input.value) {
this.props.onBlur();
} else {
this.removeLink();
}
}
};
removeLink = () => {
this.save('');
};
openLink = () => {
const href = this.props.link.data.get('href');
window.open(href, '_blank');
};
save = (href: string) => {
href = href.trim();
const { state } = this.props;
const transform = state.transform();
if (href) {
transform.setInline({ type: 'link', data: { href } });
} else {
transform.unwrapInline('link');
}
this.props.onChange(transform.apply());
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>
<LinkEditor>
<Input
innerRef={ref => (this.input = ref)}
defaultValue={href}
placeholder="Search or paste a link…"
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
autoFocus
/>
{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,.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

@@ -0,0 +1,26 @@
// @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: .7;
&:first-child {
margin-left: 0;
}
&:hover {
opacity: 1;
}
${({ active }) => active && 'opacity: 1;'}
`;

View File

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

View File

@@ -0,0 +1,9 @@
// @flow
import { escape } from 'lodash';
import type { Node } from './types';
import slug from 'slug';
export default function headingToSlug(node: Node) {
const level = node.type.replace('heading', 'h');
return escape(`${level}-${slug(node.text)}-${node.key}`);
}

View File

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

View File

@@ -0,0 +1,56 @@
// @flow
import uuid from 'uuid';
import uploadFile from 'utils/uploadFile';
import type { Editor, Transform } from './types';
export default async function insertImageFile(
transform: Transform,
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;
// insert into document as uploading placeholder
const state = transform
.insertBlock({
type: 'image',
isVoid: true,
data: { src, id, alt, loading: true },
})
.apply();
editor.onChange(state);
});
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 transform provided to the callback here
// as the state may have changed significantly in the time it took to
// upload the file.
const state = editor.getState();
const finalTransform = state.transform();
const placeholder = state.document.findDescendant(
node => node.data && node.data.get('id') === id
);
return finalTransform.setNodeByKey(placeholder.key, {
data: { src, alt, loading: false },
});
} catch (err) {
throw err;
} finally {
onImageUploadStop();
}
}

View File

@@ -0,0 +1,57 @@
// @flow
import DropOrPasteImages 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 insertImage from './insertImage';
const onlyInCode = node => node.type === 'code';
type Options = {
onImageUploadStart: Function,
onImageUploadStop: Function,
};
const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => {
return [
PasteLinkify({
type: 'link',
collapseTo: 'end',
}),
DropOrPasteImages({
extensions: ['png', 'jpg', 'gif'],
applyTransform: (transform, file, editor) => {
return insertImage(
transform,
file,
editor,
onImageUploadStart,
onImageUploadStop
);
},
}),
EditList,
EditCode({
onlyIn: onlyInCode,
containerType: 'code',
lineType: 'code-line',
exitBlocktype: 'paragraph',
selectAll: true,
}),
Prism({
onlyIn: onlyInCode,
getSyntax: node => 'javascript',
}),
CollapseOnEscape({ toEdge: 'end' }),
TrailingBlock({ type: 'paragraph' }),
KeyboardShortcuts(),
MarkdownShortcuts(),
];
};
export default createPlugins;

View File

@@ -0,0 +1,7 @@
// @flow
import EditList from 'slate-edit-list';
export default EditList({
types: ['ordered-list', 'bulleted-list', 'todo-list'],
typeItem: 'list-item',
});

View File

@@ -0,0 +1,44 @@
// @flow
export default function KeyboardShortcuts() {
return {
/**
* On key down, check for our specific key shortcuts.
*
* @param {Event} e
* @param {Data} data
* @param {State} state
* @return {State or Null} state
*/
onKeyDown(ev: SyntheticEvent, data: Object, state: Object) {
if (!data.isMeta) return null;
switch (data.key) {
case 'b':
return this.toggleMark(state, 'bold');
case 'i':
return this.toggleMark(state, 'italic');
case 'u':
return this.toggleMark(state, 'underlined');
case 'd':
return this.toggleMark(state, 'deleted');
case 'k':
return state
.transform()
.wrapInline({ type: 'link', data: { href: '' } })
.apply();
default:
return null;
}
},
toggleMark(state: Object, type: string) {
// don't allow formatting of document title
const firstNode = state.document.nodes.first();
if (firstNode === state.startBlock) return;
state = state.transform().toggleMark(type).apply();
return state;
},
};
}

View File

@@ -0,0 +1,290 @@
// @flow
const inlineShortcuts = [
{ mark: 'bold', shortcut: '**' },
{ mark: 'bold', shortcut: '__' },
{ mark: 'italic', shortcut: '*' },
{ mark: 'italic', shortcut: '_' },
{ mark: 'code', shortcut: '`' },
{ mark: 'added', shortcut: '++' },
{ mark: 'deleted', shortcut: '~~' },
];
export default function MarkdownShortcuts() {
return {
/**
* On key down, check for our specific key shortcuts.
*/
onKeyDown(ev: SyntheticEvent, data: Object, state: Object) {
switch (data.key) {
case '-':
return this.onDash(ev, state);
case '`':
return this.onBacktick(ev, state);
case 'tab':
return this.onTab(ev, state);
case 'space':
return this.onSpace(ev, state);
case 'backspace':
return this.onBackspace(ev, state);
case 'enter':
return this.onEnter(ev, state);
default:
return null;
}
},
/**
* On space, if it was after an auto-markdown shortcut, convert the current
* node into the shortcut's corresponding type.
*/
onSpace(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset } = state;
const chars = startBlock.text.slice(0, startOffset).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;
const transform = state
.transform()
.setBlock({ type, data: { checked } });
if (type === 'list-item') {
if (checked !== undefined) {
transform.wrapBlock('todo-list');
} else if (chars === '1.') {
transform.wrapBlock('ordered-list');
} else {
transform.wrapBlock('bulleted-list');
}
}
state = transform.extendToStartOf(startBlock).delete().apply();
return state;
}
for (const key of inlineShortcuts) {
// find all inline characters
let { mark, shortcut } = key;
let inlineTags = [];
// 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 transform = state.transform();
const firstText = startBlock.getFirstText();
const firstCodeTagIndex = inlineTags[0];
const lastCodeTagIndex = inlineTags[inlineTags.length - 1];
transform.removeTextByKey(
firstText.key,
lastCodeTagIndex,
shortcut.length
);
transform.removeTextByKey(
firstText.key,
firstCodeTagIndex,
shortcut.length
);
transform.moveOffsetsTo(
firstCodeTagIndex,
lastCodeTagIndex - shortcut.length
);
transform.addMark(mark);
state = transform.collapseToEnd().removeMark(mark).apply();
return state;
}
}
},
onDash(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset } = state;
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
if (chars === '--') {
ev.preventDefault();
return state
.transform()
.extendToStartOf(startBlock)
.delete()
.setBlock({
type: 'horizontal-rule',
isVoid: true,
})
.collapseToStartOfNextBlock()
.insertBlock('paragraph')
.apply();
}
},
onBacktick(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset } = state;
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
if (chars === '``') {
ev.preventDefault();
return state
.transform()
.extendToStartOf(startBlock)
.delete()
.setBlock({
type: 'code',
})
.apply();
}
},
onBackspace(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, selection, startOffset } = state;
// If at the start of a non-paragraph, convert it back into a paragraph
if (startOffset === 0) {
if (startBlock.type === 'paragraph') return;
ev.preventDefault();
const transform = state.transform().setBlock('paragraph');
if (startBlock.type === 'list-item')
transform.unwrapBlock('bulleted-list');
state = transform.apply();
return state;
}
// If at the end of a code mark hitting backspace should remove the mark
if (selection.isCollapsed) {
const marksAtCursor = startBlock.getMarksAtRange(selection);
const codeMarksAtCursor = marksAtCursor.filter(
mark => mark.type === 'code'
);
if (codeMarksAtCursor.size > 0) {
ev.preventDefault();
const textNode = startBlock.getTextAtOffset(startOffset);
const charsInCodeBlock = textNode.characters
.takeUntil((v, k) => k === startOffset)
.reverse()
.takeUntil((v, k) => !v.marks.some(mark => mark.type === 'code'));
const transform = state.transform();
transform.removeMarkByKey(
textNode.key,
state.startOffset - charsInCodeBlock.size,
state.startOffset,
'code'
);
state = transform.apply();
return state;
}
}
},
/**
* On 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: SyntheticEvent, state: Object) {
if (state.startBlock.type === 'heading1') {
ev.preventDefault();
return state.transform().splitBlock().setBlock('paragraph').apply();
}
},
/**
* On return, if at the end of a node type that should not be extended,
* create a new paragraph below it.
*/
onEnter(ev: SyntheticEvent, state: Object) {
if (state.isExpanded) return;
const { startBlock, startOffset, endOffset } = state;
if (startOffset === 0 && startBlock.length === 0)
return this.onBackspace(ev, state);
if (endOffset !== startBlock.length) return;
// Hitting enter while an image is selected should jump caret below and
// insert a new paragraph
if (startBlock.type === 'image') {
ev.preventDefault();
return state
.transform()
.collapseToEnd()
.insertBlock('paragraph')
.apply();
}
// Hitting enter in a heading or blockquote will split the node at that
// point and make the new node a paragraph
if (
startBlock.type !== 'heading1' &&
startBlock.type !== 'heading2' &&
startBlock.type !== 'heading3' &&
startBlock.type !== 'heading4' &&
startBlock.type !== 'heading5' &&
startBlock.type !== 'heading6' &&
startBlock.type !== 'block-quote'
) {
return;
}
ev.preventDefault();
return state.transform().splitBlock().setBlock('paragraph').apply();
},
/**
* Get the block type for a series of auto-markdown shortcut `chars`.
*/
getType(chars: string) {
switch (chars) {
case '*':
case '-':
case '+':
case '1.':
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

@@ -0,0 +1,106 @@
// @flow
import React from 'react';
import Code from './components/Code';
import HorizontalRule from './components/HorizontalRule';
import InlineCode from './components/InlineCode';
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 { Props, Node, Transform } from './types';
const createSchema = () => {
return {
marks: {
bold: (props: Props) => <strong>{props.children}</strong>,
code: (props: Props) => <InlineCode>{props.children}</InlineCode>,
italic: (props: Props) => <em>{props.children}</em>,
underlined: (props: Props) => <u>{props.children}</u>,
deleted: (props: Props) => <del>{props.children}</del>,
added: (props: Props) => <mark>{props.children}</mark>,
},
nodes: {
paragraph: (props: Props) => <Paragraph {...props} />,
'block-quote': (props: Props) => (
<blockquote>{props.children}</blockquote>
),
'horizontal-rule': HorizontalRule,
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
'todo-list': (props: Props) => <TodoList>{props.children}</TodoList>,
table: (props: Props) => <table>{props.children}</table>,
'table-row': (props: Props) => <tr>{props.children}</tr>,
'table-head': (props: Props) => <th>{props.children}</th>,
'table-cell': (props: Props) => <td>{props.children}</td>,
code: Code,
image: Image,
link: Link,
'list-item': ListItem,
heading1: (props: Props) => <Heading1 placeholder {...props} />,
heading2: (props: Props) => <Heading2 {...props} />,
heading3: (props: Props) => <Heading3 {...props} />,
heading4: (props: Props) => <Heading4 {...props} />,
heading5: (props: Props) => <Heading5 {...props} />,
heading6: (props: Props) => <Heading6 {...props} />,
},
rules: [
// ensure first node is always a heading
{
match: (node: Node) => {
return node.kind === 'document';
},
validate: (document: Node) => {
const firstNode = document.nodes.first();
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
},
normalize: (transform: Transform, document: Node, firstNode: Node) => {
transform.setBlock({ type: 'heading1' });
},
},
// automatically removes any marks in first heading
{
match: (node: Node) => {
return node.kind === 'heading1';
},
validate: (heading: Node) => {
const hasMarks = heading.getMarks().isEmpty();
const hasInlines = heading.getInlines().isEmpty();
return !(hasMarks && hasInlines);
},
normalize: (transform: Transform, heading: Node) => {
transform.unwrapInlineByKey(heading.key);
heading.getMarks().forEach(mark => {
heading.nodes.forEach(textNode => {
if (textNode.kind === 'text') {
transform.removeMarkByKey(
textNode.key,
0,
textNode.text.length,
mark
);
}
});
});
return transform;
},
},
],
};
};
export default createSchema;

View File

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

View File

@@ -0,0 +1,113 @@
// @flow
import { List, Set, Map } from 'immutable';
import { Selection } from 'slate';
export type NodeTransform = {
addMarkByKey: Function,
insertNodeByKey: Function,
insertTextByKey: Function,
moveNodeByKey: Function,
removeMarkByKey: Function,
removeNodeByKey: Function,
removeTextByKey: Function,
setMarkByKey: Function,
setNodeByKey: Function,
splitNodeByKey: Function,
unwrapInlineByKey: Function,
unwrapBlockByKey: Function,
unwrapNodeByKey: Function,
wrapBlockByKey: Function,
wrapInlineByKey: Function,
};
export type StateTransform = {
deleteBackward: Function,
deleteForward: Function,
delete: Function,
insertBlock: Function,
insertFragment: Function,
insertInline: Function,
insertText: Function,
addMark: Function,
setBlock: Function,
setInline: Function,
splitBlock: Function,
splitInline: Function,
removeMark: Function,
toggleMark: Function,
unwrapBlock: Function,
unwrapInline: Function,
wrapBlock: Function,
wrapInline: Function,
wrapText: Function,
};
export type Transform = NodeTransform & StateTransform;
export type Editor = {
props: Object,
className: string,
onChange: Function,
onDocumentChange: Function,
onSelectionChange: Function,
plugins: Array<Object>,
readOnly: boolean,
state: Object,
style: Object,
placeholder?: string,
placeholderClassName?: string,
placeholderStyle?: string,
blur: Function,
focus: Function,
getSchema: Function,
getState: Function,
};
export type Node = {
key: string,
kind: string,
type: string,
length: number,
text: string,
data: Map<string, any>,
nodes: List<Node>,
getMarks: Function,
getBlocks: Function,
getParent: Function,
getInlines: Function,
getInlinesAtRange: Function,
setBlock: Function,
};
export type Block = Node & {
type: string,
};
export type Document = Node;
export type State = {
document: Document,
selection: Selection,
startBlock: Block,
endBlock: Block,
startText: Node,
endText: Node,
marks: Set<*>,
blocks: List<Block>,
fragment: Document,
lines: List<Node>,
tests: List<Node>,
startBlock: Block,
transform: Function,
isBlurred: Function,
};
export type Props = {
node: Node,
parent?: Node,
attributes?: Object,
state: State,
editor: Editor,
readOnly?: boolean,
children?: React$Element<any>,
};