client/src/components/SlateEditor.js
changeset 21 284e866f55c7
parent 19 f1b125b95fe9
child 25 e04714a1d4eb
--- a/client/src/components/SlateEditor.js	Wed Jun 07 18:18:44 2017 +0200
+++ b/client/src/components/SlateEditor.js	Thu Jun 08 11:13:41 2017 +0200
@@ -1,8 +1,10 @@
 import { Editor, Plain, Raw } from 'slate'
 import React from 'react'
-import moment from 'moment';
+import Portal from 'react-portal'
+import moment from 'moment'
 import HtmlSerializer from '../HtmlSerializer'
-import AnnotationPlugin from '../AnnotationPlugin';
+import AnnotationPlugin from '../AnnotationPlugin'
+import CategoriesTooltip from './CategoriesTooltip'
 
 const plugins = [];
 
@@ -17,7 +19,8 @@
  *
  * @type {Object}
  */
-
+// TODO Check if we can move this to the plugin using the schema option
+// https://docs.slatejs.org/reference/plugins/plugin.html#schema
 const schema = {
   nodes: {
     'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
@@ -28,12 +31,16 @@
     bold: {
       fontWeight: 'bold'
     },
-    // TODO Check if we can move this to the plugin using the schema option
-    // https://docs.slatejs.org/reference/plugins/plugin.html#schema
-    annotation: {
+    // This is a "temporary" mark added when the hovering menu is open
+    highlight: {
       textDecoration: 'underline',
       textDecorationStyle: 'dotted',
-      backgroundColor: 'yellow',
+      backgroundColor: '#ccc',
+    },
+    // This is the mark actually used for annotations
+    annotation: props => {
+      const data = props.mark.data;
+      return <span style={{ backgroundColor: data.get('color') }}>{props.children}</span>
     },
     italic: {
       fontStyle: 'italic'
@@ -44,6 +51,12 @@
   }
 }
 
+const annotationCategories = [
+  { key: 'important', name: 'Important',    color: '#F1C40F' },
+  { key: 'keyword',   name: 'Mot-clé',      color: '#2ECC71' },
+  { key: 'comment',   name: 'Commentaire',  color: '#3498DB' }
+];
+
 /**
  * The rich text example.
  *
@@ -72,14 +85,21 @@
       state: Plain.deserialize(''),
       startedAt: null,
       finishedAt: null,
-      currentSelectionText: ''
+      currentSelectionText: '',
+      hoveringMenu: null,
+      isPortalOpen: false
     };
   }
 
-  componentDidMount() {
+  componentDidMount = () => {
+    this.updateMenu();
     this.focus();
   }
 
+  componentDidUpdate = () => {
+    this.updateMenu();
+  }
+
   /**
    * Check if the current selection has a mark with `type` in it.
    *
@@ -207,11 +227,14 @@
 
   onClickMark = (e, type) => {
     e.preventDefault()
-    let { state } = this.state
+    let { state, hoveringMenu } = this.state
 
     let toggleMarkOptions;
-    if (type === 'annotation') {
+    let isPortalOpen = false;
+
+    if (type === 'highlight') {
       toggleMarkOptions = { type: type, data: { text: this.state.currentSelectionText } }
+      isPortalOpen = !this.state.isPortalOpen;
     } else {
       toggleMarkOptions = type;
     }
@@ -221,7 +244,10 @@
       .toggleMark(toggleMarkOptions)
       .apply()
 
-    this.setState({ state })
+    this.setState({
+      state: state,
+      isPortalOpen: isPortalOpen
+    })
   }
 
   /**
@@ -282,6 +308,51 @@
     this.setState({ state })
   }
 
+  onPortalOpen = (portal) => {
+    // When the portal opens, cache the menu element.
+    this.setState({ hoveringMenu: portal.firstChild })
+  }
+
+  onPortalClose = (portal) => {
+
+    let { state } = this.state
+    const transform = state.transform();
+
+    state.marks.forEach(mark => {
+      if (mark.type === 'highlight') {
+        transform.removeMark(mark)
+      }
+    });
+
+    this.setState({
+      state: transform.apply(),
+      isPortalOpen: false
+    })
+  }
+
+  onCategoryClick = (category) => {
+
+    const { state } = this.state;
+    const transform = state.transform();
+
+    state.marks.forEach(mark => transform.removeMark(mark));
+
+    transform.addMark({
+      type: 'annotation',
+      data: {
+        text: this.state.currentSelectionText,
+        color: category.color,
+        key: category.key
+      }
+    })
+
+    this.setState({
+      state: transform.apply(),
+      isPortalOpen: false
+    });
+
+  }
+
   /**
    * Render.
    *
@@ -309,7 +380,7 @@
         {this.renderMarkButton('bold', 'format_bold')}
         {this.renderMarkButton('italic', 'format_italic')}
         {this.renderMarkButton('underlined', 'format_underlined')}
-        {this.renderMarkButton('annotation', 'label')}
+        {this.renderMarkButton('highlight', 'label')}
 
         {this.renderBlockButton('numbered-list', 'format_list_numbered')}
         {this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
@@ -364,6 +435,7 @@
   renderEditor = () => {
     return (
       <div className="editor">
+        {this.renderHoveringMenu()}
         <Editor
           ref="editor"
           spellCheck
@@ -378,6 +450,45 @@
     )
   }
 
+  renderHoveringMenu = () => {
+    return (
+      <Portal ref="portal"
+        isOpened={this.state.isPortalOpen} isOpen={this.state.isPortalOpen}
+        onOpen={this.onPortalOpen}
+        onClose={this.onPortalClose}
+        closeOnOutsideClick={false} closeOnEsc={true}>
+        <div className="hovering-menu">
+          <CategoriesTooltip categories={annotationCategories} onCategoryClick={this.onCategoryClick} />
+        </div>
+      </Portal>
+    )
+  }
+
+  updateMenu = () => {
+
+    const { hoveringMenu, state } = this.state
+
+    if (!hoveringMenu) return
+
+    // if (state.isBlurred || state.isCollapsed) {
+    //   hoveringMenu.removeAttribute('style')
+    //   return
+    // }
+
+    const selection = window.getSelection()
+
+    if (selection.isCollapsed) {
+      return
+    }
+
+    const range = selection.getRangeAt(0)
+    const rect = range.getBoundingClientRect()
+
+    hoveringMenu.style.opacity = 1
+    hoveringMenu.style.top = `${rect.top + window.scrollY + hoveringMenu.offsetHeight}px`
+    hoveringMenu.style.left = `${rect.left + window.scrollX - hoveringMenu.offsetWidth / 2 + rect.width / 2}px`
+  }
+
 }
 
 /**