Introduce SlateJS.
authorAlexandre Segura <mex.zktk@gmail.com>
Tue, 23 May 2017 16:18:34 +0200
changeset 5 5c91bfa8fcde
parent 4 885a20cde527
child 6 540161f63238
Introduce SlateJS.
client/package.json
client/public/index.html
client/src/App.js
client/src/App.scss
client/src/components/NoteInput.js
client/src/components/SlateEditor.js
client/src/components/state.json
--- 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