2016-11-01 16:55:21 -07:00
|
|
|
import React, { Component } from 'react';
|
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';
|
2016-11-01 23:31:20 -07:00
|
|
|
import { replaceWith } from 'prosemirror-transform';
|
|
|
|
import { schema, 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';
|
2016-11-01 16:55:21 -07:00
|
|
|
import { buildKeymap } from './keymap';
|
2016-11-01 23:31:20 -07:00
|
|
|
import createMarkdownParser from './parser';
|
2016-11-01 16:55:21 -07:00
|
|
|
import Toolbar from '../Toolbar';
|
2016-11-01 23:31:20 -07:00
|
|
|
import BlockMenu from '../BlockMenu';
|
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;
|
|
|
|
}
|
|
|
|
if (url.match(/^[^\/]+\.[^\/]+/)) {
|
|
|
|
return `https://${ url }`;
|
|
|
|
}
|
|
|
|
return `/${ url }`;
|
|
|
|
}
|
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
function buildInputRules(schema) {
|
|
|
|
let result = [], type;
|
|
|
|
if (type = schema.nodes.blockquote) result.push(blockQuoteRule(type));
|
|
|
|
if (type = schema.nodes.ordered_list) result.push(orderedListRule(type));
|
|
|
|
if (type = schema.nodes.bullet_list) result.push(bulletListRule(type));
|
|
|
|
if (type = schema.nodes.code_block) result.push(codeBlockRule(type));
|
|
|
|
if (type = schema.nodes.heading) result.push(headingRule(type, 6));
|
|
|
|
return result;
|
|
|
|
}
|
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');
|
|
|
|
state.write(toBlock.call(plugin, node.attrs));
|
|
|
|
};
|
|
|
|
});
|
|
|
|
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();
|
|
|
|
const s = schemaWithPlugins(schema, plugins);
|
|
|
|
this.state = {
|
|
|
|
plugins,
|
|
|
|
schema: s,
|
|
|
|
parser: createMarkdownParser(s),
|
|
|
|
serializer: createSerializer(s, plugins),
|
|
|
|
};
|
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() {
|
2016-11-01 23:31:20 -07:00
|
|
|
const { schema, parser } = this.state;
|
2016-11-01 16:55:21 -07:00
|
|
|
this.view = new EditorView(this.ref, {
|
|
|
|
state: EditorState.create({
|
2016-11-01 23:31:20 -07:00
|
|
|
doc: parser.parse(this.props.value || ''),
|
2016-11-01 16:55:21 -07:00
|
|
|
schema,
|
|
|
|
plugins: [
|
|
|
|
inputRules({
|
|
|
|
rules: allInputRules.concat(buildInputRules(schema)),
|
|
|
|
}),
|
2016-11-01 17:25:37 -07:00
|
|
|
keymap(buildKeymap(schema)),
|
2016-11-01 16:55:21 -07:00
|
|
|
keymap(baseKeymap),
|
|
|
|
history.history(),
|
2016-11-01 17:25:37 -07:00
|
|
|
keymap({
|
|
|
|
'Mod-z': history.undo,
|
|
|
|
'Mod-y': history.redo,
|
|
|
|
}),
|
2016-11-01 16:55:21 -07:00
|
|
|
],
|
2016-10-18 12:32:39 -02:00
|
|
|
}),
|
2016-11-01 16:55:21 -07:00
|
|
|
onAction: this.handleAction,
|
|
|
|
});
|
2016-08-11 11:27:09 -03:00
|
|
|
}
|
|
|
|
|
2016-11-01 16:55:21 -07:00
|
|
|
handleAction = (action) => {
|
2016-11-01 23:31:20 -07:00
|
|
|
const { schema, 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 };
|
|
|
|
this.setState({ showToolbar: false, showBlockMenu: true, selectionPosition });
|
|
|
|
} else {
|
|
|
|
this.setState({ showToolbar: false, showBlockMenu: false });
|
|
|
|
}
|
|
|
|
} 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 };
|
2016-11-01 23:31:20 -07:00
|
|
|
this.setState({ showToolbar: true, showBlockMenu: false, 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)) {
|
2016-11-01 17:58:19 -07:00
|
|
|
url = prompt('Link URL:');
|
|
|
|
}
|
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);
|
|
|
|
};
|
|
|
|
|
2016-11-01 23:31:20 -07:00
|
|
|
handleBlock = (plugin, data) => {
|
|
|
|
const { schema } = this.state;
|
|
|
|
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
|
|
|
|
this.view.props.onAction(this.view.state.tr.replaceSelection(nodeType.create(data.toJS())).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() {
|
2016-11-01 23:31:20 -07:00
|
|
|
const { onAddMedia, onRemoveMedia, getMedia } = this.props;
|
|
|
|
const { plugins, showToolbar, showBlockMenu, selectionPosition } = this.state;
|
2016-11-01 16:55:21 -07:00
|
|
|
|
|
|
|
return (<div className={styles.editor}>
|
|
|
|
<Toolbar
|
|
|
|
isOpen={showToolbar}
|
|
|
|
selectionPosition={selectionPosition}
|
|
|
|
onH1={this.handleHeader(1)}
|
|
|
|
onH2={this.handleHeader(2)}
|
|
|
|
onBold={this.handleBold}
|
|
|
|
onItalic={this.handleItalic}
|
|
|
|
onLink={this.handleLink}
|
|
|
|
onToggleMode={this.handleToggle}
|
|
|
|
/>
|
2016-11-01 23:31:20 -07:00
|
|
|
<BlockMenu
|
|
|
|
isOpen={showBlockMenu}
|
|
|
|
selectionPosition={selectionPosition}
|
|
|
|
plugins={plugins}
|
|
|
|
onBlock={this.handleBlock}
|
|
|
|
onAddMedia={onAddMedia}
|
|
|
|
onRemoveMedia={onRemoveMedia}
|
|
|
|
getMedia={getMedia}
|
|
|
|
/>
|
2016-11-01 16:55:21 -07:00
|
|
|
<div ref={this.handleRef} />
|
|
|
|
</div>);
|
2016-08-11 11:27:09 -03:00
|
|
|
}
|
|
|
|
}
|