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