|
import React, { PropTypes } from 'react'; |
|
import { Editor, Html, Raw, Utils } from 'slate' |
|
import keycode from 'keycode' |
|
|
|
// Includes code from Slate examples. |
|
// https://github.com/ianstormtaylor/slate/blob/master/examples/paste-html/index.js |
|
|
|
/** |
|
* Define a set of node renderers. |
|
* |
|
* @type {Object} |
|
*/ |
|
const NODES = { |
|
'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>, |
|
'code': props => <pre><code {...props.attributes}>{props.children}</code></pre>, |
|
'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>, |
|
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>, |
|
'heading-three': props => <h3 {...props.attributes}>{props.children}</h3>, |
|
'heading-four': props => <h4 {...props.attributes}>{props.children}</h4>, |
|
'heading-five': props => <h5 {...props.attributes}>{props.children}</h5>, |
|
'heading-six': props => <h6 {...props.attributes}>{props.children}</h6>, |
|
'list-item': props => <li {...props.attributes}>{props.children}</li>, |
|
'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>, |
|
'block-quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>, |
|
'link': (props) => { |
|
const { data } = props.node |
|
const href = data.get('href') |
|
return <a href={href} {...props.attributes}>{props.children}</a> |
|
} |
|
} |
|
|
|
/** |
|
* Define a set of mark renderers. |
|
* |
|
* @type {Object} |
|
*/ |
|
|
|
const MARKS = { |
|
bold: { |
|
fontWeight: 'bold' |
|
}, |
|
code: { |
|
fontFamily: 'monospace', |
|
backgroundColor: '#eee', |
|
padding: '3px', |
|
borderRadius: '4px' |
|
}, |
|
italic: { |
|
fontStyle: 'italic' |
|
}, |
|
underlined: { |
|
textDecoration: 'underline' |
|
} |
|
} |
|
|
|
/** |
|
* Tags to blocks. |
|
* |
|
* @type {Object} |
|
*/ |
|
|
|
const BLOCK_TAGS = { |
|
p: 'paragraph', |
|
li: 'list-item', |
|
ul: 'bulleted-list', |
|
ol: 'numbered-list', |
|
blockquote: 'block-quote', |
|
pre: 'code', |
|
h1: 'heading-one', |
|
h2: 'heading-two', |
|
h3: 'heading-three', |
|
h4: 'heading-four', |
|
h5: 'heading-five', |
|
h6: 'heading-six' |
|
} |
|
|
|
/** |
|
* Tags to marks. |
|
* |
|
* @type {Object} |
|
*/ |
|
|
|
const MARK_TAGS = { |
|
b: 'bold', |
|
strong: 'bold', |
|
em: 'italic', |
|
i: 'italic', |
|
u: 'underline', |
|
s: 'strikethrough', |
|
code: 'code' |
|
} |
|
|
|
/** |
|
* Serializer rules. |
|
* |
|
* @type {Array} |
|
*/ |
|
|
|
const RULES = [ |
|
{ |
|
deserialize(el, next) { |
|
const block = BLOCK_TAGS[el.tagName] |
|
if (!block) return |
|
return { |
|
kind: 'block', |
|
type: block, |
|
nodes: next(el.children) |
|
} |
|
} |
|
}, |
|
{ |
|
deserialize(el, next) { |
|
const mark = MARK_TAGS[el.tagName] |
|
if (!mark) return |
|
return { |
|
kind: 'mark', |
|
type: mark, |
|
nodes: next(el.children) |
|
} |
|
} |
|
}, |
|
{ |
|
// Special case for code blocks, which need to grab the nested children. |
|
deserialize(el, next) { |
|
if (el.tagName != 'pre') return |
|
const code = el.children[0] |
|
const children = code && code.tagName == 'code' |
|
? code.children |
|
: el.children |
|
|
|
return { |
|
kind: 'block', |
|
type: 'code', |
|
nodes: next(children) |
|
} |
|
} |
|
}, |
|
{ |
|
// Special case for links, to grab their href. |
|
deserialize(el, next) { |
|
if (el.tagName != 'a') return |
|
return { |
|
kind: 'inline', |
|
type: 'link', |
|
nodes: next(el.children), |
|
data: { |
|
href: el.attribs.href |
|
} |
|
} |
|
} |
|
} |
|
] |
|
|
|
/** |
|
* Create a new HTML serializer with `RULES`. |
|
* |
|
* @type {Html} |
|
*/ |
|
|
|
const serializer = new Html(RULES) |
|
|
|
export default class BetterEditor extends React.Component { |
|
constructor(props, context) { |
|
super(props, context); |
|
|
|
// How to set initial state in ES6 class syntax |
|
// https://facebook.github.io/react/docs/reusable-components.html#es6-classes |
|
|
|
if ('html' in this.props) { |
|
this.state = { state: serializer.deserialize(this.props.html) } |
|
} else { |
|
this.state = { state: serializer.deserialize('') } |
|
} |
|
} |
|
|
|
// /** |
|
// * Deserialize the raw initial state. |
|
// * |
|
// * @type {Object} |
|
// */ |
|
// |
|
// state = { |
|
// state: initialState |
|
// }; |
|
|
|
/** |
|
* Check if the current selection has a mark with `type` in it. |
|
* |
|
* @param {String} type |
|
* @return {Boolean} |
|
*/ |
|
|
|
hasMark = (type) => { |
|
const { state } = this.state |
|
return state.marks.some(mark => mark.type == type) |
|
} |
|
|
|
/** |
|
* Check if the any of the currently selected blocks are of `type`. |
|
* |
|
* @param {String} type |
|
* @return {Boolean} |
|
*/ |
|
|
|
hasBlock = (type) => { |
|
const { state } = this.state |
|
return state.blocks.some(node => node.type == type) |
|
} |
|
|
|
/** |
|
* On key down, if it's a formatting command toggle a mark. |
|
* |
|
* @param {Event} e |
|
* @param {State} state |
|
* @return {State} |
|
*/ |
|
|
|
onKeyDown = (e, state) => { |
|
if (!Utils.Key.isCommand(e)) return |
|
const key = keycode(e.which) |
|
let mark |
|
|
|
switch (key) { |
|
case 'b': |
|
mark = 'bold' |
|
break |
|
case 'i': |
|
mark = 'italic' |
|
break |
|
case 'u': |
|
mark = 'underlined' |
|
break |
|
case '`': |
|
mark = 'code' |
|
break |
|
default: |
|
return |
|
} |
|
|
|
state = state |
|
.transform() |
|
[this.hasMark(mark) ? 'unmark' : 'mark'](mark) |
|
.apply() |
|
|
|
e.preventDefault() |
|
return state |
|
} |
|
|
|
/** |
|
* When a mark button is clicked, toggle the current mark. |
|
* |
|
* @param {Event} e |
|
* @param {String} type |
|
*/ |
|
|
|
onClickMark = (e, type) => { |
|
e.preventDefault() |
|
const isActive = this.hasMark(type) |
|
let { state } = this.state |
|
|
|
state = state |
|
.transform() |
|
[isActive ? 'unmark' : 'mark'](type) |
|
.apply() |
|
|
|
this.setState({ state }) |
|
} |
|
|
|
/** |
|
* When a block button is clicked, toggle the block type. |
|
* |
|
* @param {Event} e |
|
* @param {String} type |
|
*/ |
|
|
|
onClickBlock = (e, type) => { |
|
e.preventDefault() |
|
const isActive = this.hasBlock(type) |
|
let { state } = this.state |
|
|
|
state = state |
|
.transform() |
|
.setBlock(isActive ? 'paragraph' : type) |
|
.apply() |
|
|
|
this.setState({ state }) |
|
} |
|
|
|
/** |
|
* On change, save the new state. |
|
* |
|
* @param {State} state |
|
*/ |
|
|
|
onChange = (state) => { |
|
this.setState({ state }) |
|
} |
|
|
|
/** |
|
* Render the toolbar. |
|
* |
|
* @return {Element} |
|
*/ |
|
|
|
renderToolbar = () => { |
|
return ( |
|
<div className="menu toolbar-menu"> |
|
{this.renderMarkButton('bold', 'format_bold')} |
|
{this.renderMarkButton('italic', 'format_italic')} |
|
{this.renderMarkButton('underlined', 'format_underlined')} |
|
{this.renderMarkButton('code', 'code')} |
|
{this.renderBlockButton('heading-one', 'looks_one')} |
|
{this.renderBlockButton('heading-two', 'looks_two')} |
|
{this.renderBlockButton('block-quote', 'format_quote')} |
|
{this.renderBlockButton('numbered-list', 'format_list_numbered')} |
|
{this.renderBlockButton('bulleted-list', 'format_list_bulleted')} |
|
</div> |
|
) |
|
} |
|
|
|
/** |
|
* Render a mark-toggling toolbar button. |
|
* |
|
* @param {String} type |
|
* @param {String} icon |
|
* @return {Element} |
|
*/ |
|
|
|
renderMarkButton = (type, icon) => { |
|
const isActive = this.hasMark(type) |
|
const onMouseDown = e => this.onClickMark(e, type) |
|
|
|
return ( |
|
<span className="button" onMouseDown={onMouseDown} data-active={isActive}> |
|
<span className="material-icons">{icon}</span> |
|
</span> |
|
) |
|
} |
|
|
|
/** |
|
* Render a block-toggling toolbar button. |
|
* |
|
* @param {String} type |
|
* @param {String} icon |
|
* @return {Element} |
|
*/ |
|
|
|
renderBlockButton = (type, icon) => { |
|
const isActive = this.hasBlock(type) |
|
const onMouseDown = e => this.onClickBlock(e, type) |
|
|
|
return ( |
|
<span className="button" onMouseDown={onMouseDown} data-active={isActive}> |
|
<span className="material-icons">{icon}</span> |
|
</span> |
|
) |
|
} |
|
|
|
/** |
|
* Render the Slate editor. |
|
* |
|
* @return {Element} |
|
*/ |
|
|
|
renderEditor = () => { |
|
return ( |
|
<div className="editor"> |
|
<Editor |
|
placeholder={'Type here...'} |
|
state={this.state.state} |
|
renderNode={this.renderNode} |
|
renderMark={this.renderMark} |
|
onChange={this.onChange} |
|
onKeyDown={this.onKeyDown} |
|
onPaste={this.onPaste} |
|
/> |
|
</div> |
|
) |
|
} |
|
|
|
/** |
|
* On paste, deserialize the HTML and then insert the fragment. |
|
* |
|
* @param {Event} e |
|
* @param {Object} paste |
|
* @param {State} state |
|
*/ |
|
|
|
onPaste = (e, paste, state) => { |
|
if (paste.type != 'html') return |
|
const { html } = paste |
|
const { document } = serializer.deserialize(html) |
|
|
|
return state |
|
.transform() |
|
.insertFragment(document) |
|
.apply() |
|
} |
|
|
|
/** |
|
* Render. |
|
* |
|
* @return {Component} |
|
*/ |
|
|
|
render = () => { |
|
return ( |
|
<div className="bettereditor"> |
|
{this.renderToolbar()} |
|
{this.renderEditor()} |
|
</div> |
|
) |
|
} |
|
|
|
/** |
|
* Return a node renderer for a Slate `node`. |
|
* |
|
* @param {Node} node |
|
* @return {Component or Void} |
|
*/ |
|
|
|
renderNode = (node) => { |
|
return NODES[node.type] |
|
} |
|
|
|
/** |
|
* Return a mark renderer for a Slate `mark`. |
|
* |
|
* @param {Mark} mark |
|
* @return {Object or Void} |
|
*/ |
|
|
|
renderMark = (mark) => { |
|
return MARKS[mark.type] |
|
} |
|
} |