First version of categories tooltip.
authorAlexandre Segura <mex.zktk@gmail.com>
Thu, 08 Jun 2017 11:13:41 +0200
changeset 21 284e866f55c7
parent 20 a8300ef1876e
child 22 92283f86282d
First version of categories tooltip. - Use react-portal to display hovering menu. - Add custom Mark to store category data.
client/package.json
client/src/App.scss
client/src/HtmlSerializer.js
client/src/components/CategoriesTooltip.js
client/src/components/Clock.js
client/src/components/NoteInput.js
client/src/components/SlateEditor.js
--- a/client/package.json	Wed Jun 07 18:18:44 2017 +0200
+++ b/client/package.json	Thu Jun 08 11:13:41 2017 +0200
@@ -12,6 +12,7 @@
     "react": "^15.5.4",
     "react-bootstrap": "^0.31.0",
     "react-dom": "^15.5.4",
+    "react-portal": "^3.1.0",
     "react-redux": "^5.0.5",
     "react-router-redux": "next",
     "redux": "^3.6.0",
--- a/client/src/App.scss	Wed Jun 07 18:18:44 2017 +0200
+++ b/client/src/App.scss	Thu Jun 08 11:13:41 2017 +0200
@@ -35,6 +35,38 @@
   }
 }
 
+.hovering-menu {
+    position: absolute;
+    z-index: 1;
+    top: -10000px;
+    left: -10000px;
+    margin-top: -64px;
+    opacity: 0;
+    transition: opacity .75s;
+}
+
+.categories-tooltip {
+    background-color: #efefef;
+    border-radius: 4px;
+    border: 1px solid #ccc;
+    padding: 5px;
+    .buttons {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: space-between;
+        button {
+            background-color: yellow;
+            border: 1px solid #ccc;
+        }
+        button:not(:last-child) {
+            margin-right: 10px;
+        }
+    }
+    .form-group:last-child {
+        margin-bottom: 0;
+    }
+}
+
 .editor-wrapper {
   border: 1px solid #efefef;
   padding: 20px;
--- a/client/src/HtmlSerializer.js	Wed Jun 07 18:18:44 2017 +0200
+++ b/client/src/HtmlSerializer.js	Thu Jun 08 11:13:41 2017 +0200
@@ -13,12 +13,6 @@
   annotation: 'span'
 }
 
-const annotationStyle = {
-  textDecoration: 'underline',
-  textDecorationStyle: 'dotted',
-  backgroundColor: 'yellow'
-}
-
 const rules = [
   // Block rules
   {
@@ -57,7 +51,7 @@
         case 'bold': return <strong>{children}</strong>
         case 'italic': return <em>{children}</em>
         case 'underline': return <u>{children}</u>
-        case 'annotation': return <span style={annotationStyle}>{children}</span>
+        case 'annotation': return <span style={{ backgroundColor: object.data.get('color') }}>{children}</span>
         default: return;
       }
     }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/CategoriesTooltip.js	Thu Jun 08 11:13:41 2017 +0200
@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+import { FormGroup, FormControl, Button } from 'react-bootstrap';
+
+class CategoriesTooltip extends Component {
+
+  onButtonClick = (category) => {
+    if (typeof this.props.onCategoryClick === 'function') {
+      this.props.onCategoryClick(category)
+    }
+  }
+
+  render() {
+    return (
+      <div className="categories-tooltip">
+        <FormGroup className="buttons">
+        {this.props.categories.map((category) =>
+          <Button key={ category.name } bsStyle="primary" style={{ backgroundColor: category.color }}
+            onClick={this.onButtonClick.bind(this, category)}>{ category.name }</Button>
+        )}
+        </FormGroup>
+        <FormGroup>
+          <FormControl />
+        </FormGroup>
+      </div>
+    );
+  }
+}
+
+export default CategoriesTooltip
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/Clock.js	Thu Jun 08 11:13:41 2017 +0200
@@ -0,0 +1,25 @@
+import React, { Component } from 'react';
+import { Label } from 'react-bootstrap';
+import moment from 'moment';
+
+class Clock extends Component {
+
+  state = {
+    time: moment().format('H:mm:ss'),
+  }
+
+  componentDidMount() {
+    setInterval(() => {
+      const time = moment().format('H:mm:ss');
+      this.setState({ time });
+    }, 1000);
+  }
+
+  render() {
+    return (
+      <Label bsStyle="info">{ this.state.time }</Label>
+    );
+  }
+}
+
+export default Clock
--- a/client/src/components/NoteInput.js	Wed Jun 07 18:18:44 2017 +0200
+++ b/client/src/components/NoteInput.js	Thu Jun 08 11:13:41 2017 +0200
@@ -4,12 +4,12 @@
 
 import PropTypes from 'prop-types';
 import SlateEditor from './SlateEditor';
+import Clock from './Clock'
 
 class NoteInput extends Component {
 
   state = {
     buttonDisabled: false,
-    time: moment().format('H:mm:ss'),
     startedAt: null,
     finishedAt: null,
   }
@@ -43,10 +43,6 @@
   componentDidMount() {
     const text = this.refs.editor.asPlain();
     this.setState({ buttonDisabled: text.length === 0 });
-    setInterval(() => {
-      const time = moment().format('H:mm:ss');
-      this.setState({ time });
-    }, 1000);
   }
 
   renderTiming() {
@@ -71,7 +67,7 @@
             { this.renderTiming() }
           </Col>
           <Col md={6} className="text-right">
-            <Label bsStyle="info">{ this.state.time }</Label>
+            <Clock />
           </Col>
         </Row>
         <hr />
--- 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`
+  }
+
 }
 
 /**