Introduce SlateJS.
--- a/client/package.json Tue May 23 16:42:07 2017 +0200
+++ b/client/package.json Tue May 23 16:18:34 2017 +0200
@@ -12,6 +12,7 @@
"react-redux": "^5.0.5",
"redux": "^3.6.0",
"redux-immutable": "^4.0.0",
+ "slate": "^0.20.1",
"uuid": "^3.0.1"
},
"devDependencies": {
--- a/client/public/index.html Tue May 23 16:42:07 2017 +0200
+++ b/client/public/index.html Tue May 23 16:18:34 2017 +0200
@@ -10,6 +10,7 @@
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
--- a/client/src/App.js Tue May 23 16:42:07 2017 +0200
+++ b/client/src/App.js Tue May 23 16:18:34 2017 +0200
@@ -38,7 +38,7 @@
<Grid>
<Row>
<Col xs={12} md={12}>
- < NotesContainer />
+ <NotesContainer />
</Col>
</Row>
</Grid>
--- a/client/src/App.scss Tue May 23 16:42:07 2017 +0200
+++ b/client/src/App.scss Tue May 23 16:18:34 2017 +0200
@@ -18,6 +18,22 @@
font-size: large;
}
+.toolbar-menu {
+ padding: 1px 0 17px 18px;
+ margin: 0 -20px;
+ border-bottom: 2px solid #eee;
+ margin-bottom: 20px;
+ .button {
+ color: #ccc;
+ cursor: pointer;
+ }
+
+ .button[data-active="true"] {
+ color: black;
+ }
+}
+
+
@keyframes App-logo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
--- a/client/src/components/NoteInput.js Tue May 23 16:42:07 2017 +0200
+++ b/client/src/components/NoteInput.js Tue May 23 16:18:34 2017 +0200
@@ -4,6 +4,7 @@
import { Form, FormControl, FormGroup, Button } from 'react-bootstrap';
import PropTypes from 'prop-types';
+import SlateEditor from './SlateEditor';
class NoteInput extends Component {
constructor(props) {
@@ -20,27 +21,20 @@
}
onAddNoteClick(event) {
- this.props.addNote(this.state.value);
-
- this.noteInput.value = "";
-
- this.noteInput.focus();
+ const text = this.refs.editor.asPlain();
+ this.props.addNote(text);
+ this.refs.editor.clear();
}
componentDidMount() {
- this.noteInput.focus();
+ // this.noteInput.focus();
}
render() {
return (
<Form>
<FormGroup>
- <FormControl
- componentClass="textarea"
- placeholder="Enter note"
- onChange={this.handleChange}
- ref={(input) => { this.noteInput = ReactDOM.findDOMNode(input); }}
- />
+ <SlateEditor ref="editor" />
</FormGroup>
<Button type="button" onClick={this.onAddNoteClick}>Add Note</Button>
</Form>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/SlateEditor.js Tue May 23 16:18:34 2017 +0200
@@ -0,0 +1,325 @@
+import { Editor, Raw, Plain } from 'slate'
+import React from 'react'
+import initialState from './state.json'
+
+/**
+ * Define the default node type.
+ */
+
+const DEFAULT_NODE = 'paragraph'
+
+/**
+ * Define a schema.
+ *
+ * @type {Object}
+ */
+
+const schema = {
+ nodes: {
+ 'block-quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>,
+ 'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
+ 'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>,
+ 'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
+ 'list-item': props => <li {...props.attributes}>{props.children}</li>,
+ 'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>,
+ },
+ marks: {
+ bold: {
+ fontWeight: 'bold'
+ },
+ code: {
+ fontFamily: 'monospace',
+ backgroundColor: '#eee',
+ padding: '3px',
+ borderRadius: '4px'
+ },
+ italic: {
+ fontStyle: 'italic'
+ },
+ underlined: {
+ textDecoration: 'underline'
+ }
+ }
+}
+
+/**
+ * The rich text example.
+ *
+ * @type {Component}
+ */
+
+class RichText extends React.Component {
+
+ /**
+ * Deserialize the initial editor state.
+ *
+ * @type {Object}
+ */
+
+ state = {
+ state: Raw.deserialize(initialState, { terse: true })
+ };
+
+ /**
+ * 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 change, save the new state.
+ *
+ * @param {State} state
+ */
+
+ onChange = (state) => {
+ this.setState({ state })
+
+ }
+
+ asPlain = () => {
+ return Plain.serialize(this.state.state);
+ }
+
+ clear = () => {
+ const state = Plain.deserialize('');
+ this.setState({ stateĀ });
+ }
+
+ /**
+ * On key down, if it's a formatting command toggle a mark.
+ *
+ * @param {Event} e
+ * @param {Object} data
+ * @param {State} state
+ * @return {State}
+ */
+
+ onKeyDown = (e, data, state) => {
+ if (!data.isMod) return
+ let mark
+
+ switch (data.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()
+ .toggleMark(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()
+ let { state } = this.state
+
+ state = state
+ .transform()
+ .toggleMark(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()
+ let { state } = this.state
+ const transform = state.transform()
+ const { document } = state
+
+ // Handle everything but list buttons.
+ if (type !== 'bulleted-list' && type !== 'numbered-list') {
+ const isActive = this.hasBlock(type)
+ const isList = this.hasBlock('list-item')
+
+ if (isList) {
+ transform
+ .setBlock(isActive ? DEFAULT_NODE : type)
+ .unwrapBlock('bulleted-list')
+ .unwrapBlock('numbered-list')
+ }
+
+ else {
+ transform
+ .setBlock(isActive ? DEFAULT_NODE : type)
+ }
+ }
+
+ // Handle the extra wrapping required for list buttons.
+ else {
+ const isList = this.hasBlock('list-item')
+ const isType = state.blocks.some((block) => {
+ return !!document.getClosest(block.key, parent => parent.type === type)
+ })
+
+ if (isList && isType) {
+ transform
+ .setBlock(DEFAULT_NODE)
+ .unwrapBlock('bulleted-list')
+ .unwrapBlock('numbered-list')
+ } else if (isList) {
+ transform
+ .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
+ .wrapBlock(type)
+ } else {
+ transform
+ .setBlock('list-item')
+ .wrapBlock(type)
+ }
+ }
+
+ state = transform.apply()
+ this.setState({ state })
+ }
+
+ /**
+ * Render.
+ *
+ * @return {Element}
+ */
+
+ render = () => {
+ return (
+ <div>
+ {this.renderToolbar()}
+ {this.renderEditor()}
+ </div>
+ )
+ }
+
+ /**
+ * 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
+ spellCheck
+ placeholder={'Enter some rich text...'}
+ schema={schema}
+ state={this.state.state}
+ onChange={this.onChange}
+ onKeyDown={this.onKeyDown}
+ />
+ </div>
+ )
+ }
+
+}
+
+/**
+ * Export.
+ */
+
+export default RichText
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/state.json Tue May 23 16:18:34 2017 +0200
@@ -0,0 +1,95 @@
+{
+ "nodes": [
+ {
+ "kind": "block",
+ "type": "paragraph",
+ "nodes": [
+ {
+ "kind": "text",
+ "ranges": [
+ {
+ "text": "This is editable "
+ },
+ {
+ "text": "rich",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ]
+ },
+ {
+ "text": " text, "
+ },
+ {
+ "text": "much",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ]
+ },
+ {
+ "text": " better than a "
+ },
+ {
+ "text": "<textarea>",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ]
+ },
+ {
+ "text": "!"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "kind": "block",
+ "type": "paragraph",
+ "nodes": [
+ {
+ "kind": "text",
+ "ranges": [
+ {
+ "text": "Since it's rich text, you can do things like turn a selection of text "
+ },
+ {
+ "text": "bold",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ]
+ },{
+ "text": ", or add a semantically rendered block quote in the middle of the page, like this:"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "kind": "block",
+ "type": "block-quote",
+ "nodes": [
+ {
+ "kind": "text",
+ "text": "A wise quote."
+ }
+ ]
+ },
+ {
+ "kind": "block",
+ "type": "paragraph",
+ "nodes": [
+ {
+ "kind": "text",
+ "text": "Try it out for yourself!"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file