2016-11-04 11:04:54 -07:00
|
|
|
import React, { Component, PropTypes } from 'react';
|
2017-03-16 20:45:46 -04:00
|
|
|
import { Map } from 'immutable';
|
2016-11-01 23:31:20 -07:00
|
|
|
import { Schema } from 'prosemirror-model';
|
2016-11-01 16:55:21 -07:00
|
|
|
import { EditorState } from 'prosemirror-state';
|
|
|
|
import { EditorView } from 'prosemirror-view';
|
|
|
|
import history from 'prosemirror-history';
|
|
|
|
import {
|
|
|
|
blockQuoteRule, orderedListRule, bulletListRule, codeBlockRule, headingRule,
|
|
|
|
inputRules, allInputRules,
|
|
|
|
} from 'prosemirror-inputrules';
|
|
|
|
import { keymap } from 'prosemirror-keymap';
|
2017-03-16 20:45:46 -04:00
|
|
|
import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
2016-11-01 16:55:21 -07:00
|
|
|
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
|
2016-11-01 23:31:20 -07:00
|
|
|
import registry from '../../../../lib/registry';
|
2017-01-10 22:23:22 -02:00
|
|
|
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
|
2016-11-01 16:55:21 -07:00
|
|
|
import { buildKeymap } from './keymap';
|
2016-11-01 23:31:20 -07:00
|
|
|
import createMarkdownParser from './parser';
|
2017-04-18 12:30:38 -04:00
|
|
|
import Toolbar from '../Toolbar/Toolbar';
|
2017-03-23 17:15:48 -04:00
|
|
|
import { Sticky } from '../../../UI/Sticky/Sticky';
|
2016-11-01 16:55:21 -07:00
|
|
|
import styles from './index.css';
|
|
|
|
|
2016-11-01 17:58:19 -07:00
|
|
|
function processUrl(url) {
|
|
|
|
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
|
|
|
|
return url;
|
|
|
|
}
|
2017-03-16 20:45:46 -04:00
|
|
|
if (url.match(/^[^/]+\.[^/]+/)) {
|
2016-11-01 17:58:19 -07:00
|
|
|
return `https://${ url }`;
|
|
|
|
}
|
|
|
|
return `/${ url }`;
|
|
|
|
}
|
|
|
|
|
2016-11-04 11:04:54 -07:00
|
|
|
const ruleset = {
|
|
|
|
blockquote: [blockQuoteRule],
|
|
|
|
ordered_list: [orderedListRule],
|
|
|
|
bullet_list: [bulletListRule],
|
|
|
|
code_block: [codeBlockRule],
|
|
|
|
heading: [headingRule, 6],
|
|
|
|
};
|
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
function buildInputRules(schema) {
|
2017-03-16 20:45:46 -04:00
|
|
|
return Map(ruleset)
|
|
|
|
.filter(rule => schema.nodes[rule])
|
|
|
|
.map(rule => rule[0].apply(rule[0].slice(1)))
|
|
|
|
.toArray();
|
2016-11-01 16:55:21 -07:00
|
|
|
}
|
2016-10-03 16:57:48 +02:00
|
|
|
|
2016-11-01 17:58:19 -07:00
|
|
|
function markActive(state, type) {
|
|
|
|
const { from, to, empty } = state.selection;
|
|
|
|
if (empty) {
|
|
|
|
return type.isInSet(state.storedMarks || state.doc.marksAt(from));
|
|
|
|
}
|
|
|
|
return state.doc.rangeHasMark(from, to, type);
|
|
|
|
}
|
|
|
|
|
2016-11-01 23:31:20 -07:00
|
|
|
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');
|
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;
|
|
|
|
}
|
|
|
|
|
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);
|
2016-11-01 23:31:20 -07:00
|
|
|
const plugins = registry.getEditorComponents();
|
2017-03-16 20:45:46 -04:00
|
|
|
const schema = schemaWithPlugins(markdownSchema, plugins);
|
2016-11-01 23:31:20 -07:00
|
|
|
this.state = {
|
|
|
|
plugins,
|
2017-03-16 20:45:46 -04:00
|
|
|
schema,
|
|
|
|
parser: createMarkdownParser(schema, plugins),
|
|
|
|
serializer: createSerializer(schema, plugins),
|
2016-11-01 23:31:20 -07:00
|
|
|
};
|
2016-11-01 16:55:21 -07:00
|
|
|
}
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
componentDidMount() {
|
|
|
|
this.view = new EditorView(this.ref, {
|
2017-07-23 19:38:05 +02:00
|
|
|
state: this.createEditorState(),
|
2016-11-01 16:55:21 -07:00
|
|
|
onAction: this.handleAction,
|
|
|
|
});
|
2016-08-11 11:27:09 -03:00
|
|
|
}
|
|
|
|
|
2017-07-23 19:38:05 +02:00
|
|
|
createEditorState() {
|
|
|
|
const { schema, parser } = this.state;
|
|
|
|
const doc = parser.parse(this.props.value || '');
|
|
|
|
|
|
|
|
return EditorState.create({
|
|
|
|
doc,
|
|
|
|
schema,
|
|
|
|
plugins: [
|
|
|
|
inputRules({
|
|
|
|
rules: allInputRules.concat(buildInputRules(schema)),
|
|
|
|
}),
|
|
|
|
keymap(buildKeymap(schema)),
|
|
|
|
keymap(baseKeymap),
|
|
|
|
history.history(),
|
|
|
|
keymap({
|
|
|
|
'Mod-z': history.undo,
|
|
|
|
'Mod-y': history.redo,
|
|
|
|
}),
|
|
|
|
],
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
|
|
const editorValue = this.state.serializer.serialize(this.view.state.doc);
|
|
|
|
|
|
|
|
if (editorValue !== this.props.value && editorValue !== prevProps.value) {
|
|
|
|
this.view.updateState(this.createEditorState());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleAction = (action) => {
|
2017-03-16 20:45:46 -04:00
|
|
|
const { serializer } = this.state;
|
2016-11-01 16:55:21 -07:00
|
|
|
const newState = this.view.state.applyAction(action);
|
2016-11-01 23:31:20 -07:00
|
|
|
const md = serializer.serialize(newState.doc);
|
|
|
|
this.props.onChange(md);
|
2016-11-01 16:55:21 -07:00
|
|
|
this.view.updateState(newState);
|
2016-11-01 23:31:20 -07:00
|
|
|
if (newState.selection !== this.state.selection) {
|
|
|
|
this.handleSelection(newState);
|
|
|
|
}
|
2016-11-01 16:55:21 -07:00
|
|
|
this.view.focus();
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleSelection = (state) => {
|
2016-11-01 23:31:20 -07:00
|
|
|
const { schema, selection } = state;
|
2016-11-01 16:55:21 -07:00
|
|
|
if (selection.from === selection.to) {
|
2016-11-01 23:31:20 -07:00
|
|
|
const { $from } = selection;
|
|
|
|
if ($from.parent && $from.parent.type === schema.nodes.paragraph && $from.parent.textContent === '') {
|
|
|
|
const pos = this.view.coordsAtPos(selection.from);
|
|
|
|
const editorPos = this.view.content.getBoundingClientRect();
|
|
|
|
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
|
2017-03-16 20:45:46 -04:00
|
|
|
this.setState({ selectionPosition });
|
2016-11-01 23:31:20 -07:00
|
|
|
}
|
|
|
|
} else {
|
2016-11-01 16:55:21 -07:00
|
|
|
const pos = this.view.coordsAtPos(selection.from);
|
|
|
|
const editorPos = this.view.content.getBoundingClientRect();
|
|
|
|
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
|
2017-03-16 20:45:46 -04:00
|
|
|
this.setState({ selectionPosition });
|
2016-08-11 11:27:09 -03:00
|
|
|
}
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleRef = (ref) => {
|
|
|
|
this.ref = ref;
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleHeader = level => (
|
|
|
|
() => {
|
2016-11-01 23:31:20 -07:00
|
|
|
const { schema } = this.state;
|
2016-11-01 17:51:49 -07:00
|
|
|
const state = this.view.state;
|
|
|
|
const { $from, to, node } = state.selection;
|
|
|
|
let nodeType = schema.nodes.heading;
|
|
|
|
let attrs = { level };
|
|
|
|
let inHeader = node && node.hasMarkup(nodeType, attrs);
|
|
|
|
if (!inHeader) {
|
|
|
|
inHeader = to <= $from.end() && $from.parent.hasMarkup(nodeType, attrs);
|
|
|
|
}
|
|
|
|
if (inHeader) {
|
|
|
|
nodeType = schema.nodes.paragraph;
|
|
|
|
attrs = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
const command = setBlockType(nodeType, { level });
|
|
|
|
command(state, this.handleAction);
|
2016-08-11 11:27:09 -03:00
|
|
|
}
|
2016-11-01 16:55:21 -07:00
|
|
|
);
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleBold = () => {
|
2016-11-01 23:31:20 -07:00
|
|
|
const command = toggleMark(this.state.schema.marks.strong);
|
2016-11-01 16:55:21 -07:00
|
|
|
command(this.view.state, this.handleAction);
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleItalic = () => {
|
2016-11-01 23:31:20 -07:00
|
|
|
const command = toggleMark(this.state.schema.marks.em);
|
2016-11-01 16:55:21 -07:00
|
|
|
command(this.view.state, this.handleAction);
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03: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)) {
|
2017-03-16 20:45:46 -04:00
|
|
|
url = prompt('Link URL:'); // eslint-disable-line no-alert
|
2016-11-01 17:58:19 -07:00
|
|
|
}
|
2016-11-01 23:31:20 -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;
|
|
|
|
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
|
2016-12-27 23:13:31 -08:00
|
|
|
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
|
2016-11-01 23:31:20 -07:00
|
|
|
};
|
|
|
|
|
2016-12-27 23:18:37 -08: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) => {
|
2017-01-10 22:23:22 -02:00
|
|
|
createAssetProxy(file.name, file)
|
|
|
|
.then((assetProxy) => {
|
|
|
|
this.props.onAddAsset(assetProxy);
|
|
|
|
if (file.type.split('/')[0] === 'image') {
|
|
|
|
nodes.push(
|
|
|
|
schema.nodes.image.create({ src: assetProxy.public_path, alt: file.name })
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
nodes.push(
|
|
|
|
schema.marks.link.create({ href: assetProxy.public_path, title: file.name })
|
|
|
|
);
|
|
|
|
}
|
|
|
|
});
|
2016-12-27 23:18:37 -08: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());
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleToggle = () => {
|
|
|
|
this.props.onMode('raw');
|
2016-10-03 14:25:27 +02:00
|
|
|
};
|
2016-08-11 11:27:09 -03:00
|
|
|
|
|
|
|
render() {
|
2017-01-10 22:23:22 -02:00
|
|
|
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
|
2017-03-16 20:45:46 -04:00
|
|
|
const { plugins, selectionPosition, dragging } = this.state;
|
2016-12-27 23:18:37 -08:00
|
|
|
const classNames = [styles.editor];
|
|
|
|
if (dragging) {
|
|
|
|
classNames.push(styles.dragging);
|
|
|
|
}
|
2016-11-01 16:55:21 -07:00
|
|
|
|
2016-12-27 23:18:37 -08:00
|
|
|
return (<div
|
|
|
|
className={classNames.join(' ')}
|
|
|
|
onDragEnter={this.handleDragEnter}
|
|
|
|
onDragLeave={this.handleDragLeave}
|
|
|
|
onDragOver={this.handleDragOver}
|
|
|
|
onDrop={this.handleDrop}
|
|
|
|
>
|
2017-05-04 15:19:43 -04:00
|
|
|
<Sticky
|
|
|
|
className={styles.editorControlBar}
|
|
|
|
classNameActive={styles.editorControlBarSticky}
|
|
|
|
fillContainerWidth
|
|
|
|
>
|
2017-03-16 20:45:46 -04:00
|
|
|
<Toolbar
|
|
|
|
selectionPosition={selectionPosition}
|
|
|
|
onH1={this.handleHeader(1)}
|
|
|
|
onH2={this.handleHeader(2)}
|
|
|
|
onBold={this.handleBold}
|
|
|
|
onItalic={this.handleItalic}
|
|
|
|
onLink={this.handleLink}
|
|
|
|
onToggleMode={this.handleToggle}
|
|
|
|
plugins={plugins}
|
2017-04-18 11:09:14 -04:00
|
|
|
onSubmit={this.handlePluginSubmit}
|
2017-03-16 20:45:46 -04:00
|
|
|
onAddAsset={onAddAsset}
|
|
|
|
onRemoveAsset={onRemoveAsset}
|
|
|
|
getAsset={getAsset}
|
|
|
|
/>
|
2017-03-23 17:15:48 -04:00
|
|
|
</Sticky>
|
2016-11-01 16:55:21 -07:00
|
|
|
<div ref={this.handleRef} />
|
2016-12-27 23:18:37 -08:00
|
|
|
<div className={styles.shim} />
|
2016-11-01 16:55:21 -07: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,
|
|
|
|
value: PropTypes.node,
|
|
|
|
};
|