2017-09-09 19:39:10 -06:00
|
|
|
import PropTypes from 'prop-types';
|
|
|
|
import React, { Component } from 'react';
|
2017-08-31 21:25:44 -04:00
|
|
|
import { get, isEmpty, debounce } from 'lodash';
|
2017-09-28 11:07:04 -04:00
|
|
|
import { Editor as Slate, State, Document, Block, Text } from 'slate';
|
2017-08-31 20:28:11 -04:00
|
|
|
import { slateToMarkdown, markdownToSlate, htmlToSlate } from '../../serializers';
|
2017-06-23 14:42:40 -04:00
|
|
|
import registry from '../../../../../lib/registry';
|
2017-05-22 14:04:24 -04:00
|
|
|
import Toolbar from '../Toolbar/Toolbar';
|
2017-06-23 14:42:40 -04:00
|
|
|
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
2017-07-27 18:03:13 -04:00
|
|
|
import { MARK_COMPONENTS, NODE_COMPONENTS } from './components';
|
|
|
|
import RULES from './rules';
|
|
|
|
import plugins, { EditListConfigured } from './plugins';
|
|
|
|
import onKeyDown from './keys';
|
2017-05-22 14:04:24 -04:00
|
|
|
import styles from './index.css';
|
2016-11-01 16:55:21 -07:00
|
|
|
|
|
|
|
export default class Editor extends Component {
|
2016-08-11 11:27:09 -03:00
|
|
|
constructor(props) {
|
|
|
|
super(props);
|
2017-07-27 18:03:13 -04:00
|
|
|
const emptyBlock = Block.create({ kind: 'block', type: 'paragraph'});
|
2017-08-31 20:28:11 -04:00
|
|
|
const emptyRawDoc = { nodes: [emptyBlock] };
|
|
|
|
const rawDoc = this.props.value && markdownToSlate(this.props.value);
|
|
|
|
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'))
|
2017-09-28 11:07:04 -04:00
|
|
|
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : emptyRawDoc, { terse: true });
|
|
|
|
const editorState = State.create({ document });
|
2016-11-01 23:31:20 -07:00
|
|
|
this.state = {
|
2017-07-18 19:14:40 -04:00
|
|
|
editorState,
|
2017-06-19 17:15:59 -04:00
|
|
|
schema: {
|
|
|
|
nodes: NODE_COMPONENTS,
|
|
|
|
marks: MARK_COMPONENTS,
|
2017-07-27 18:03:13 -04:00
|
|
|
rules: RULES,
|
2017-06-19 17:15:59 -04:00
|
|
|
},
|
2017-07-30 11:09:35 -04:00
|
|
|
shortcodePlugins: registry.getEditorComponents(),
|
2016-11-01 23:31:20 -07:00
|
|
|
};
|
2016-11-01 16:55:21 -07:00
|
|
|
}
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2017-07-27 08:46:53 -04:00
|
|
|
shouldComponentUpdate(nextProps, nextState) {
|
2017-07-27 18:03:13 -04:00
|
|
|
return !this.state.editorState.equals(nextState.editorState);
|
2017-07-27 08:46:53 -04:00
|
|
|
}
|
|
|
|
|
2017-10-02 11:23:47 -04:00
|
|
|
handlePaste = (e, data, change) => {
|
2017-06-23 17:04:17 -04:00
|
|
|
if (data.type !== 'html' || data.isShift) {
|
|
|
|
return;
|
|
|
|
}
|
2017-07-26 20:55:30 -04:00
|
|
|
const ast = htmlToSlate(data.html);
|
2017-09-28 11:07:04 -04:00
|
|
|
const doc = Document.fromJSON(ast, { terse: true });
|
2017-10-02 11:23:47 -04:00
|
|
|
return change.insertFragment(doc);
|
2017-06-23 17:04:17 -04:00
|
|
|
}
|
|
|
|
|
2017-06-19 17:15:59 -04: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
|
|
|
|
2017-06-19 17:15:59 -04:00
|
|
|
handleMarkClick = (event, type) => {
|
|
|
|
event.preventDefault();
|
2017-10-02 11:23:47 -04:00
|
|
|
const resolvedChange = this.state.editorState.change().focus().toggleMark(type);
|
|
|
|
this.ref.onChange(resolvedChange);
|
|
|
|
this.setState({ editorState: resolvedChange.state });
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2017-06-19 17:15:59 -04:00
|
|
|
handleBlockClick = (event, type) => {
|
|
|
|
event.preventDefault();
|
|
|
|
let { editorState } = this.state;
|
2017-07-26 21:44:39 -04:00
|
|
|
const { document: doc, selection } = editorState;
|
2017-10-02 11:23:47 -04:00
|
|
|
const { unwrapList, wrapInList } = EditListConfigured.changes;
|
|
|
|
let change = editorState.change();
|
2017-06-19 17:15:59 -04:00
|
|
|
|
|
|
|
// Handle everything except list buttons.
|
|
|
|
if (!['bulleted-list', 'numbered-list'].includes(type)) {
|
|
|
|
const isActive = this.hasBlock(type);
|
2017-10-02 11:23:47 -04:00
|
|
|
change = change.setBlock(isActive ? 'paragraph' : type);
|
2017-06-19 17:15:59 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
// Handle the extra wrapping required for list buttons.
|
|
|
|
else {
|
2017-07-26 21:44:39 -04:00
|
|
|
const isSameListType = editorState.blocks.some(block => {
|
2017-06-19 17:15:59 -04:00
|
|
|
return !!doc.getClosest(block.key, parent => parent.type === type);
|
|
|
|
});
|
2017-07-27 18:03:13 -04:00
|
|
|
const isInList = EditListConfigured.utils.isSelectionInList(editorState);
|
2017-07-26 21:44:39 -04:00
|
|
|
|
|
|
|
if (isInList && isSameListType) {
|
2017-10-02 11:23:47 -04:00
|
|
|
change = change.call(unwrapList, type);
|
2017-07-26 21:44:39 -04:00
|
|
|
} else if (isInList) {
|
|
|
|
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
|
2017-10-02 11:23:47 -04:00
|
|
|
change = change.call(unwrapList, currentListType).call(wrapInList, type);
|
2017-06-19 17:15:59 -04:00
|
|
|
} else {
|
2017-10-02 11:23:47 -04:00
|
|
|
change = change.call(wrapInList, type);
|
2017-06-19 17:15:59 -04:00
|
|
|
}
|
2017-05-22 14:34:49 -04:00
|
|
|
}
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2017-10-02 11:23:47 -04:00
|
|
|
const resolvedChange = change.focus();
|
|
|
|
this.ref.onChange(resolvedChange);
|
|
|
|
this.setState({ editorState: resolvedChange.state });
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2017-07-27 11:02:17 -04:00
|
|
|
hasLinks = () => {
|
|
|
|
return this.state.editorState.inlines.some(inline => inline.type === 'link');
|
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2016-11-01 17:58:19 -07:00
|
|
|
handleLink = () => {
|
2017-10-02 11:23:47 -04:00
|
|
|
let change = this.state.editorState.change();
|
2017-07-27 11:02:17 -04:00
|
|
|
|
|
|
|
// If the current selection contains links, clicking the "link" button
|
|
|
|
// should simply unlink them.
|
|
|
|
if (this.hasLinks()) {
|
2017-10-02 11:23:47 -04:00
|
|
|
change = change.unwrapInline('link');
|
2017-07-27 11:02:17 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
else {
|
|
|
|
const url = window.prompt('Enter the URL of the link');
|
|
|
|
|
|
|
|
// If nothing is entered in the URL prompt, do nothing.
|
|
|
|
if (!url) return;
|
|
|
|
|
|
|
|
// If no text is selected, use the entered URL as text.
|
2017-10-02 11:23:47 -04:00
|
|
|
if (change.state.isCollapsed) {
|
|
|
|
change = change
|
2017-07-27 11:02:17 -04:00
|
|
|
.insertText(url)
|
|
|
|
.extend(0 - url.length);
|
|
|
|
}
|
|
|
|
|
2017-10-02 11:23:47 -04:00
|
|
|
change = change
|
2017-07-27 11:02:17 -04:00
|
|
|
.wrapInline({ type: 'link', data: { url } })
|
2017-10-02 11:23:47 -04:00
|
|
|
.collapseToEnd();
|
2016-11-01 17:58:19 -07:00
|
|
|
}
|
2017-07-27 11:02:17 -04:00
|
|
|
|
2017-10-02 11:23:47 -04:00
|
|
|
this.ref.onChange(change);
|
|
|
|
this.setState({ editorState: change.state });
|
2016-11-01 17:58:19 -07:00
|
|
|
};
|
|
|
|
|
2017-07-25 21:45:33 -04:00
|
|
|
handlePluginSubmit = (plugin, shortcodeData) => {
|
2017-07-13 21:33:50 -04:00
|
|
|
const { editorState } = this.state;
|
2017-07-25 21:45:33 -04:00
|
|
|
const data = {
|
|
|
|
shortcode: plugin.id,
|
|
|
|
shortcodeData,
|
|
|
|
};
|
2017-07-27 18:03:13 -04:00
|
|
|
const nodes = [Text.createFromString('')];
|
2017-07-25 21:45:33 -04:00
|
|
|
const block = { kind: 'block', type: 'shortcode', data, isVoid: true, nodes };
|
2017-10-02 11:23:47 -04:00
|
|
|
const resolvedChange = editorState.change().insertBlock(block).focus();
|
|
|
|
this.ref.onChange(resolvedChange);
|
|
|
|
this.setState({ editorState: resolvedChange.state });
|
2016-12-27 23:18:37 -08:00
|
|
|
};
|
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleToggle = () => {
|
2017-05-22 14:04:24 -04:00
|
|
|
this.props.onMode('raw');
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2017-07-27 11:02:17 -04:00
|
|
|
getButtonProps = (type, opts = {}) => {
|
|
|
|
const { isBlock } = opts;
|
|
|
|
const handler = opts.handler || (isBlock ? this.handleBlockClick: this.handleMarkClick);
|
|
|
|
const isActive = opts.isActive || (isBlock ? this.hasBlock : this.hasMark);
|
2017-06-19 17:15:59 -04:00
|
|
|
return { onAction: e => handler(e, type), active: isActive(type) };
|
|
|
|
};
|
|
|
|
|
2017-09-28 11:02:59 -04:00
|
|
|
handleDocumentChange = debounce(change => {
|
|
|
|
const raw = change.state.document.toJSON({ terse: true });
|
|
|
|
const plugins = this.state.shortcodePlugins;
|
|
|
|
const markdown = slateToMarkdown(raw, plugins);
|
|
|
|
this.props.onChange(markdown);
|
|
|
|
}, 150);
|
|
|
|
|
2017-10-02 11:23:47 -04:00
|
|
|
handleChange = change => {
|
2017-09-28 11:02:59 -04:00
|
|
|
if (!this.state.editorState.document.equals(change.state.document)) {
|
|
|
|
this.handleDocumentChange(change);
|
|
|
|
}
|
2017-10-02 11:23:47 -04:00
|
|
|
this.setState({ editorState: change.state });
|
|
|
|
};
|
|
|
|
|
2016-08-11 11:27:09 -03:00
|
|
|
render() {
|
2017-01-10 22:23:22 -02:00
|
|
|
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
|
2016-11-01 16:55:21 -07:00
|
|
|
|
2017-07-13 21:33:50 -04:00
|
|
|
return (
|
2017-07-27 18:03:13 -04:00
|
|
|
<div className={styles.wrapper}>
|
2017-07-13 21:33:50 -04:00
|
|
|
<Sticky
|
|
|
|
className={styles.editorControlBar}
|
|
|
|
classNameActive={styles.editorControlBarSticky}
|
|
|
|
fillContainerWidth
|
|
|
|
>
|
|
|
|
<Toolbar
|
|
|
|
buttons={{
|
|
|
|
bold: this.getButtonProps('bold'),
|
|
|
|
italic: this.getButtonProps('italic'),
|
|
|
|
code: this.getButtonProps('code'),
|
2017-07-27 11:02:17 -04:00
|
|
|
link: this.getButtonProps('link', { handler: this.handleLink, isActive: this.hasLinks }),
|
|
|
|
h1: this.getButtonProps('heading-one', { isBlock: true }),
|
|
|
|
h2: this.getButtonProps('heading-two', { isBlock: true }),
|
|
|
|
list: this.getButtonProps('bulleted-list', { isBlock: true }),
|
|
|
|
listNumbered: this.getButtonProps('numbered-list', { isBlock: true }),
|
|
|
|
codeBlock: this.getButtonProps('code', { isBlock: true }),
|
2017-07-31 13:08:56 -04:00
|
|
|
quote: this.getButtonProps('quote', { isBlock: true }),
|
2017-07-13 21:33:50 -04:00
|
|
|
}}
|
|
|
|
onToggleMode={this.handleToggle}
|
2017-07-30 11:09:35 -04:00
|
|
|
plugins={this.state.shortcodePlugins}
|
2017-07-13 21:33:50 -04:00
|
|
|
onSubmit={this.handlePluginSubmit}
|
|
|
|
onAddAsset={onAddAsset}
|
|
|
|
onRemoveAsset={onRemoveAsset}
|
|
|
|
getAsset={getAsset}
|
|
|
|
/>
|
|
|
|
</Sticky>
|
2017-07-27 18:03:13 -04:00
|
|
|
<Slate
|
|
|
|
className={styles.editor}
|
2017-07-13 21:33:50 -04:00
|
|
|
state={this.state.editorState}
|
|
|
|
schema={this.state.schema}
|
2017-07-27 18:03:13 -04:00
|
|
|
plugins={plugins}
|
2017-10-02 11:23:47 -04:00
|
|
|
onChange={this.handleChange}
|
2017-07-27 18:03:13 -04:00
|
|
|
onKeyDown={onKeyDown}
|
2017-07-13 21:33:50 -04:00
|
|
|
onPaste={this.handlePaste}
|
|
|
|
ref={ref => this.ref = ref}
|
|
|
|
spellCheck
|
2017-05-22 14:34:49 -04:00
|
|
|
/>
|
2017-07-13 21:33:50 -04:00
|
|
|
</div>
|
|
|
|
);
|
2016-08-11 11:27:09 -03:00
|
|
|
}
|
|
|
|
}
|
2016-11-04 11:04:54 -07:00
|
|
|
|
|
|
|
Editor.propTypes = {
|
2017-01-10 22:23:22 -02:00
|
|
|
onAddAsset: PropTypes.func.isRequired,
|
|
|
|
onRemoveAsset: PropTypes.func.isRequired,
|
|
|
|
getAsset: PropTypes.func.isRequired,
|
2016-11-04 11:04:54 -07:00
|
|
|
onChange: PropTypes.func.isRequired,
|
|
|
|
onMode: PropTypes.func.isRequired,
|
2017-08-31 20:28:11 -04:00
|
|
|
value: PropTypes.string,
|
2016-11-04 11:04:54 -07:00
|
|
|
};
|