import React, { Component, PropTypes } from 'react';
import ReactDOMServer from 'react-dom/server';
import { Map, List, fromJS } from 'immutable';
import { get, reduce, mapValues } from 'lodash';
import cn from 'classnames';
import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw, Text as SlateText, Block as SlateBlock, Selection as SlateSelection} from 'slate';
import EditList from 'slate-edit-list';
import EditTable from 'slate-edit-table';
import { markdownToRemark, remarkToMarkdown, slateToRemark, remarkToSlate, markdownToHtml, htmlToMarkdown } from '../../unified';
import registry from '../../../../../lib/registry';
import { createAssetProxy } from '../../../../../valueObjects/AssetProxy';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
import styles from './index.css';
function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
return url;
}
if (url.match(/^[^/]+\.[^/]+/)) {
return `https://${ url }`;
}
return `/${ url }`;
}
const DEFAULT_NODE = 'paragraph';
function schemaWithPlugins(schema, plugins) {
let nodeSpec = schema.nodeSpec;
plugins.forEach((plugin) => {
const attrs = {};
plugin.get('fields').forEach((field) => {
attrs[field.get('name')] = { default: null };
});
nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get('id') }`, {
attrs,
group: 'block',
parseDOM: [{
tag: 'div[data-plugin]',
getAttrs(dom) {
return JSON.parse(dom.getAttribute('data-plugin'));
},
}],
toDOM(node) {
return ['div', { 'data-plugin': JSON.stringify(node.attrs) }, plugin.get('label')];
},
});
});
return new Schema({
nodes: nodeSpec,
marks: schema.markSpec,
});
}
function createSerializer(schema, plugins) {
const serializer = Object.create(defaultMarkdownSerializer);
plugins.forEach((plugin) => {
serializer.nodes[`plugin_${ plugin.get('id') }`] = (state, node) => {
const toBlock = plugin.get('toBlock');
state.write(`${ toBlock.call(plugin, node.attrs) }\n\n`);
};
});
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',
del: 'strikethrough',
code: 'code'
}
const BLOCK_COMPONENTS = {
'container': props =>
{props.children}
,
'paragraph': props => {props.children}
,
'list-item': props => {props.children},
'numbered-list': props => {
const { data } = props.node;
const start = data.get('start') || 1;
return {props.children}
;
},
'bulleted-list': props => ,
'quote': props => {props.children}
,
'code': props => {props.children}
,
'heading-one': props => {props.children}
,
'heading-two': props => {props.children}
,
'heading-three': props => {props.children}
,
'heading-four': props => {props.children}
,
'heading-five': props => {props.children}
,
'heading-six': props => {props.children}
,
'image': props => {
const data = props.node && props.node.get('data');
const src = data && data.get('src') || props.src;
const alt = data && data.get('alt') || props.alt;
const title = data && data.get('title') || props.title;
return ;
},
'table': props => ,
'table-row': props => {props.children}
,
'table-cell': props => {props.children} | ,
'thematic-break': props =>
,
};
const getShortcodeId = props => {
if (props.node) {
const result = props.node.getIn(['data', 'shortcode', 'shortcodeId']);
return result || props.node.getIn(['data', 'shortcode']).shortcodeId;
}
return null;
}
const shortcodeStyles = {border: '2px solid black', padding: '8px', margin: '2px 0', cursor: 'pointer'};
const NODE_COMPONENTS = {
...BLOCK_COMPONENTS,
'link': props => {
const data = props.node.get('data');
const href = data && data.get('url') || props.href;
const title = data && data.get('title') || props.title;
return {props.children};
},
'shortcode': props => {
const { attributes, node, state: editorState } = props;
const { data } = node;
const isSelected = editorState.selection.hasFocusIn(node);
return (
{data.get('shortcode')}
);
},
};
const MARK_COMPONENTS = {
bold: props => {props.children},
italic: props => {props.children},
strikethrough: props => {props.children},
code: props => {props.children}
,
};
const RULES = [
{
deserialize(el, next) {
const shortcodeId = el.attribs && el.attribs['data-ncp'];
if (!shortcodeId) {
return;
}
const plugin = registry.getEditorComponents().get(shortcodeId);
if (!plugin) {
return;
}
const shortcodeData = Map(el.attribs).reduce((acc, value, key) => {
if (key.startsWith('data-ncp-')) {
const dataKey = key.slice('data-ncp-'.length).toLowerCase();
if (dataKey) {
return acc.set(dataKey, value);
}
}
return acc;
}, Map({ shortcodeId }));
const result = {
kind: 'block',
isVoid: true,
type: 'shortcode',
data: { shortcode: shortcodeData },
};
return result;
},
serialize(entity, children) {
if (entity.type !== 'shortcode') {
return;
}
const data = Map(entity.data.get('shortcode'));
const shortcodeId = data.get('shortcodeId');
const plugin = registry.getEditorComponents().get(shortcodeId);
const dataAttrs = data.delete('shortcodeId').mapKeys(key => `data-ncp-${key}`).set('data-ncp', shortcodeId);
const preview = plugin.toPreview(data.toJS());
const component = typeof preview === 'string'
?
: {preview}
;
return component;
},
},
{
deserialize(el, next) {
const block = BLOCK_TAGS[el.tagName]
if (!block) return
return {
kind: 'block',
type: block,
nodes: next(el.children)
}
},
serialize(entity, children) {
if (['bulleted-list', 'numbered-list'].includes(entity.type)) {
return;
}
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) {
if (entity.kind !== 'mark') {
return;
}
const component = MARK_COMPONENTS[entity.type]
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)
}
},
},
{
deserialize(el, next) {
if (el.tagName != 'img') return
return {
kind: 'block',
type: 'image',
isVoid: true,
nodes: [],
data: {
src: el.attribs.src,
alt: el.attribs.alt,
title: el.attribs.title,
}
}
},
serialize(entity, children) {
if (entity.type !== 'image') {
return;
}
const data = entity.get('data');
const props = {
src: data.get('src'),
alt: data.get('alt'),
title: data.get('title'),
};
const result = NODE_COMPONENTS.image(props);
return result;
}
},
{
// 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,
title: el.attribs.title,
}
}
},
serialize(entity, children) {
if (entity.type !== 'link') {
return;
}
const data = entity.get('data');
const props = {
href: data.get('href'),
title: data.get('title'),
attributes: data.get('attributes'),
children,
};
return NODE_COMPONENTS.link(props);
}
},
{
serialize(entity, children) {
if (!['bulleted-list', 'numbered-list'].includes(entity.type)) {
return;
}
return NODE_COMPONENTS[entity.type]({ children });
}
}
]
const htmlSerializer = new SlateHtml({ rules: RULES });
const SoftBreak = (options = {}) => ({
onKeyDown(e, data, state) {
if (data.key != 'enter') return;
if (options.shift && e.shiftKey == false) return;
const { onlyIn, ignoreIn, closeAfter, unwrapBlocks, defaultBlock = 'paragraph' } = options;
const { type, nodes } = state.startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
const shouldClose = nodes.last().characters.takeLast(closeAfter).every(c => c.text === '\n');
if (closeAfter && shouldClose) {
const trimmed = state.transform().deleteBackward(closeAfter);
const unwrapped = unwrapBlocks
? unwrapBlocks.reduce((acc, blockType) => acc.unwrapBlock(blockType), trimmed)
: trimmed;
return unwrapped.insertBlock(defaultBlock).apply();
}
return state.transform().insertText('\n').apply();
}
});
const BackspaceCloseBlock = (options = {}) => ({
onKeyDown(e, data, state) {
if (data.key != 'backspace') return;
const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options;
const { startBlock } = state;
const { type } = startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
const characters = startBlock.getFirstText().characters;
const isEmpty = !characters || characters.isEmpty();
if (isEmpty) {
return state.transform().insertBlock(defaultBlock).focus().apply();
}
}
});
const slatePlugins = [
SoftBreak({ ignoreIn: ['paragraph', 'list-item', 'numbered-list', 'bulleted-list', 'table', 'table-row', 'table-cell'], closeAfter: 1 }),
BackspaceCloseBlock({ ignoreIn: ['paragraph', 'list-item', 'bulleted-list', 'numbered-list', 'table', 'table-row', 'table-cell'] }),
EditList({ types: ['bulleted-list', 'numbered-list'], typeItem: 'list-item' }),
EditTable({ typeTable: 'table', typeRow: 'table-row', typeCell: 'table-cell' }),
];
export default class Editor extends Component {
constructor(props) {
super(props);
const plugins = registry.getEditorComponents();
const emptyRaw = {
nodes: [{ kind: 'block', type: 'paragraph', nodes: [
{ kind: 'text', ranges: [{ text: '' }] }
]}],
};
const remark = this.props.value && remarkToSlate(this.props.value);
const initialValue = get(remark, ['nodes', 'length']) ? remark : emptyRaw;
const editorState = SlateRaw.deserialize(initialValue, { terse: true });
this.state = {
editorState,
schema: {
nodes: NODE_COMPONENTS,
marks: MARK_COMPONENTS,
rules: [
{
match: object => object.kind === 'document',
validate: doc => {
const hasBlocks = !doc.getBlocks().isEmpty();
return hasBlocks ? null : {};
},
normalize: transform => {
const block = SlateBlock.create({
type: 'paragraph',
nodes: [SlateText.createFromString('')],
});
const { key } = transform.state.document;
return transform.insertNodeByKey(key, 0, block).focus();
},
},
],
},
plugins,
};
}
handlePaste = (e, data, state) => {
if (data.type !== 'html' || data.isShift) {
return;
}
const markdown = htmlToMarkdown(data.html);
const html = markdownToHtml(markdown);
const fragment = serializer.deserialize(html).document;
return state.transform().insertFragment(fragment).apply();
}
handleDocumentChange = (doc, editorState) => {
const raw = SlateRaw.serialize(editorState, { terse: true });
const mdast = slateToRemark(raw);
this.props.onChange(mdast);
};
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
hasBlock = type => this.state.editorState.blocks.some(node => node.type === type);
handleKeyDown = (e, data, state) => {
const createDefaultBlock = () => {
return SlateBlock.create({
type: 'paragraph',
nodes: [SlateText.createFromString('')]
});
};
if (data.key === 'enter') {
/**
* If a single void block is selected, and it's a direct descendant of the
* document (top level), a new paragraph should be added above or below it
* when 'Enter' is pressed, and the current selection should move to the
* new paragraph.
*
* If the selected block is the first block in the document, create the
* new block above it. If not, create the new block below it.
*/
const { document: doc, selection, anchorBlock, focusBlock } = state;
const focusBlockIndex = doc.nodes.indexOf(focusBlock);
const focusBlockIsTopLevel = focusBlockIndex > -1;
const focusBlockIsFirstChild = focusBlockIndex === 0;
const singleBlockSelected = anchorBlock === focusBlock;
if (focusBlock.isVoid && focusBlockIsTopLevel && singleBlockSelected) {
e.preventDefault();
const newBlock = createDefaultBlock();
const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1;
return state.transform()
.insertNodeByKey(doc.key, newBlockIndex, newBlock)
.collapseToStartOf(newBlock)
.apply();
}
}
if (data.isMod) {
if (data.key === 'y') {
e.preventDefault();
return state.transform().redo().focus().apply({ save: false });
}
if (data.key === 'z') {
e.preventDefault();
return state.transform()[data.isShift ? 'redo' : 'undo']().focus().apply({ save: false });
}
const marks = {
b: 'bold',
i: 'italic',
u: 'underlined',
s: 'strikethrough',
'`': 'code',
};
const mark = marks[data.key];
if (mark) {
e.preventDefault();
return state.transform().toggleMark(mark).apply();
}
}
};
handleMarkClick = (event, type) => {
event.preventDefault();
const resolvedState = this.state.editorState.transform().focus().toggleMark(type).apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
handleBlockClick = (event, type) => {
event.preventDefault();
let { editorState } = this.state;
const transform = editorState.transform();
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);
}
}
const resolvedState = transform.focus().apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
handleLink = () => {
let url = null;
if (!markActive(this.view.state, this.state.schema.marks.link)) {
url = prompt('Link URL:'); // eslint-disable-line no-alert
}
const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null });
command(this.view.state, this.handleAction);
};
handlePluginSubmit = (plugin, shortcodeData) => {
const { editorState } = this.state;
const data = {
shortcode: plugin.id,
shortcodeValue: plugin.toBlock(shortcodeData.toJS()),
shortcodeData,
};
const nodes = [SlateText.createFromString('')];
const block = { kind: 'block', type: 'shortcode', data, isVoid: true, nodes };
const resolvedState = editorState.transform().insertBlock(block).apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
handleToggle = () => {
this.props.onMode('raw');
};
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() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { plugins, selectionPosition, dragging } = this.state;
return (
this.setState({ editorState })}
onDocumentChange={this.handleDocumentChange}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
ref={ref => this.ref = ref}
spellCheck
/>
);
}
}
Editor.propTypes = {
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.object,
};