Editor plugin architecture skeleton
This commit is contained in:
parent
ae52a14cb1
commit
e52ccc0dbc
@ -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>
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -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);
|
|
||||||
}
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user