Custom plugin support in rte

This commit is contained in:
Mathias Biilmann Christensen 2016-11-01 23:31:20 -07:00
parent f02bd9a789
commit 038597573c
9 changed files with 149 additions and 41 deletions

View File

@ -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",

View File

@ -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, {

View File

@ -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 });
}; };

View File

@ -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 {

View File

@ -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.props.onChange(md);
this.handleSelection(newState);
default:
const md = defaultMarkdownSerializer.serialize(newState.doc);
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 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 {
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: true, showBlockMenu: false, selectionPosition });
} else {
this.setState({ showToolbar: true });
} }
}; };
@ -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>);
} }

View File

@ -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"}
});
}

View File

@ -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"