Moving previews to client side rendering for consistency

This commit is contained in:
Tom Moor
2017-05-25 00:07:41 -07:00
parent 3da1c06620
commit e791509dac
28 changed files with 74 additions and 55 deletions

View File

@@ -0,0 +1,121 @@
// @flow
import React, { Component } from 'react';
import { observer } from 'mobx-react';
import { Editor, Plain } from 'slate';
import classnames from 'classnames/bind';
import type { Document, State, Editor as EditorType } from './types';
import ClickablePadding from './components/ClickablePadding';
import Toolbar from './components/Toolbar';
import schema from './schema';
import Markdown from './serializer';
import createPlugins from './plugins';
import styles from './Editor.scss';
const cx = classnames.bind(styles);
type Props = {
text: string,
onChange: Function,
onSave: Function,
onCancel: Function,
onImageUploadStart: Function,
onImageUploadStop: Function,
readOnly: boolean,
};
type KeyData = {
isMeta: boolean,
key: string,
};
@observer
export default class MarkdownEditor extends Component {
props: Props;
editor: EditorType;
plugins: Array<Object>;
state: {
state: State,
};
constructor(props: Props) {
super(props);
this.plugins = createPlugins({
onImageUploadStart: props.onImageUploadStart,
onImageUploadStop: props.onImageUploadStop,
});
if (props.text) {
this.state = { state: Markdown.deserialize(props.text) };
} else {
this.state = { state: Plain.deserialize('') };
}
}
onChange = (state: State) => {
this.setState({ state });
};
onDocumentChange = (document: Document, state: State) => {
this.props.onChange(Markdown.serialize(state));
};
onKeyDown = (ev: SyntheticKeyboardEvent, data: KeyData, state: State) => {
if (!data.isMeta) return;
switch (data.key) {
case 's':
ev.preventDefault();
ev.stopPropagation();
return this.props.onSave({ redirect: false });
case 'enter':
ev.preventDefault();
ev.stopPropagation();
this.props.onSave();
return state;
case 'escape':
return this.props.onCancel();
default:
}
};
focusAtStart = () => {
const state = this.editor.getState();
const transform = state.transform();
transform.collapseToStartOf(state.document);
transform.focus();
this.setState({ state: transform.apply() });
};
focusAtEnd = () => {
const state = this.editor.getState();
const transform = state.transform();
transform.collapseToEndOf(state.document);
transform.focus();
this.setState({ state: transform.apply() });
};
render = () => {
return (
<span className={styles.container}>
<ClickablePadding onClick={this.focusAtStart} />
<Toolbar state={this.state.state} onChange={this.onChange} />
<Editor
ref={ref => (this.editor = ref)}
placeholder="Start with a title…"
className={cx(styles.editor, { readOnly: this.props.readOnly })}
schema={schema}
plugins={this.plugins}
state={this.state.state}
onChange={this.onChange}
onDocumentChange={this.onDocumentChange}
onKeyDown={this.onKeyDown}
onSave={this.props.onSave}
readOnly={this.props.readOnly}
/>
<ClickablePadding onClick={this.focusAtEnd} grow />
</span>
);
};
}

View File

@@ -0,0 +1,143 @@
.container {
display: flex;
flex: 1;
flex-direction: column;
font-weight: 400;
font-size: 1em;
line-height: 1.5em;
padding: 0 3em;
max-width: 50em;
}
.editor {
background: #fff;
color: #1b2631;
height: auto;
width: 100%;
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 500;
.anchor {
visibility: hidden;
color: #dedede;
padding-left: .25em;
}
&:hover {
.anchor {
visibility: visible;
&:hover {
color: #cdcdcd;
}
}
}
}
ul,
ol {
margin: 1em .1em;
padding-left: 1em;
ul,
ol {
margin: .1em;
}
}
li p {
display: inline;
margin: 0;
}
.todoList {
list-style: none;
padding-left: 0;
.todoList {
padding-left: 1em;
}
}
.todo {
span:last-child:focus {
outline: none;
}
}
code,
pre {
background: #efefef;
border-radius: 3px;
border: 1px solid #dedede;
}
pre {
padding: 0 .5em;
code {
background: none;
border: 0;
padding: 0;
border-radius: 0;
}
}
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;
}
}
.readOnly {
cursor: default;
}
.title {
position: relative;
}
.placeholder {
position: absolute;
top: 0;
pointer-events: none;
color: #ddd;
}
@media all and (max-width: 2000px) and (min-width: 960px) {
.container {
// margin-top: 48px;
font-size: 1.1em;
}
}
@media all and (max-width: 960px) {
.container {
font-size: 0.9em;
}
}

View File

@@ -0,0 +1,20 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import styles from './ClickablePadding.scss';
type Props = {
onClick: Function,
grow?: boolean,
};
const ClickablePadding = (props: Props) => {
return (
<div
className={classnames(styles.container, { [styles.grow]: props.grow })}
onClick={props.onClick}
/>
);
};
export default ClickablePadding;

View File

@@ -0,0 +1,14 @@
.container {
padding-top: 50px;
cursor: text;
}
.grow {
flex-grow: 1;
}
@media all and (max-width: 960px) {
.container {
padding-top: 50px;
}
}

View File

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

View File

@@ -0,0 +1,13 @@
// @flow
import React from 'react';
import type { Props } from '../types';
export default function Code({ children, attributes }: Props) {
return (
<pre>
<code {...attributes}>
{children}
</code>
</pre>
);
}

View File

@@ -0,0 +1,43 @@
// @flow
import React from 'react';
import _ from 'lodash';
import slug from 'slug';
import type { Node, Editor } from '../types';
import styles from '../Editor.scss';
type Props = {
children: React$Element<any>,
placeholder?: boolean,
parent: Node,
node: Node,
editor: Editor,
readOnly: boolean,
component?: string,
};
export default function Heading({
parent,
placeholder,
node,
editor,
readOnly,
children,
component = 'h1',
}: Props) {
const firstHeading = parent.nodes.first() === node;
const showPlaceholder = placeholder && firstHeading && !node.text;
const slugish = readOnly && _.escape(`${component}-${slug(node.text)}`);
const Component = component;
return (
<Component className={styles.title}>
{children}
{showPlaceholder &&
<span className={styles.placeholder}>
{editor.props.placeholder}
</span>}
{slugish &&
<a name={slugish} className={styles.anchor} href={`#${slugish}`}>#</a>}
</Component>
);
}

View File

@@ -0,0 +1,13 @@
// @flow
import React from 'react';
import type { Props } from '../types';
export default function Image({ attributes, node }: Props) {
return (
<img
{...attributes}
src={node.data.get('src')}
alt={node.data.get('alt')}
/>
);
}

View File

@@ -0,0 +1,11 @@
// @flow
import React from 'react';
import type { Props } from '../types';
export default function Link({ attributes, node, children }: Props) {
return (
<a {...attributes} href={node.data.get('href')}>
{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,39 @@
// @flow
import React, { Component } from 'react';
import type { Props } from '../types';
import styles from '../Editor.scss';
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 (
<li contentEditable={false} className={styles.todo}>
<input
type="checkbox"
checked={checked}
onChange={this.handleChange}
disabled={readOnly}
/>
{' '}
<span contentEditable={!readOnly} suppressContentEditableWarning>
{children}
</span>
</li>
);
}
}

View File

@@ -0,0 +1,142 @@
// @flow
import React, { Component } from 'react';
import Portal from 'react-portal';
import classnames from 'classnames';
import _ from 'lodash';
import type { State } from '../../types';
import FormattingToolbar from './components/FormattingToolbar';
import LinkToolbar from './components/LinkToolbar';
import styles from './Toolbar.scss';
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 classes = classnames(styles.menu, {
[styles.active]: this.state.active,
});
const style = {
top: this.state.top,
left: this.state.left,
};
return (
<Portal isOpened>
<div className={classes} style={style} ref={this.setRef}>
{link &&
<LinkToolbar
{...this.props}
link={link}
onBlur={this.handleBlur}
/>}
{!link &&
<FormattingToolbar
onCreateLink={this.handleFocus}
{...this.props}
/>}
</div>
</Portal>
);
}
}

View File

@@ -0,0 +1,62 @@
.menu {
padding: 8px 16px;
position: absolute;
z-index: 1;
top: -10000px;
left: -10000px;
opacity: 0;
background-color: #222;
border-radius: 4px;
transition: opacity 250ms ease-in-out, transform 250ms ease-in-out;
line-height: 0;
height: 40px;
min-width: 260px;
}
.active {
transform: translateY(-6px);
opacity: 1;
}
.linkEditor {
display: flex;
margin-left: -8px;
margin-right: -8px;
input {
background: rgba(255,255,255,.1);
border-radius: 2px;
padding: 5px 8px;
border: 0;
margin: 0;
outline: none;
color: #fff;
flex-grow: 1;
}
}
.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;
}
&[data-active="true"] {
opacity: 1;
}
}

View File

@@ -0,0 +1,112 @@
// @flow
import React, { Component } from 'react';
import styles from '../Toolbar.scss';
import type { State } from '../../../types';
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 LinkIcon from 'components/Icon/LinkIcon';
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
import BulletedListIcon from 'components/Icon/BulletedListIcon';
export default 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 (
<button
className={styles.button}
onMouseDown={onMouseDown}
data-active={isActive}
>
<IconClass light />
</button>
);
};
renderBlockButton = (type: string, IconClass: Function) => {
const isActive = this.isBlock(type);
const onMouseDown = ev =>
this.onClickBlock(ev, isActive ? 'paragraph' : type);
return (
<button
className={styles.button}
onMouseDown={onMouseDown}
data-active={isActive}
>
<IconClass light />
</button>
);
};
render() {
return (
<span>
{this.renderMarkButton('bold', BoldIcon)}
{this.renderMarkButton('deleted', StrikethroughIcon)}
{this.renderBlockButton('heading1', Heading1Icon)}
{this.renderBlockButton('heading2', Heading2Icon)}
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
{this.renderMarkButton('code', CodeIcon)}
<button className={styles.button} onMouseDown={this.onCreateLink}>
<LinkIcon light />
</button>
</span>
);
}
}

View File

@@ -0,0 +1,66 @@
// @flow
import React, { Component } from 'react';
import type { State } from '../../../types';
import keydown from 'react-keydown';
import styles from '../Toolbar.scss';
import CloseIcon from 'components/Icon/CloseIcon';
@keydown
export default class LinkToolbar extends Component {
input: HTMLElement;
props: {
state: State,
link: Object,
onBlur: Function,
onChange: Function,
};
onKeyDown = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
switch (ev.keyCode) {
case 13: // enter
ev.preventDefault();
return this.save(ev.target.value);
case 26: // escape
return this.input.blur();
default:
}
};
removeLink = () => {
this.save('');
};
save = (href: string) => {
href = href.trim();
const transform = this.props.state.transform();
transform.unwrapInline('link');
if (href) {
const data = { href };
transform.wrapInline({ type: 'link', data });
}
const state = transform.apply();
this.props.onChange(state);
this.input.blur();
};
render() {
const href = this.props.link.data.get('href');
return (
<span className={styles.linkEditor}>
<input
ref={ref => (this.input = ref)}
defaultValue={href}
placeholder="http://"
onBlur={this.props.onBlur}
onKeyDown={this.onKeyDown}
autoFocus
/>
<button className={styles.button} onMouseDown={this.removeLink}>
<CloseIcon light />
</button>
</span>
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
// @flow
import DropOrPasteImages from 'slate-drop-or-paste-images';
import PasteLinkify from 'slate-paste-linkify';
import EditList from 'slate-edit-list';
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 uploadFile from 'utils/uploadFile';
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
const onlyInCode = node => node.type === 'code';
const createPlugins = ({
onImageUploadStart,
onImageUploadStop,
}: {
onImageUploadStart: Function,
onImageUploadStop: Function,
}) => {
return [
PasteLinkify({
type: 'link',
collapseTo: 'end',
}),
DropOrPasteImages({
extensions: ['png', 'jpg', 'gif'],
applyTransform: async (transform, file) => {
try {
onImageUploadStart();
const asset = await uploadFile(file);
const alt = file.name;
const src = asset.url;
return transform.insertBlock({
type: 'image',
isVoid: true,
data: { src, alt },
});
} catch (err) {
// TODO: Show a failure alert
} finally {
onImageUploadStop();
}
},
}),
EditList({
types: ['ordered-list', 'bulleted-list', 'todo-list'],
typeItem: 'list-item',
}),
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,39 @@
// @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');
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,244 @@
// @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 '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).replace(/\s*/g, '');
const type = this.getType(chars);
if (type) {
if (type === 'list-item' && startBlock.type === 'list-item') return;
ev.preventDefault();
const transform = state.transform().setBlock(type);
if (type === 'list-item') {
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 = [];
for (let i = 0; i < startBlock.text.length; i++) {
if (startBlock.text.slice(i, i + shortcut.length) === shortcut)
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();
const transform = state
.transform()
.extendToStartOf(startBlock)
.delete()
.setBlock({
type: 'horizontal-rule',
isVoid: true,
});
state = transform
.collapseToStartOfNextBlock()
.insertBlock('paragraph')
.apply();
return state;
}
},
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 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;
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.':
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,94 @@
// @flow
import React from 'react';
import Code from './components/Code';
import Image from './components/Image';
import Link from './components/Link';
import ListItem from './components/ListItem';
import Heading from './components/Heading';
import type { Props, Node, Transform } from './types';
import styles from './Editor.scss';
const schema = {
marks: {
bold: (props: Props) => <strong>{props.children}</strong>,
code: (props: Props) => <code>{props.children}</code>,
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) => <p>{props.children}</p>,
'block-quote': (props: Props) => <blockquote>{props.children}</blockquote>,
'horizontal-rule': (props: Props) => <hr />,
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
'todo-list': (props: Props) => (
<ul className={styles.todoList}>{props.children}</ul>
),
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) => <Heading placeholder {...props} />,
heading2: (props: Props) => <Heading component="h2" {...props} />,
heading3: (props: Props) => <Heading component="h3" {...props} />,
heading4: (props: Props) => <Heading component="h4" {...props} />,
heading5: (props: Props) => <Heading component="h5" {...props} />,
heading6: (props: Props) => <Heading component="h6" {...props} />,
},
rules: [
// ensure first node is 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' });
},
},
// remove 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 schema;

View File

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

View File

@@ -0,0 +1,110 @@
// @flow
import { List, Set, Map } from 'immutable';
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,
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 Props = {
node: Node,
parent?: Node,
attributes?: Object,
editor: Editor,
readOnly?: boolean,
children?: React$Element<any>,
};
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,
};