Custom plugin support in rte
This commit is contained in:
parent
f02bd9a789
commit
038597573c
@ -111,10 +111,12 @@
|
||||
"prosemirror-inputrules": "^0.12.0",
|
||||
"prosemirror-keymap": "^0.12.0",
|
||||
"prosemirror-markdown": "^0.12.0",
|
||||
"prosemirror-model": "^0.12.0",
|
||||
"prosemirror-schema-basic": "^0.12.0",
|
||||
"prosemirror-schema-list": "^0.12.0",
|
||||
"prosemirror-schema-table": "^0.12.0",
|
||||
"prosemirror-state": "^0.12.0",
|
||||
"prosemirror-transform": "^0.12.1",
|
||||
"prosemirror-view": "^0.12.0",
|
||||
"react": "^15.1.0",
|
||||
"react-addons-css-transition-group": "^15.3.1",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { fromJS } from 'immutable';
|
||||
import { Button } from 'react-toolbox/lib/button';
|
||||
import { resolveWidget } from '../../../Widgets';
|
||||
import { resolveWidget } from '../../Widgets';
|
||||
import styles from './BlockMenu.css';
|
||||
|
||||
export default class BlockMenu extends Component {
|
||||
@ -49,7 +49,7 @@ export default class BlockMenu extends Component {
|
||||
}
|
||||
|
||||
buttonFor(plugin) {
|
||||
return (<li key={plugin.get('id')}>
|
||||
return (<li key={`plugin-${plugin.get('id')}`}>
|
||||
<button onClick={this.handlePlugin(plugin)}>{plugin.get('label')}</button>
|
||||
</li>);
|
||||
}
|
||||
@ -57,8 +57,7 @@ export default class BlockMenu extends Component {
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const { openPlugin, pluginData } = this.state;
|
||||
const toBlock = openPlugin.get('toBlock');
|
||||
this.props.onBlock(toBlock.call(toBlock, pluginData.toJS()));
|
||||
this.props.onBlock(openPlugin, pluginData);
|
||||
this.setState({ openPlugin: null, isExpanded: false });
|
||||
};
|
||||
|
||||
@ -74,7 +73,7 @@ export default class BlockMenu extends Component {
|
||||
const value = pluginData.get(field.get('name'));
|
||||
|
||||
return (
|
||||
<div className={styles.control} key={field.get('name')}>
|
||||
<div className={styles.control} key={`field-${field.get('name')}`}>
|
||||
<label className={styles.label}>{field.get('label')}</label>
|
||||
{
|
||||
React.createElement(widget.control, {
|
@ -7,7 +7,7 @@ import CaretPosition from 'textarea-caret-position';
|
||||
import registry from '../../../../lib/registry';
|
||||
import MediaProxy from '../../../../valueObjects/MediaProxy';
|
||||
import Toolbar from '../Toolbar';
|
||||
import BlockMenu from './BlockMenu';
|
||||
import BlockMenu from '../BlockMenu';
|
||||
import styles from './index.css';
|
||||
|
||||
const HAS_LINE_BREAK = /\n/m;
|
||||
@ -92,9 +92,7 @@ export default class RawEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const plugins = registry.getEditorComponents();
|
||||
this.state = {
|
||||
plugins: plugins,
|
||||
};
|
||||
this.state = { plugins };
|
||||
this.shortcuts = {
|
||||
meta: {
|
||||
b: this.handleBold,
|
||||
@ -271,8 +269,9 @@ export default class RawEditor extends React.Component {
|
||||
this.updateHeight();
|
||||
};
|
||||
|
||||
handleBlock = (chars) => {
|
||||
this.replaceSelection(chars);
|
||||
handleBlock = (plugin, data) => {
|
||||
const toBlock = plugin.get('toBlock');
|
||||
this.replaceSelection(toBlock.call(toBlock, data.toJS()));
|
||||
this.setState({ showBlockMenu: false });
|
||||
};
|
||||
|
||||
|
@ -7,10 +7,6 @@
|
||||
border-bottom: none;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.45;
|
||||
&:before {
|
||||
content: "# ";
|
||||
color: #a5afad;
|
||||
}
|
||||
}
|
||||
& h1 {
|
||||
font-size: 2.5rem;
|
||||
@ -21,15 +17,15 @@
|
||||
& h3 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
& h2:before {
|
||||
content: "## ";
|
||||
}
|
||||
& h3:before {
|
||||
content: "### ";
|
||||
}
|
||||
& p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
& div[data-plugin] {
|
||||
background: #fff;
|
||||
border: 1px solid #aaa;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import history from 'prosemirror-history';
|
||||
@ -7,10 +8,14 @@ import {
|
||||
inputRules, allInputRules,
|
||||
} from 'prosemirror-inputrules';
|
||||
import { keymap } from 'prosemirror-keymap';
|
||||
import { schema, defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
import { replaceWith } from 'prosemirror-transform';
|
||||
import { schema, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
|
||||
import registry from '../../../../lib/registry';
|
||||
import { buildKeymap } from './keymap';
|
||||
import createMarkdownParser from './parser';
|
||||
import Toolbar from '../Toolbar';
|
||||
import BlockMenu from '../BlockMenu';
|
||||
import styles from './index.css';
|
||||
|
||||
function processUrl(url) {
|
||||
@ -41,16 +46,63 @@ function markActive(state, type) {
|
||||
return state.doc.rangeHasMark(from, to, type);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default class Editor extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
const plugins = registry.getEditorComponents();
|
||||
const s = schemaWithPlugins(schema, plugins);
|
||||
this.state = {
|
||||
plugins,
|
||||
schema: s,
|
||||
parser: createMarkdownParser(s),
|
||||
serializer: createSerializer(s, plugins),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { schema, parser } = this.state;
|
||||
this.view = new EditorView(this.ref, {
|
||||
state: EditorState.create({
|
||||
doc: defaultMarkdownParser.parse(this.props.value || ''),
|
||||
doc: parser.parse(this.props.value || ''),
|
||||
schema,
|
||||
plugins: [
|
||||
inputRules({
|
||||
@ -70,27 +122,34 @@ export default class Editor extends Component {
|
||||
}
|
||||
|
||||
handleAction = (action) => {
|
||||
const { schema, serializer } = this.state;
|
||||
const newState = this.view.state.applyAction(action);
|
||||
switch (action.type) {
|
||||
case 'selection':
|
||||
this.handleSelection(newState);
|
||||
default:
|
||||
const md = defaultMarkdownSerializer.serialize(newState.doc);
|
||||
const md = serializer.serialize(newState.doc);
|
||||
this.props.onChange(md);
|
||||
}
|
||||
this.view.updateState(newState);
|
||||
if (newState.selection !== this.state.selection) {
|
||||
this.handleSelection(newState);
|
||||
}
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
handleSelection = (state) => {
|
||||
const { selection } = state;
|
||||
const { schema, selection } = state;
|
||||
if (selection.from === selection.to) {
|
||||
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, selectionPosition });
|
||||
this.setState({ showToolbar: false, showBlockMenu: true, selectionPosition });
|
||||
} else {
|
||||
this.setState({ showToolbar: true });
|
||||
this.setState({ showToolbar: false, showBlockMenu: false });
|
||||
}
|
||||
} 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({ showToolbar: true, showBlockMenu: false, selectionPosition });
|
||||
}
|
||||
};
|
||||
|
||||
@ -100,6 +159,7 @@ export default class Editor extends Component {
|
||||
|
||||
handleHeader = level => (
|
||||
() => {
|
||||
const { schema } = this.state;
|
||||
const state = this.view.state;
|
||||
const { $from, to, node } = state.selection;
|
||||
let nodeType = schema.nodes.heading;
|
||||
@ -119,30 +179,37 @@ export default class Editor extends Component {
|
||||
);
|
||||
|
||||
handleBold = () => {
|
||||
const command = toggleMark(schema.marks.strong);
|
||||
const command = toggleMark(this.state.schema.marks.strong);
|
||||
command(this.view.state, this.handleAction);
|
||||
};
|
||||
|
||||
handleItalic = () => {
|
||||
const command = toggleMark(schema.marks.em);
|
||||
const command = toggleMark(this.state.schema.marks.em);
|
||||
command(this.view.state, this.handleAction);
|
||||
};
|
||||
|
||||
handleLink = () => {
|
||||
let url = null;
|
||||
if (!markActive(this.view.state, schema.marks.link)) {
|
||||
if (!markActive(this.view.state, this.state.schema.marks.link)) {
|
||||
url = prompt('Link URL:');
|
||||
}
|
||||
const command = toggleMark(schema.marks.link, { href: url ? processUrl(url) : null });
|
||||
const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null });
|
||||
command(this.view.state, this.handleAction);
|
||||
};
|
||||
|
||||
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());
|
||||
};
|
||||
|
||||
handleToggle = () => {
|
||||
this.props.onMode('raw');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showToolbar, selectionPosition } = this.state;
|
||||
const { onAddMedia, onRemoveMedia, getMedia } = this.props;
|
||||
const { plugins, showToolbar, showBlockMenu, selectionPosition } = this.state;
|
||||
|
||||
return (<div className={styles.editor}>
|
||||
<Toolbar
|
||||
@ -155,6 +222,15 @@ export default class Editor extends Component {
|
||||
onLink={this.handleLink}
|
||||
onToggleMode={this.handleToggle}
|
||||
/>
|
||||
<BlockMenu
|
||||
isOpen={showBlockMenu}
|
||||
selectionPosition={selectionPosition}
|
||||
plugins={plugins}
|
||||
onBlock={this.handleBlock}
|
||||
onAddMedia={onAddMedia}
|
||||
onRemoveMedia={onRemoveMedia}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
<div ref={this.handleRef} />
|
||||
</div>);
|
||||
}
|
||||
|
@ -0,0 +1,30 @@
|
||||
import { MarkdownParser } from 'prosemirror-markdown';
|
||||
import markdownit from 'markdown-it';
|
||||
|
||||
export default function createMarkdownParser(schema) {
|
||||
return new MarkdownParser(schema, markdownit("commonmark", {html: false}), {
|
||||
blockquote: {block: "blockquote"},
|
||||
paragraph: {block: "paragraph"},
|
||||
list_item: {block: "list_item"},
|
||||
bullet_list: {block: "bullet_list"},
|
||||
ordered_list: {block: "ordered_list", attrs: tok => ({order: +tok.attrGet("order") || 1})},
|
||||
heading: {block: "heading", attrs: tok => ({level: +tok.tag.slice(1)})},
|
||||
code_block: {block: "code_block"},
|
||||
fence: {block: "code_block"},
|
||||
hr: {node: "horizontal_rule"},
|
||||
image: {node: "image", attrs: tok => ({
|
||||
src: tok.attrGet("src"),
|
||||
title: tok.attrGet("title") || null,
|
||||
alt: tok.children[0] && tok.children[0].content || null
|
||||
})},
|
||||
hardbreak: {node: "hard_break"},
|
||||
|
||||
em: {mark: "em"},
|
||||
strong: {mark: "strong"},
|
||||
link: {mark: "link", attrs: tok => ({
|
||||
href: tok.attrGet("href"),
|
||||
title: tok.attrGet("title") || null
|
||||
})},
|
||||
code_inline: {mark: "code"}
|
||||
});
|
||||
}
|
@ -6611,7 +6611,7 @@ prosemirror-markdown:
|
||||
markdown-it "^6.0.4"
|
||||
prosemirror-model "~0.12.0"
|
||||
|
||||
prosemirror-model@^0.12.0, prosemirror-model@~0.12.0:
|
||||
prosemirror-model, prosemirror-model@^0.12.0, prosemirror-model@~0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-0.12.0.tgz#5430c4056f2d3fe87d36de3f73aa9d9d07b0e8a7"
|
||||
|
||||
@ -6643,6 +6643,12 @@ prosemirror-state@^0.12.0:
|
||||
prosemirror-model "^0.12.0"
|
||||
prosemirror-transform "^0.12.0"
|
||||
|
||||
prosemirror-transform:
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-0.12.1.tgz#69bca7e55976815e59281fbd8af4518f5ab90844"
|
||||
dependencies:
|
||||
prosemirror-model "^0.12.0"
|
||||
|
||||
prosemirror-transform@^0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-0.12.0.tgz#298660a60e2069112469e0172e78be395762d263"
|
||||
|
Loading…
x
Reference in New Issue
Block a user