Editor plugin architecture skeleton

This commit is contained in:
Cássio Zen 2016-08-18 15:13:22 -03:00
parent ae52a14cb1
commit e52ccc0dbc
6 changed files with 148 additions and 105 deletions

View File

@ -68,5 +68,22 @@
<body> <body>
<script src='/cms.js'></script> <script src='/cms.js'></script>
<script>
CMS.registerEditorComponent({
id: "youtube",
label: "Youtube",
icon: 'video',
fields: ['video-id'],
pattern: /^{{<\s?youtube (\S+)\s?>}}/,
fromBlock: function(match) {
return {
"video-id": match[1]
};
},
toBlock: function(obj) {
return '{{< youtube ' + obj['video-id'] + ' >}}';
}
})
</script>
</body> </body>
</html> </html>

View File

@ -17,9 +17,11 @@ export default class BlockTypesMenu extends Component {
this.toggleMenu = this.toggleMenu.bind(this); this.toggleMenu = this.toggleMenu.bind(this);
this.handleOpen = this.handleOpen.bind(this); this.handleOpen = this.handleOpen.bind(this);
this.handleBlockTypeClick = this.handleBlockTypeClick.bind(this); this.handleBlockTypeClick = this.handleBlockTypeClick.bind(this);
this.handlePluginClick = this.handlePluginClick.bind(this);
this.handleFileUploadClick = this.handleFileUploadClick.bind(this); this.handleFileUploadClick = this.handleFileUploadClick.bind(this);
this.handleFileUploadChange = this.handleFileUploadChange.bind(this); this.handleFileUploadChange = this.handleFileUploadChange.bind(this);
this.renderBlockTypeButton = this.renderBlockTypeButton.bind(this); this.renderBlockTypeButton = this.renderBlockTypeButton.bind(this);
this.renderPluginButton = this.renderPluginButton.bind(this);
} }
/** /**
@ -58,6 +60,14 @@ export default class BlockTypesMenu extends Component {
this.props.onClickBlock(type); this.props.onClickBlock(type);
} }
handlePluginClick(e, plugin) {
const data = {};
plugin.fields.forEach(field => {
data[field] = window.prompt(field);
});
this.props.onClickPlugin(plugin.id, data);
}
handleFileUploadClick() { handleFileUploadClick() {
this._fileInput.click(); this._fileInput.click();
} }
@ -84,7 +94,6 @@ export default class BlockTypesMenu extends Component {
} }
renderBlockTypeButton(type, icon) { renderBlockTypeButton(type, icon) {
const onClick = e => this.handleBlockTypeClick(e, type); const onClick = e => this.handleBlockTypeClick(e, type);
return ( return (
@ -92,13 +101,20 @@ export default class BlockTypesMenu extends Component {
); );
} }
renderPluginButton(plugin) {
const onClick = e => this.handlePluginClick(e, plugin);
return (
<Icon key={plugin.id} type={plugin.icon} onClick={onClick} className={styles.icon} />
);
}
renderMenu() { renderMenu() {
const { plugins } = this.props; const { plugins } = this.props;
if (this.state.expanded) { if (this.state.expanded) {
return ( return (
<div className={styles.menu}> <div className={styles.menu}>
{this.renderBlockTypeButton('hr', 'dot-3')} {this.renderBlockTypeButton('hr', 'dot-3')}
{plugins.map(plugin => this.renderBlockTypeButton(plugin.id, plugin.icon))} {plugins.map(plugin => this.renderPluginButton(plugin))}
<Icon type="picture" onClick={this.handleFileUploadClick} className={styles.icon} /> <Icon type="picture" onClick={this.handleFileUploadClick} className={styles.icon} />
<input <input
type="file" type="file"
@ -142,5 +158,6 @@ BlockTypesMenu.propTypes = {
left: PropTypes.number.isRequired left: PropTypes.number.isRequired
}), }),
onClickBlock: PropTypes.func.isRequired, onClickBlock: PropTypes.func.isRequired,
onClickPlugin: PropTypes.func.isRequired,
onClickImage: PropTypes.func.isRequired onClickImage: PropTypes.func.isRequired
}; };

View File

@ -1,3 +1,26 @@
.active { .active {
box-shadow: 0 0 0 2px blue; box-shadow: 0 0 0 2px blue;
} }
:global .plugin {
background-color: #ddd;
color: #555;
text-align: center;
width: 200px;
padding: 8px;
border-radius: 2px;
}
:global .plugin_icon {
font-size: 50px;
margin: 12px 0;
}
:global .plugin_fields {
font-size: 11px;
outline:none;
}
:global .active {
box-shadow: 0 0 0 2px blue;
}

View File

@ -17,8 +17,11 @@ class VisualEditor extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.getMedia = this.getMedia.bind(this);
const MarkdownSyntax = getSyntaxes(this.getMedia).markdown;
this.markdown = new MarkupIt(MarkdownSyntax);
SCHEMA.nodes = _.merge(SCHEMA.nodes, getNodes()); SCHEMA.nodes = _.merge(SCHEMA.nodes, getNodes());
this.markdown = new MarkupIt(getSyntaxes().markdown);
this.blockEdit = false; this.blockEdit = false;
this.menuPositions = { this.menuPositions = {
@ -39,7 +42,7 @@ class VisualEditor extends React.Component {
let rawJson; let rawJson;
if (props.value !== undefined) { if (props.value !== undefined) {
const content = this.markdown.toContent(props.value); const content = this.markdown.toContent(props.value);
rawJson = SlateUtils.encode(content); rawJson = SlateUtils.encode(content, null, getPlugins().map(plugin => plugin.id));
} else { } else {
rawJson = emptyParagraphBlock; rawJson = emptyParagraphBlock;
} }
@ -53,6 +56,7 @@ class VisualEditor extends React.Component {
this.handleBlockStyleClick = this.handleBlockStyleClick.bind(this); this.handleBlockStyleClick = this.handleBlockStyleClick.bind(this);
this.handleInlineClick = this.handleInlineClick.bind(this); this.handleInlineClick = this.handleInlineClick.bind(this);
this.handleBlockTypeClick = this.handleBlockTypeClick.bind(this); this.handleBlockTypeClick = this.handleBlockTypeClick.bind(this);
this.handlePluginClick = this.handlePluginClick.bind(this);
this.handleImageClick = this.handleImageClick.bind(this); this.handleImageClick = this.handleImageClick.bind(this);
this.focusAndAddParagraph = this.focusAndAddParagraph.bind(this); this.focusAndAddParagraph = this.focusAndAddParagraph.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
@ -65,19 +69,6 @@ class VisualEditor extends React.Component {
return this.props.getMedia(src); return this.props.getMedia(src);
} }
/**
* Custom local renderer for image proxy.
*/
customImageNodeRenderer(editorProps) {
const { node, state } = editorProps;
const isFocused = state.selection.hasEdgeIn(node);
const className = isFocused ? styles.active : null;
const src = node.data.get('src');
return (
<img {...editorProps.attributes} src={this.props.getMedia(src)} className={className} />
);
}
/** /**
* Slate keeps track of selections, scroll position etc. * Slate keeps track of selections, scroll position etc.
* So, onChange gets dispatched on every interaction (click, arrows, everything...) * So, onChange gets dispatched on every interaction (click, arrows, everything...)
@ -235,6 +226,24 @@ class VisualEditor extends React.Component {
this.setState({ state }, this.focusAndAddParagraph); this.setState({ state }, this.focusAndAddParagraph);
} }
handlePluginClick(type, data) {
let { state } = this.state;
state = state
.transform()
.insertInline({
type: type,
data: data,
isVoid: true
})
.collapseToEnd()
.insertBlock(DEFAULT_NODE)
.focus()
.apply();
this.setState({ state });
}
handleImageClick(mediaProxy) { handleImageClick(mediaProxy) {
let { state } = this.state; let { state } = this.state;
this.props.onAddMedia(mediaProxy); this.props.onAddMedia(mediaProxy);
@ -294,6 +303,7 @@ class VisualEditor extends React.Component {
plugins={getPlugins()} plugins={getPlugins()}
position={this.menuPositions.blockTypesMenu} position={this.menuPositions.blockTypesMenu}
onClickBlock={this.handleBlockTypeClick} onClickBlock={this.handleBlockTypeClick}
onClickPlugin={this.handlePluginClick}
onClickImage={this.handleImageClick} onClickImage={this.handleImageClick}
/> />
); );

View File

@ -1,77 +0,0 @@
import Immutable from 'immutable';
import MarkupIt from 'markup-it';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import reInline from 'markup-it/syntaxes/markdown/re/inline';
/**
* Test if a link input is an image
* @param {String} raw
* @return {Boolean}
*/
function isImage(raw) {
return raw.charAt(0) === '!';
}
export default function getSyntax(getMedia) {
const customImageRule = MarkupIt.Rule('mediaproxy')
.regExp(reInline.link, function(state, match) {
if (!isImage(match[0])) {
return;
}
var imgData = Immutable.Map({
alt: match[1],
src: getMedia(match[2]),
title: match[3]
}).filter(Boolean);
return {
data: imgData
};
})
.regExp(reInline.reflink, function(state, match) {
if (!isImage(match[0])) {
return;
}
var refId = (match[2] || match[1]);
return {
data: { ref: refId }
};
})
.regExp(reInline.nolink, function(state, match) {
if (!isImage(match[0])) {
return;
}
var refId = (match[2] || match[1]);
return {
data: { ref: refId }
};
})
.regExp(reInline.reffn, function(state, match) {
if (!isImage(match[0])) {
return;
}
var refId = match[1];
return {
data: { ref: refId }
};
})
.toText(function(state, token) {
var data = token.getData();
var alt = data.get('alt', '');
var src = getMedia(data.get('src', ''));
var title = data.get('title', '');
if (title) {
return '![' + alt + '](' + src + ' "' + title + '")';
} else {
return '![' + alt + '](' + src + ')';
}
});
return markdownSyntax.addInlineRules(customImageRule);
}

View File

@ -1,8 +1,10 @@
/* eslint react/prop-types: 0, react/no-multi-comp: 0 */
import React from 'react'; import React from 'react';
import { List } from 'immutable'; import { List, Map } from 'immutable';
import MarkupIt from 'markup-it'; import MarkupIt from 'markup-it';
import markdownSyntax from 'markup-it/syntaxes/markdown'; import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html'; import htmlSyntax from 'markup-it/syntaxes/html';
import reInline from 'markup-it/syntaxes/markdown/re/inline';
import { Icon } from '../UI'; import { Icon } from '../UI';
/* /*
@ -15,8 +17,8 @@ let processedPlugins = List([]);
const nodes = {}; const nodes = {};
const augmentedMarkdownSyntax = markdownSyntax; let augmentedMarkdownSyntax = markdownSyntax;
const augmentedHTMLSyntax = htmlSyntax; let augmentedHTMLSyntax = htmlSyntax;
function processEditorPlugins(plugins) { function processEditorPlugins(plugins) {
// Since the plugin list is immutable, a simple comparisson is enough // Since the plugin list is immutable, a simple comparisson is enough
@ -25,36 +27,86 @@ function processEditorPlugins(plugins) {
plugins.forEach(plugin => { plugins.forEach(plugin => {
const markdownRule = MarkupIt.Rule(plugin.id) const markdownRule = MarkupIt.Rule(plugin.id)
.regExp(plugin.pattern, function(state, match) { return plugin.fromBlock(match); }) .regExp(plugin.pattern, function(state, match) {
.toText(function(state, token) { return plugin.toBlock(token.getData()); }); return { data: plugin.fromBlock(match) };
})
.toText(function(state, token) { return plugin.toBlock(token.getData().toObject()) + '\n\n'; });
const htmlRule = MarkupIt.Rule(plugin.id) const htmlRule = MarkupIt.Rule(plugin.id)
.regExp(plugin.pattern, function(state, match) { return plugin.fromBlock(match); }) .regExp(plugin.pattern, function(state, match) { return plugin.fromBlock(match); })
.toText(function(state, token) { return plugin.toPreview(token.getData()); }); .toText(function(state, token) { return plugin.toPreview(token.getData()); });
const nodeRenderer = (props) => { const nodeRenderer = (props) => {
/* eslint react/prop-types: 0 */
const { node, state } = props; const { node, state } = props;
const isFocused = state.selection.hasEdgeIn(node); const isFocused = state.selection.hasEdgeIn(node);
const className = isFocused ? 'plugin active' : 'plugin'; const className = isFocused ? 'plugin active' : 'plugin';
return ( return (
<div {...props.attributes} className={className}> <div {...props.attributes} className={className}>
<Icon type={plugin.icon}/> <div className="plugin_icon" contentEditable={false}><Icon type={plugin.icon}/></div>
<div className="plugin_fields" contentEditable={false}>
{ plugin.fields.map(field => `${field}: “${node.data.get(field)}`) }
</div>
</div> </div>
); );
}; };
augmentedMarkdownSyntax.addInlineRules(markdownRule); augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(markdownRule);
augmentedHTMLSyntax.addInlineRules(htmlRule); augmentedHTMLSyntax = augmentedHTMLSyntax.addInlineRules(htmlRule);
nodes[plugin.id] = nodeRenderer; nodes[plugin.id] = nodeRenderer;
}); });
processedPlugins = plugins; processedPlugins = plugins;
} }
function processMediaProxyPlugins(getMedia) {
const customImageRule = MarkupIt.Rule('mediaproxy')
.regExp(reInline.link, function(state, match) {
if (match[0].charAt(0) !== '!') {
// Return if this is not an image
return;
}
var imgData = Map({
alt: match[1],
src: getMedia(match[2]),
title: match[3]
}).filter(Boolean);
return {
data: imgData
};
})
.toText(function(state, token) {
var data = token.getData();
var alt = data.get('alt', '');
var src = getMedia(data.get('src', ''));
var title = data.get('title', '');
if (title) {
return '![' + alt + '](' + src + ' "' + title + '")';
} else {
return '![' + alt + '](' + src + ')';
}
});
nodes['mediaproxy'] = (props) => {
/* eslint react/prop-types: 0 */
const { node, state } = props;
const isFocused = state.selection.hasEdgeIn(node);
const className = isFocused ? 'active' : null;
const src = node.data.get('src');
return (
<img {...props.attributes} src={getMedia(src)} className={className} />
);
};
augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(customImageRule);
}
function getPlugins() { function getPlugins() {
return processedPlugins.map(plugin => ( return processedPlugins.map(plugin => (
{ id: plugin.id, icon: plugin.icon } { id: plugin.id, icon: plugin.icon, fields: plugin.fields }
)).toArray(); )).toArray();
} }
@ -62,7 +114,8 @@ function getNodes() {
return nodes; return nodes;
} }
function getSyntaxes() { function getSyntaxes(getMedia) {
processMediaProxyPlugins(getMedia);
return { markdown: augmentedMarkdownSyntax, html:augmentedHTMLSyntax }; return { markdown: augmentedMarkdownSyntax, html:augmentedHTMLSyntax };
} }