453 lines
13 KiB
JavaScript
Raw Normal View History

import React, { Component, PropTypes } from 'react';
import { Map, List } from 'immutable';
import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw} from 'slate';
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkToRehype from 'remark-rehype';
import rehypeToHtml from 'rehype-stringify';
import remarkToMarkdown from 'remark-stringify';
import htmlToRehype from 'rehype-parse';
import rehypeToRemark from 'rehype-remark';
2017-05-22 14:04:24 -04:00
import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import { buildKeymap } from './keymap';
import createMarkdownParser from './parser';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../UI/Sticky/Sticky';
2017-05-22 14:04:24 -04:00
import styles from './index.css';
// Register handler to transform html to markdown before persist
2017-06-22 17:35:47 -04:00
registry.registerWidgetValueSerializer('markdown', {
serialize: value => unified()
.use(htmlToRehype)
.use(htmlToRehype)
.use(rehypeToRemark)
.use(remarkToMarkdown)
.processSync(value)
.contents,
deserialize: value => unified()
.use(markdownToRemark)
.use(remarkToRehype)
.use(rehypeToHtml)
.processSync(value)
.contents
});
2016-11-01 17:58:19 -07:00
function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
return url;
}
if (url.match(/^[^/]+\.[^/]+/)) {
2016-11-01 17:58:19 -07:00
return `https://${ url }`;
}
return `/${ url }`;
}
const DEFAULT_NODE = 'paragraph';
2016-11-01 17:58:19 -07:00
2016-11-01 23:31:20 -07:00
function schemaWithPlugins(schema, plugins) {
let nodeSpec = schema.nodeSpec;
plugins.forEach((plugin) => {
const attrs = {};
2017-05-22 14:04:24 -04:00
plugin.get('fields').forEach((field) => {
attrs[field.get('name')] = { default: null };
2016-11-01 23:31:20 -07:00
});
2017-05-22 14:04:24 -04:00
nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get('id') }`, {
2016-11-01 23:31:20 -07:00
attrs,
2017-05-22 14:04:24 -04:00
group: 'block',
parseDOM: [{
tag: 'div[data-plugin]',
getAttrs(dom) {
return JSON.parse(dom.getAttribute('data-plugin'));
2016-11-01 23:31:20 -07:00
},
}],
2016-11-01 23:31:20 -07:00
toDOM(node) {
return ['div', { 'data-plugin': JSON.stringify(node.attrs) }, plugin.get('label')];
2016-11-01 23:31:20 -07:00
},
});
});
return new Schema({
nodes: nodeSpec,
marks: schema.markSpec,
});
}
function createSerializer(schema, plugins) {
const serializer = Object.create(defaultMarkdownSerializer);
plugins.forEach((plugin) => {
2017-05-22 14:04:24 -04:00
serializer.nodes[`plugin_${ plugin.get('id') }`] = (state, node) => {
const toBlock = plugin.get('toBlock');
2017-01-10 22:23:22 -02:00
state.write(`${ toBlock.call(plugin, node.attrs) }\n\n`);
2016-11-01 23:31:20 -07:00
};
});
return serializer;
}
const BLOCK_TAGS = {
p: 'paragraph',
li: 'list-item',
ul: 'bulleted-list',
ol: 'numbered-list',
blockquote: 'quote',
pre: 'code',
h1: 'heading-one',
h2: 'heading-two',
h3: 'heading-three',
h4: 'heading-four',
h5: 'heading-five',
h6: 'heading-six'
}
const MARK_TAGS = {
strong: 'bold',
em: 'italic',
u: 'underline',
s: 'strikethrough',
code: 'code'
}
2017-06-21 16:53:54 -04:00
const BLOCK_COMPONENTS = {
'paragraph': props => <p>{props.children}</p>,
'list-item': props => <li {...props.attributes}>{props.children}</li>,
'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
2017-06-21 16:53:54 -04:00
'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>,
'quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>,
'code': props => <pre {...props.attributes}><code>{props.children}</code></pre>,
'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>,
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
'heading-three': props => <h3 {...props.attributes}>{props.children}</h3>,
'heading-four': props => <h4 {...props.attributes}>{props.children}</h4>,
'heading-five': props => <h5 {...props.attributes}>{props.children}</h5>,
'heading-six': props => <h6 {...props.attributes}>{props.children}</h6>,
};
2017-06-21 16:53:54 -04:00
const NODE_COMPONENTS = {
...BLOCK_COMPONENTS,
'link': props => {
const href = props.node && props.node.getIn(['data', 'href']) || props.href;
return <a href={href} {...props.attributes}>{props.children}</a>;
},
}
const MARK_COMPONENTS = {
bold: props => <strong>{props.children}</strong>,
italic: props => <em>{props.children}</em>,
underlined: props => <u>{props.children}</u>,
2017-06-21 16:53:54 -04:00
strikethrough: props => <s>{props.children}</s>,
code: props => <code>{props.children}</code>,
};
const RULES = [
{
deserialize(el, next) {
const block = BLOCK_TAGS[el.tagName]
if (!block) return
return {
kind: 'block',
type: block,
nodes: next(el.children)
}
},
serialize(entity, children) {
2017-06-21 16:53:54 -04:00
const component = BLOCK_COMPONENTS[entity.type]
if (!component) {
return;
}
return component({ children });
}
},
{
deserialize(el, next) {
const mark = MARK_TAGS[el.tagName]
if (!mark) return
return {
kind: 'mark',
type: mark,
nodes: next(el.children)
}
},
serialize(entity, children) {
const component = MARK_COMPONENTS[entity.type]
if (!component) {
return;
}
return component({ children });
}
},
{
// Special case for code blocks, which need to grab the nested children.
deserialize(el, next) {
if (el.tagName != 'pre') return
const code = el.children[0]
const children = code && code.tagName == 'code'
? code.children
: el.children
return {
kind: 'block',
type: 'code',
nodes: next(children)
}
},
},
{
// Special case for links, to grab their href.
deserialize(el, next) {
if (el.tagName != 'a') return
return {
kind: 'inline',
type: 'link',
nodes: next(el.children),
data: {
href: el.attribs.href
}
}
},
2017-06-21 16:53:54 -04:00
serialize(entity, children) {
if (entity.type !== 'link') {
return;
}
const data = entity.get('data');
const props = {
href: data.get('href'),
attributes: data.get('attributes'),
children,
};
return NODE_COMPONENTS.link(props);
}
},
]
const serializer = new SlateHtml({ rules: RULES });
export default class Editor extends Component {
constructor(props) {
super(props);
2016-11-01 23:31:20 -07:00
const plugins = registry.getEditorComponents();
this.state = {
editorState: serializer.deserialize(this.props.value || '<p></p>'),
schema: {
nodes: NODE_COMPONENTS,
marks: MARK_COMPONENTS,
},
2016-11-01 23:31:20 -07:00
plugins,
};
}
handleDocumentChange = (doc, editorState) => {
const html = serializer.serialize(editorState);
this.props.onChange(html);
};
2017-07-23 19:38:05 +02:00
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
hasBlock = type => this.state.editorState.blocks.some(node => node.type === type);
2017-07-23 19:38:05 +02:00
handleKeyDown = (e, data, state) => {
if (!data.isMod) {
return;
2017-07-23 19:38:05 +02:00
}
const marks = {
b: 'bold',
i: 'italic',
u: 'underlined',
'`': 'code',
};
2017-07-23 19:38:05 +02:00
const mark = marks[data.key];
if (mark) {
state = state.transform().toggleMark(mark).apply();
}
return;
2016-10-03 14:25:27 +02:00
};
handleMarkClick = (event, type) => {
event.preventDefault();
2017-06-21 17:08:53 -04:00
const resolvedState = this.state.editorState.transform().focus().toggleMark(type).apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
2016-10-03 14:25:27 +02:00
};
handleBlockClick = (event, type) => {
event.preventDefault();
let { editorState } = this.state;
2017-06-21 17:08:53 -04:00
const transform = editorState.transform().focus();
const doc = editorState.document;
const isList = this.hasBlock('list-item')
// Handle everything except list buttons.
if (!['bulleted-list', 'numbered-list'].includes(type)) {
const isActive = this.hasBlock(type);
const transformed = transform.setBlock(isActive ? DEFAULT_NODE : type);
if (isList) {
transformed
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list');
}
}
// Handle the extra wrapping required for list buttons.
else {
const isType = editorState.blocks.some(block => {
return !!doc.getClosest(block.key, parent => parent.type === type);
});
if (isList && isType) {
transform
.setBlock(DEFAULT_NODE)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list');
} else if (isList) {
transform
.unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
.wrapBlock(type);
} else {
transform
.setBlock('list-item')
.wrapBlock(type);
}
}
2017-06-21 17:08:53 -04:00
const resolvedState = transform.apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
2016-10-03 14:25:27 +02:00
};
2016-11-01 17:58:19 -07:00
handleLink = () => {
let url = null;
2016-11-01 23:31:20 -07:00
if (!markActive(this.view.state, this.state.schema.marks.link)) {
url = prompt('Link URL:'); // eslint-disable-line no-alert
2016-11-01 17:58:19 -07:00
}
const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null });
2016-11-01 17:58:19 -07:00
command(this.view.state, this.handleAction);
};
2017-04-18 11:09:14 -04:00
handlePluginSubmit = (plugin, data) => {
2016-11-01 23:31:20 -07:00
const { schema } = this.state;
2017-05-22 14:04:24 -04:00
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
//this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
2016-11-01 23:31:20 -07:00
};
handleDragEnter = (e) => {
e.preventDefault();
this.setState({ dragging: true });
};
handleDragLeave = (e) => {
e.preventDefault();
this.setState({ dragging: false });
};
handleDragOver = (e) => {
e.preventDefault();
};
handleDrop = (e) => {
e.preventDefault();
this.setState({ dragging: false });
const { schema } = this.state;
const nodes = [];
if (e.dataTransfer.files && e.dataTransfer.files.length) {
Array.from(e.dataTransfer.files).forEach((file) => {
createAssetProxy(file.name, file)
.then((assetProxy) => {
2017-01-10 22:23:22 -02:00
this.props.onAddAsset(assetProxy);
2017-05-22 14:04:24 -04:00
if (file.type.split('/')[0] === 'image') {
2017-01-10 22:23:22 -02:00
nodes.push(
schema.nodes.image.create({ src: assetProxy.public_path, alt: file.name })
2017-01-10 22:23:22 -02:00
);
} else {
nodes.push(
schema.marks.link.create({ href: assetProxy.public_path, title: file.name })
2017-01-10 22:23:22 -02:00
);
}
});
});
} else {
nodes.push(schema.nodes.paragraph.create({}, e.dataTransfer.getData('text/plain')));
}
nodes.forEach((node) => {
//this.view.props.onAction(this.view.state.tr.replaceSelectionWith(node).action());
});
};
handleToggle = () => {
2017-05-22 14:04:24 -04:00
this.props.onMode('raw');
2016-10-03 14:25:27 +02:00
};
getButtonProps = (type, isBlock) => {
const handler = isBlock ? this.handleBlockClick: this.handleMarkClick;
const isActive = isBlock ? this.hasBlock : this.hasMark;
return { onAction: e => handler(e, type), active: isActive(type) };
};
render() {
2017-01-10 22:23:22 -02:00
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { plugins, selectionPosition, dragging } = this.state;
const classNames = [styles.editor];
if (dragging) {
classNames.push(styles.dragging);
}
return (<div
className={classNames.join(' ')}
onDragEnter={this.handleDragEnter}
onDragLeave={this.handleDragLeave}
onDragOver={this.handleDragOver}
onDrop={this.handleDrop}
>
<Sticky
className={styles.editorControlBar}
classNameActive={styles.editorControlBarSticky}
fillContainerWidth
2017-05-04 15:19:43 -04:00
>
<Toolbar
selectionPosition={selectionPosition}
buttons={{
h1: this.getButtonProps('heading-one', true),
h2: this.getButtonProps('heading-two', true),
bold: this.getButtonProps('bold'),
italic: this.getButtonProps('italic'),
link: this.getButtonProps('link'),
}}
onToggleMode={this.handleToggle}
plugins={plugins}
onSubmit={this.handlePluginSubmit}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
</Sticky>
<SlateEditor
className={styles.slateEditor}
state={this.state.editorState}
schema={this.state.schema}
onChange={editorState => this.setState({ editorState })}
onDocumentChange={this.handleDocumentChange}
onKeyDown={this.onKeyDown}
ref={ref => this.ref = ref}
spellCheck
/>
<div className={styles.shim} />
</div>);
}
}
Editor.propTypes = {
2017-01-10 22:23:22 -02:00
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.node,
};