325 lines
9.4 KiB
JavaScript
Raw Normal View History

import React, { Component, PropTypes } from 'react';
import { Map } from 'immutable';
2016-11-01 23:31:20 -07:00
import { Schema } from 'prosemirror-model';
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';
import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown';
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';
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';
import { Sticky } from '../../../UI/Sticky/Sticky';
import styles from './index.css';
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 ruleset = {
blockquote: [blockQuoteRule],
ordered_list: [orderedListRule],
bullet_list: [bulletListRule],
code_block: [codeBlockRule],
heading: [headingRule, 6],
};
function buildInputRules(schema) {
return Map(ruleset)
.filter(rule => schema.nodes[rule])
.map(rule => rule[0].apply(rule[0].slice(1)))
.toArray();
}
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;
}
export default class Editor extends Component {
constructor(props) {
super(props);
2016-11-01 23:31:20 -07:00
const plugins = registry.getEditorComponents();
const schema = schemaWithPlugins(markdownSchema, plugins);
2016-11-01 23:31:20 -07:00
this.state = {
plugins,
schema,
parser: createMarkdownParser(schema, plugins),
serializer: createSerializer(schema, plugins),
2016-11-01 23:31:20 -07:00
};
}
componentDidMount() {
this.view = new EditorView(this.ref, {
2017-07-23 19:38:05 +02:00
state: this.createEditorState(),
onAction: this.handleAction,
});
}
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());
}
}
handleAction = (action) => {
const { serializer } = this.state;
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);
this.view.updateState(newState);
2016-11-01 23:31:20 -07:00
if (newState.selection !== this.state.selection) {
this.handleSelection(newState);
}
this.view.focus();
2016-10-03 14:25:27 +02:00
};
handleSelection = (state) => {
2016-11-01 23:31:20 -07:00
const { schema, selection } = state;
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 };
this.setState({ selectionPosition });
2016-11-01 23:31:20 -07:00
}
} else {
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 };
this.setState({ selectionPosition });
}
2016-10-03 14:25:27 +02:00
};
handleRef = (ref) => {
this.ref = ref;
2016-10-03 14:25:27 +02: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);
}
);
handleBold = () => {
2016-11-01 23:31:20 -07:00
const command = toggleMark(this.state.schema.marks.strong);
command(this.view.state, this.handleAction);
2016-10-03 14:25:27 +02:00
};
handleItalic = () => {
2016-11-01 23:31:20 -07:00
const command = toggleMark(this.state.schema.marks.em);
command(this.view.state, this.handleAction);
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
}
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
};
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 })
);
}
});
});
} 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 = () => {
this.props.onMode('raw');
2016-10-03 14:25:27 +02:00
};
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}
>
2017-05-04 15:19:43 -04:00
<Sticky
className={styles.editorControlBar}
classNameActive={styles.editorControlBarSticky}
fillContainerWidth
>
<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}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
</Sticky>
<div ref={this.handleRef} />
<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,
};