From bd9a5d0e1d5b8ec83b5129d3823879671bb39e12 Mon Sep 17 00:00:00 2001
From: Tim <roadscape@users.noreply.github.com>
Date: Tue, 4 Oct 2016 15:56:25 -0400
Subject: [PATCH] slate editor poc #185

---
 app/components/all.scss                  |   1 +
 app/components/elements/SlateEditor.jsx  | 299 +++++++++++++++++++++++
 app/components/elements/SlateEditor.scss |  74 ++++++
 package.json                             |   3 +
 4 files changed, 377 insertions(+)
 create mode 100644 app/components/elements/SlateEditor.jsx
 create mode 100644 app/components/elements/SlateEditor.scss

diff --git a/app/components/all.scss b/app/components/all.scss
index 51773df20..08a7e6905 100644
--- a/app/components/all.scss
+++ b/app/components/all.scss
@@ -15,6 +15,7 @@
 @import "./elements/FormattedAsset";
 @import "./elements/ReplyEditor";
 @import "./elements/MediumEditor";
+@import "./elements/SlateEditor";
 @import "./elements/DropdownMenu";
 @import "./elements/FoundationDropdownMenu";
 @import "./elements/VerticalMenu";
diff --git a/app/components/elements/SlateEditor.jsx b/app/components/elements/SlateEditor.jsx
new file mode 100644
index 000000000..b2cf2a6d6
--- /dev/null
+++ b/app/components/elements/SlateEditor.jsx
@@ -0,0 +1,299 @@
+/*
+import EditBlockquote from 'slate-edit-blockquote'
+import TrailingBlock from 'slate-trailing-block'
+
+const plugins = [
+    TrailingBlock({ type: 'paragraph' }),
+    EditBlockquote()
+]
+*/
+const plugins = []
+
+import { Editor, Mark, Raw, Html } from 'slate'
+import Portal from 'react-portal'
+import React from 'react'
+import position from 'selection-position'
+
+const serializer = new Html({rules: [
+    {
+        deserialize: (el, next) => null,
+        serialize: (object, children) => {
+            if(object.kind == 'string') return;
+            if(object.kind == 'block') {
+                switch(object.type) {
+                    case 'paragraph':          return <p>{children}</p>
+                    case 'block-quote':        return <blockquote>{children}</blockquote>
+                    case 'bulleted-list':      return <ul>{children}</ul>
+                    case 'numbered-list':      return <ol>{children}</ol>
+                    case 'heading-one':        return <h1>{children}</h1>
+                    case 'heading-two':        return <h2>{children}</h2>
+                    case 'heading-three':      return <h3>{children}</h3>
+                    case 'heading-four':       return <h4>{children}</h4>
+                    case 'bulleted-list-item': return <li>{children}</li>
+                    case 'numbered-list-item': return <li>{children}</li>
+                }
+            }
+            if(object.kind == 'mark') {
+                switch(object.type) {
+                    case 'bold':      return <strong>{children}</strong>
+                    case 'italic':    return <i>{children}</i>
+                    case 'underline': return <u>{children}</u>
+                    case 'strike':    return <s>{children}</s>
+                    case 'code':      return <code>{children}</code>
+                }
+            }
+
+            console.log("No serializer: ", object.kind, JSON.stringify(object, null, 2), children)
+        }
+    },
+]})
+
+const schema = {
+    defaultNode: 'paragraph',
+    //blockTypes: {
+    //  ...Blocks,
+    //},
+    toolbarMarks: [
+        { type: 'bold', icon: 'bold' },
+        { type: 'italic', icon: 'italic' },
+        { type: 'underline', icon: 'underline' },
+        { type: 'code', icon: 'code' },
+    ],
+    toolbarTypes: [
+        { type: 'heading-one', icon: 'header' },
+        { type: 'heading-two', icon: 'header' },
+        { type: 'block-quote', icon: 'quote-left' },
+        { type: 'numbered-list', icon: 'list-ol' },
+        { type: 'bulleted-list', icon: 'list-ul' },
+    ],
+    sidebarTypes: [],
+    nodes: {
+        'block':   ({ children }) => <p style={{background: 'red'}}>{children}</p>,
+        'paragraph':   ({ children }) => <p style={{color: 'blue'}}>{children}</p>,
+        'block-quote':   ({ children }) => <blockquote>{children}</blockquote>,
+        'bulleted-list': ({ children }) => <ul>{children}</ul>,
+        'numbered-list': ({ children, attributes }) => <ol {...attributes}>{children}</ol>,
+        'heading-one':   ({ children }) => <h1>{children}</h1>,
+        'heading-two':   ({ children }) => <h2>{children}</h2>,
+        'heading-three': ({ children }) => <h3>{children}</h3>,
+        'heading-four':  ({ children }) => <h4>{children}</h4>,
+        'bulleted-list-item': ({ children }) => <li>{children}</li>,
+        'numbered-list-item': ({ children }) => <li>{children}</li>,
+    },
+    marks: {
+        bold:      props => <strong>{props.children}</strong>,
+        code:      props => <code>{props.children}</code>,
+        italic:    props => <em>{props.children}</em>,
+        underline: props => <u>{props.children}</u>,
+        strike:    props => <s>{props.children}</s>,
+    },
+    getMarkdownType: (chars) => {
+        switch (chars) {
+            case '*':
+            case '-': return 'bulleted-list-item';
+            case '>': return 'block-quote';
+            case '#': return 'heading-one';
+            case '##': return 'heading-two';
+            case '###': return 'heading-three';
+            case '####': return 'heading-four';
+            case '1.': return 'numbered-list-item';
+            default: return null;
+        }
+    },
+}
+
+
+class SlateEditor extends React.Component {
+
+    constructor(props) {
+        super(props)
+        this.state = {state: props.initialState}
+    }
+
+    componentDidMount = () => {
+        this.updateMenu()
+    }
+
+    componentDidUpdate = () => {
+        this.updateMenu()
+    }
+
+    // Check if the current selection has a mark with `type` in it.
+    hasMark = (type) => {
+        const { state } = this.state
+        return state.marks.some(mark => mark.type == type)
+    }
+
+    onChange = (state) => {
+        this.setState({ state })
+    }
+
+    // When a mark button is clicked, toggle the current mark.
+    onClickMark = (e, type) => {
+        e.preventDefault()
+        let { state } = this.state
+
+        state = state
+            .transform()
+            .toggleMark(type)
+            .apply()
+
+        this.setState({ state })
+    }
+
+    // When the portal opens, cache the menu element.
+    onOpen = (portal) => {
+        this.setState({ menu: portal.firstChild })
+    }
+
+
+    // Markdown-style quick formatting
+    onKeyDown = (e, data, state) => {
+        switch (data.key) {
+            case 'space': return this.onSpace(e, state)
+            case 'backspace': return this.onBackspace(e, state)
+            case 'enter': return this.onEnter(e, state)
+        }
+    }
+
+    // If space was entered, check if it was a markdown sequence
+    onSpace = (e, state) => {
+        if (state.isExpanded) return
+        let { selection } = state
+        const { startText, startBlock, startOffset } = state
+        const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '')
+        const type = schema.getMarkdownType(chars)
+
+        if (!type) return
+        if (type == 'bulleted-list-item' && startBlock.type == 'bulleted-list-item') return
+        if (type == 'numbered-list-item' && startBlock.type == 'numbered-list-item') return
+        e.preventDefault()
+
+        let transform = state
+            .transform()
+            .setBlock(type)
+
+        if (type == 'bulleted-list-item') transform = transform.wrapBlock('bulleted-list')
+        if (type == 'numbered-list-item') transform = transform.wrapBlock('numbered-list')
+
+        state = transform
+            .extendToStartOf(startBlock)
+            .delete()
+            .apply()
+
+        return state
+    }
+
+    // On backspace, if at the start of a non-paragraph, convert it back into a paragraph node.
+    onBackspace = (e, state) => {
+        if (state.isExpanded) return
+        if (state.startOffset != 0) return
+        const { startBlock } = state
+
+        if (startBlock.type == 'paragraph') return
+        e.preventDefault()
+
+        let transform = state
+            .transform()
+            .setBlock('paragraph')
+
+        if (startBlock.type == 'bulleted-list-item') transform = transform.unwrapBlock('bulleted-list')
+        if (startBlock.type == 'numbered-list-item') transform = transform.unwrapBlock('numbered-list')
+
+        state = transform.apply()
+        return state
+    }
+
+    // On return, if at the end of a node type that should not be extended, create a new paragraph below it.
+    onEnter = (e, state) => {
+        if (state.isExpanded) return //menu open
+        const { startBlock, startOffset, endOffset } = state
+        if (startOffset == 0 && startBlock.length == 0) return this.onBackspace(e, state) //empty block
+        if (endOffset != startBlock.length) return //not at end of block
+
+        if (
+            startBlock.type != 'heading-one' &&
+            startBlock.type != 'heading-two' &&
+            startBlock.type != 'heading-three' &&
+            startBlock.type != 'heading-four' &&
+            startBlock.type != 'block-quote'
+        ) return
+        
+        e.preventDefault()
+        return state
+            .transform()
+            .splitBlock()
+            .setBlock('paragraph')
+            .apply()
+    }
+
+
+    render = () => {
+        const { state } = this.state
+        console.log(serializer.serialize(state));
+        return (
+            <div>
+                {this.renderMenu()}
+                {this.renderEditor()}
+            </div>
+        )
+    }
+
+    renderMenu = () => {
+        const { state } = this.state
+        const isOpen = state.isExpanded && state.isFocused
+        return (
+            <Portal isOpened onOpen={this.onOpen}>
+                <div className="SlateEditor__menu SlateEditor__hover-menu">
+                    {this.renderMarkButton('bold',      <strong>B</strong>)}
+                    {this.renderMarkButton('italic',    <i>I</i>)}
+                    {this.renderMarkButton('underline', <u>U</u>)}
+                    {this.renderMarkButton('strike',    <s>S</s>)}
+                    {this.renderMarkButton('code',      <code>{'{}'}</code>)}
+                </div>
+            </Portal>
+        )
+    }
+
+    renderMarkButton = (type, label) => {
+        const isActive = this.hasMark(type)
+        const onMouseDown = e => this.onClickMark(e, type)
+
+        return (
+            <span className="SlateEditor__menu-button" onMouseDown={onMouseDown} data-active={isActive}>
+                <span>{label}</span>
+            </span>
+        )
+    }
+
+    renderEditor = () => {
+        return (
+            <div className="SlateEditor">
+                <Editor
+                    schema={schema}
+                    plugins={plugins}
+                    state={this.state.state}
+                    onChange={this.onChange}
+                    onKeyDown={this.onKeyDown}
+                />
+            </div>
+        )
+    }
+
+    updateMenu = () => {
+        const { menu, state } = this.state
+        if (!menu) return
+
+        if (state.isBlurred || state.isCollapsed) {
+          menu.removeAttribute('style')
+          return
+        }
+
+        const rect = position()
+        menu.style.opacity = 1
+        menu.style.top = `${rect.top + window.scrollY - menu.offsetHeight}px`
+        menu.style.left = `${rect.left + window.scrollX - menu.offsetWidth / 2 + rect.width / 2}px`
+    }
+}
+
+export default SlateEditor
diff --git a/app/components/elements/SlateEditor.scss b/app/components/elements/SlateEditor.scss
new file mode 100644
index 000000000..2458fbe7c
--- /dev/null
+++ b/app/components/elements/SlateEditor.scss
@@ -0,0 +1,74 @@
+.SlateEditor {
+  padding: 1rem;
+}
+
+.SlateEditor > * > * + * {
+  margin-top: 1em;
+}
+
+.SlateEditor__menu > * {
+  display: inline-block;
+}
+
+.SlateEditor__menu > * + * {
+  margin-left: 10px;
+}
+
+.SlateEditor__menu-button {
+  font-family: 'Georgia', serif;
+  color: #777;
+  cursor: pointer;
+  > span {
+    display: inline-block;
+    width: 2rem;
+    text-align: center;
+    background: #333;
+    border-radius: 2px;
+    code {
+      border: none;
+      background: transparent;
+      color: inherit;
+    }
+  }
+  > span:hover {
+    background: #555;
+    color: white;
+  }
+}
+
+.SlateEditor__menu-button[data-active="true"] {
+  color: white;
+  > span {
+    background: #555;
+  }
+}
+
+/*
+.toolbar-menu {
+  padding: 1px 0 17px 18px;
+  margin: 0 -20px;
+  border-bottom: 2px solid #eee;
+  margin-bottom: 20px;
+}
+*/
+
+.SlateEditor__hover-menu {
+  padding: 8px 7px 6px;
+  position: absolute;
+  z-index: 1;
+  top: -10000px;
+  left: -10000px;
+  margin-top: -6px;
+  opacity: 0;
+  background-color: #222;
+  border-radius: 4px;
+  transition: opacity .75s;
+}
+
+.SlateEditor__hover-menu .button {
+  color: #aaa;
+}
+
+.SlateEditor__hover-menu .button[data-active="true"] {
+  color: #fff;
+}
diff --git a/package.json b/package.json
index 8a94a547d..06bd5a7c9 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
     "react-medium-editor": "^1.8.0",
     "react-notification": "^5.0.7",
     "react-overlays": "0.6.4",
+    "react-portal": "^2.2.1",
     "react-prop-types": "^0.3.0",
     "react-qr": "0.0.2",
     "react-rangeslider": "^1.0.3",
@@ -108,9 +109,11 @@
     "sanitize-html": "^1.11.4",
     "sass-loader": "^3.1.2",
     "secure-random": "^1.1.1",
+    "selection-position": "^1.0.0",
     "sendgrid": "^4.0.1",
     "sequelize": "^3.21.0",
     "sequelize-cli": "^2.3.1",
+    "slate": "^0.14.13",
     "speakingurl": "^9.0.0",
     "style-loader": "^0.13.0",
     "svg-inline-loader": "^0.4.0",
-- 
GitLab