Correct the Note editor.
authorymh <ymh.work@gmail.com>
Fri, 16 Nov 2018 11:19:13 +0100
changeset 173 0e6703cd0968
parent 172 4b780ebbedc6
child 174 ac1a026edd58
Correct the Note editor. Split the source file in sub components. Correct a timing problem on the editor checkbox.
client/src/components/Note.js
client/src/components/NoteInput.js
client/src/components/Session.js
client/src/components/SlateEditor/AnnotationPlugin.js
client/src/components/SlateEditor/BlockButton.js
client/src/components/SlateEditor/CategoryButton.js
client/src/components/SlateEditor/HtmlSerializer.js
client/src/components/SlateEditor/MarkButton.js
client/src/components/SlateEditor/Toolbar.js
client/src/components/SlateEditor/ToolbarButtons.js
client/src/components/SlateEditor/index.js
client/src/locales/en/translation.json
client/src/locales/fr/translation.json
--- a/client/src/components/Note.js	Tue Nov 13 16:46:15 2018 +0100
+++ b/client/src/components/Note.js	Fri Nov 16 11:19:13 2018 +0100
@@ -25,7 +25,7 @@
     this.props.onDelete();
   }
 
-  onClickButton = (e) => {
+  submitNote = () => {
 
     const plain = this.editor.asPlain();
     const raw = this.editor.asRaw();
@@ -56,7 +56,7 @@
       return (
         <div className="note-content w-100 pl-2 pt-2">
           <SlateEditor editorRef={this.editorInst}
-            onButtonClick={ this.onClickButton }
+            submitNote={ this.submitNote }
             note={ this.props.note }
             annotationCategories={ this.props.annotationCategories } />
         </div>
--- a/client/src/components/NoteInput.js	Tue Nov 13 16:46:15 2018 +0100
+++ b/client/src/components/NoteInput.js	Fri Nov 16 11:19:13 2018 +0100
@@ -28,7 +28,7 @@
     });
   }
 
-  onAddNoteClick = () => {
+  submitNote = () => {
 
     const plain = this.editor.asPlain();
     const raw = this.editor.asRaw();
@@ -52,10 +52,6 @@
     setTimeout(() => this.editor.focus(), 250);
   }
 
-  onCheckboxChange = (e) => {
-    this.props.setAutoSubmit(e.target.checked);
-  }
-
   componentDidMount() {
     if(this.editor) {
       const text = this.editor.asPlain();
@@ -70,10 +66,7 @@
           <div className="editor-left sticky-bottom px-2">
             <SlateEditor editorRef={this.editorInst}
               onChange={this.onEditorChange}
-              onEnterKeyDown={this.onAddNoteClick}
-              onButtonClick={this.onAddNoteClick}
-              onCheckboxChange={this.onCheckboxChange}
-              isChecked={this.props.autoSubmit}
+              submitNote={this.submitNote}
               isButtonDisabled={this.state.buttonDisabled}
               annotationCategories={ this.props.annotationCategories } />
 
--- a/client/src/components/Session.js	Tue Nov 13 16:46:15 2018 +0100
+++ b/client/src/components/Session.js	Fri Nov 16 11:19:13 2018 +0100
@@ -12,9 +12,7 @@
 import ProtocolSummary from './ProtocolSummary';
 import * as sessionsActions from '../actions/sessionsActions';
 import * as notesActions from '../actions/notesActions';
-import * as userActions from '../actions/userActions';
 import { getSession, getSessionNotes } from '../selectors/coreSelectors';
-import { getAutoSubmit } from '../selectors/authSelectors';
 import { extractAnnotationCategories, defaultAnnotationsCategories } from '../constants';
 
 class Session extends Component {
@@ -88,9 +86,7 @@
                 <div className="col-lg-10 offset-md-2">
                   <NoteInput
                     session={this.props.currentSession}
-                    autoSubmit={this.props.autoSubmit}
                     addNote={this.props.notesActions.addNote}
-                    setAutoSubmit={this.props.userActions.setAutoSubmit}
                     annotationCategories={this.props.annotationCategories}/>
                 </div>
               </div>
@@ -106,7 +102,6 @@
 
   const sessionId = props.match.params.id;
 
-  const autoSubmit = getAutoSubmit(state);
   const currentSession = getSession(sessionId, state);
   const currentNotes = getSessionNotes(sessionId, state);
   const annotationCategories = currentSession?extractAnnotationCategories(currentSession.protocol):defaultAnnotationsCategories;
@@ -114,7 +109,6 @@
   return {
     currentSession,
     notes: currentNotes,
-    autoSubmit,
     annotationCategories
   };
 }
@@ -122,8 +116,7 @@
 function mapDispatchToProps(dispatch) {
   return {
     sessionsActions: bindActionCreators(sessionsActions, dispatch),
-    notesActions: bindActionCreators(notesActions, dispatch),
-    userActions: bindActionCreators(userActions, dispatch)
+    notesActions: bindActionCreators(notesActions, dispatch)
   }
 }
 
--- a/client/src/components/SlateEditor/AnnotationPlugin.js	Tue Nov 13 16:46:15 2018 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,53 +0,0 @@
-function AnnotationPlugin(options) {
-
-  const { onChange } = options
-
-  return {
-    onSelect(event, editor, next) {
-      event.preventDefault()
-
-      const { value } = editor
-      const { selection } = value
-      const { start, end} = selection
-
-      if (selection.isCollapsed) {
-        return next();
-      }
-
-      const nodes = [];
-      let hasStarted = false;
-      let hasEnded = false;
-
-      // Keep only the relevant nodes,
-      // i.e. nodes which are contained within selection
-      value.document.nodes.forEach((node) => {
-        if (start.isInNode(node)) {
-          hasStarted = true;
-        }
-        if (hasStarted && !hasEnded) {
-          nodes.push(node);
-        }
-        if (end.isAtEndOfNode(node)) {
-          hasEnded = true;
-        }
-      });
-
-      // Concatenate the nodes text
-      const text = nodes.map((node) => {
-        let textStart = start.isInNode(node) ? start.offset : 0;
-        let textEnd = end.isAtEndOfNode(node) ? end.offset : node.text.length;
-        return node.text.substring(textStart,textEnd);
-      }).join('\n');
-
-      if (onChange) {
-        onChange(text, start.offset, end.offset);
-      }
-
-      return next();
-
-    }
-
-  };
-}
-
-export default AnnotationPlugin;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/SlateEditor/BlockButton.js	Fri Nov 16 11:19:13 2018 +0100
@@ -0,0 +1,8 @@
+import React from 'react';
+import { withNamespaces } from 'react-i18next';
+
+export default withNamespaces("")(({icon, isActive, onMouseDown, t}) => (
+  <span className={"button sticky-top" + ((!isActive)?" text-primary":" text-dark")} onMouseDown={onMouseDown} data-active={isActive} title={t("common."+icon)} >
+    <span className="material-icons">{icon}</span>
+  </span>
+));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/SlateEditor/CategoryButton.js	Fri Nov 16 11:19:13 2018 +0100
@@ -0,0 +1,82 @@
+import React from 'react';
+import * as R from 'ramda';
+import { PortalWithState } from 'react-portal';
+import CategoriesTooltip from './CategoriesTooltip';
+import { defaultAnnotationsCategories } from '../../constants';
+
+/**
+ * Render a category toolbar button.
+ *
+ * @param {String} type
+ * @param {String} icon
+ * @return {Element}
+ */
+export default class CategoryButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.hoveringMenuRef = React.createRef();
+  }
+
+  get hoveringMenu() {
+    if(this.hoveringMenuRef) {
+      return this.hoveringMenuRef.current;
+    }
+    return null;
+  }
+
+  updateMenu = () => {
+
+    const hoveringMenu = this.hoveringMenu;
+
+    if (!hoveringMenu) 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 + rect.height + window.scrollY + hoveringMenu.offsetHeight}px`
+    hoveringMenu.style.left = `${rect.left + window.scrollX - hoveringMenu.offsetWidth / 2 + rect.width / 2}px`
+  }
+
+  render = () => {
+    const isActive = this.props.isActive;
+    const onClickCategoryButton = this.props.onClickCategoryButton;
+    const onCategoryClick = this.props.onCategoryClick;
+    const annotationCategories = this.props.annotationCategories;
+
+    const markActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
+
+    return (
+      <PortalWithState
+        // closeOnOutsideClick
+        closeOnEsc
+        onOpen={this.updateMenu}
+      >
+        {({ openPortal, closePortal, isOpen, portal }) => {
+          const onMouseDown = R.partial(onClickCategoryButton, [openPortal, closePortal, isOpen]);
+          const onCategoryClickHandler = R.partial(onCategoryClick, [closePortal,]);
+          return (
+            <React.Fragment>
+              <span className={markActivation} onMouseDown={onMouseDown} data-active={isActive}>
+                <span className="material-icons">label</span>
+              </span>
+              {portal(
+                <div className="hovering-menu" ref={this.hoveringMenuRef}>
+                  <CategoriesTooltip categories={annotationCategories || defaultAnnotationsCategories} onCategoryClick={onCategoryClickHandler} />
+                </div>
+              )}
+            </React.Fragment>
+          )}
+        }
+      </PortalWithState>
+    )
+  }
+}
--- a/client/src/components/SlateEditor/HtmlSerializer.js	Tue Nov 13 16:46:15 2018 +0100
+++ b/client/src/components/SlateEditor/HtmlSerializer.js	Fri Nov 16 11:19:13 2018 +0100
@@ -65,7 +65,7 @@
         case 'underlined':
           return <ins>{children}</ins>
         case 'category':
-          return <span style={{ backgroundColor: obj.color }}>{children}</span>
+          return <span style={{ backgroundColor: obj.data.get('color') }}>{children}</span>
         default: return;
       }
     }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/SlateEditor/MarkButton.js	Fri Nov 16 11:19:13 2018 +0100
@@ -0,0 +1,17 @@
+import React from 'react';
+import { withNamespaces } from 'react-i18next';
+
+/**
+ * Render a mark-toggling toolbar button.
+ *
+ * @param {String} type
+ * @param {String} icon
+ * @return {Element}
+ */
+export default withNamespaces("")(({icon, isActive, onMouseDown, t}) => (
+  <span className={"button sticky-top" + ((!isActive)?" text-primary":" text-dark")} onMouseDown={onMouseDown} data-active={isActive} title={t("common." + icon)} >
+
+    <span className="material-icons">{icon}</span>
+  </span>
+));
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/SlateEditor/Toolbar.js	Fri Nov 16 11:19:13 2018 +0100
@@ -0,0 +1,254 @@
+import React from 'react';
+import ToolbarButtons from './ToolbarButtons';
+import MarkButton from './MarkButton';
+import CategoryButton from './CategoryButton';
+import BlockButton from './BlockButton';
+
+/**
+ * Define the default node type.
+ */
+
+const DEFAULT_NODE = 'paragraph'
+
+
+/**
+ * Render the toolbar.
+ *
+ * @return {Element}
+ */
+export default class Toolbar extends React.Component {
+
+  /**
+   * Deserialize the initial editor state.
+   *
+   * @type {Object}
+   */
+  constructor(props) {
+    super(props);
+    this.editorRef = React.createRef();
+  }
+
+  /**
+   * Check if the current selection has a mark with `type` in it.
+   *
+   * @param {String} type
+   * @return {Boolean}
+   */
+
+  hasMark = type => {
+    const { value } = this.props;
+    return value.activeMarks.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 { value } = this.props;
+    return value.blocks.some(node => node.type === type)
+  }
+
+
+  /**
+   * When a mark button is clicked, toggle the current mark.
+   *
+   * @param {Event} e
+   * @param {String} type
+   */
+
+  onClickMark = (e, type) => {
+    this.props.editor.toggleMark(type)
+  }
+
+  isBlockActive = (type) => {
+    let isActive = this.hasBlock(type)
+
+    if (['numbered-list', 'bulleted-list'].includes(type)) {
+      const { value } = this.props;
+      const firstBlock = value.blocks.first();
+      if(firstBlock) {
+        const parent = value.document.getParent(firstBlock.key);
+        isActive = this.hasBlock('list-item') && parent && parent.type === type;
+      }
+    }
+
+    return isActive;
+  }
+
+  onClickCategoryButton = (openPortal, closePortal, isOpen, e) => {
+    e.preventDefault();
+    const { value, editor } = this.props;
+
+    // Can't use toggleMark here, because it expects the same object
+    // @see https://github.com/ianstormtaylor/slate/issues/873
+    if (this.hasMark('category')) {
+      value.activeMarks.filter(mark => mark.type === 'category')
+        .forEach(mark => editor.removeMark(mark));
+      closePortal();
+    } else {
+      openPortal();
+    }
+  }
+
+  getSelectionParams = (value) => {
+
+    const { selection } = value
+    const { start, end} = selection
+
+    if (selection.isCollapsed) {
+      return {};
+    }
+
+    const nodes = [];
+    let hasStarted = false;
+    let hasEnded = false;
+
+    // Keep only the relevant nodes,
+    // i.e. nodes which are contained within selection
+    value.document.nodes.forEach((node) => {
+      if (start.isInNode(node)) {
+        hasStarted = true;
+      }
+      if (hasStarted && !hasEnded) {
+        nodes.push(node);
+      }
+      if (end.isAtEndOfNode(node)) {
+        hasEnded = true;
+      }
+    });
+
+    // Concatenate the nodes text
+    const text = nodes.map((node) => {
+      let textStart = start.isInNode(node) ? start.offset : 0;
+      let textEnd = end.isInNode(node) ? end.offset : node.text.length;
+      return node.text.substring(textStart,textEnd);
+    }).join('\n');
+
+    return {
+      currentSelectionText: text,
+      currentSelectionStart: start.offset,
+      currentSelectionEnd: end.offset
+    };
+  }
+
+  onCategoryClick = (closePortal, category) => {
+
+    const { value, editor } = this.props;
+
+    const { currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.getSelectionParams(value);
+
+    if(!currentSelectionText) {
+      closePortal();
+      return;
+    }
+
+    const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category')
+    categoryMarks.forEach(mark => this.editor.removeMark(mark));
+
+    editor.addMark({
+      type: 'category',
+      data: {
+        text: currentSelectionText,
+        selection: {
+          start: currentSelectionStart,
+          end: currentSelectionEnd,
+        },
+        color: category.color,
+        key: category.key,
+        name: category.name,
+        comment: category.comment
+      }
+    })
+
+    closePortal();
+  }
+
+    /**
+   * When a block button is clicked, toggle the block type.
+   *
+   * @param {Event} e
+   * @param {String} type
+   */
+
+  onClickBlock = (e, type) => {
+    e.preventDefault();
+
+    const { editor, value } = this.props;
+
+    // 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) {
+        editor
+          .setBlocks(isActive ? DEFAULT_NODE : type)
+          .unwrapBlock('bulleted-list')
+          .unwrapBlock('numbered-list')
+      }
+
+      else {
+       editor
+          .setBlocks(isActive ? DEFAULT_NODE : type)
+      }
+    }
+
+    // Handle the extra wrapping required for list buttons.
+    else {
+      const isList = this.hasBlock('list-item')
+      const isType = value.blocks.some((block) => {
+        return !!document.getClosest(block.key, parent => parent.type === type)
+      })
+
+      if (isList && isType) {
+       editor
+          .setBlocks(DEFAULT_NODE)
+          .unwrapBlock('bulleted-list')
+          .unwrapBlock('numbered-list')
+
+      } else if (isList) {
+        editor
+          .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
+          .wrapBlock(type)
+
+      } else {
+        editor
+          .setBlocks('list-item')
+          .wrapBlock(type)
+
+      }
+    }
+  }
+
+
+  render = () => {
+    return (
+      <div className="menu toolbar-menu d-flex sticky-top bg-secondary">
+        <MarkButton icon='format_bold' isActive={this.hasMark('bold')} onMouseDown={(e) => this.onClickMark(e, 'bold')} />
+        <MarkButton icon='format_italic' isActive={this.hasMark('italic')} onMouseDown={(e) => this.onClickMark(e, 'italic')} />
+        <MarkButton icon='format_underlined' isActive={this.hasMark('underlined')} onMouseDown={(e) => this.onClickMark(e, 'underlined')} />
+
+        <CategoryButton
+          isActive={this.hasMark('category')}
+          onClickCategoryButton={this.onClickCategoryButton}
+          onCategoryClick={this.onCategoryClick}
+          annotationCategories={this.props.annotationCategories}
+        />
+
+        <BlockButton icon='format_list_numbered' isActive={this.isBlockActive('numbered-list')} onMouseDown={e => this.onClickBlock(e, 'numbered-list')} />
+        <BlockButton icon='format_list_bulleted' isActive={this.isBlockActive('bulleted-list')} onMouseDown={e => this.onClickBlock(e, 'bulleted-list')} />
+
+        <ToolbarButtons
+          hasNote={!!this.props.note}
+          isButtonDisabled={this.props.isButtonDisabled}
+          submitNote={this.props.submitNote}
+        />
+
+      </div>
+    )
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/SlateEditor/ToolbarButtons.js	Fri Nov 16 11:19:13 2018 +0100
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Trans } from 'react-i18next';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+
+import { getAutoSubmit } from '../../selectors/authSelectors';
+import * as userActions from '../../actions/userActions';
+
+function mapStateToProps(state, props) {
+
+  const autoSubmit = getAutoSubmit(state);
+
+  return {
+    autoSubmit,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return {
+    userActions: bindActionCreators(userActions, dispatch)
+  }
+}
+
+// see https://github.com/facebook/react/issues/3005 for explanation about the timeout.
+const ToolbarCheckbox = connect(mapStateToProps, mapDispatchToProps)(({ autoSubmit, userActions }) => (
+  <div className="checkbox float-right">
+    <label className="mr-2">
+      <input type="checkbox" checked={autoSubmit} onChange={(e) => { setTimeout(userActions.setAutoSubmit, 0, e.target.checked) }} value="enterBox" /><small className="text-muted ml-1"><Trans i18nKey="slate_editor.press_enter_msg">Appuyer sur <kbd className="bg-irinotes-form text-muted ml-1">Entrée</kbd> pour ajouter une note</Trans></small>
+    </label>
+  </div>
+));
+
+export default ({ hasNote, isButtonDisabled, submitNote }) => (
+  <div>
+    <button type="button" id="btn-editor" className="btn btn-primary btn-sm text-secondary font-weight-bold float-right text-capitalize" disabled={isButtonDisabled} onClick={submitNote}>
+      { hasNote ? <Trans i18nKey="common.save">Save</Trans> : <Trans i18nKey="common.add">Add</Trans> }
+    </button>
+    { !hasNote && <ToolbarCheckbox /> }
+  </div>
+);
+
--- a/client/src/components/SlateEditor/index.js	Tue Nov 13 16:46:15 2018 +0100
+++ b/client/src/components/SlateEditor/index.js	Fri Nov 16 11:19:13 2018 +0100
@@ -2,76 +2,14 @@
 import Plain from 'slate-plain-serializer';
 import { Editor } from 'slate-react';
 import React from 'react';
-import { PortalWithState } from 'react-portal';
-import { Trans, withNamespaces } from 'react-i18next';
+import { withNamespaces } from 'react-i18next';
+import { connect } from 'react-redux';
 import * as R from 'ramda';
 import HtmlSerializer from './HtmlSerializer';
-import AnnotationPlugin from './AnnotationPlugin';
-import CategoriesTooltip from './CategoriesTooltip';
 import './SlateEditor.css';
 import { now } from '../../utils';
-import { defaultAnnotationsCategories } from '../../constants';
-
-const plugins = [];
-
-/**
- * Define the default node type.
- */
-
-const DEFAULT_NODE = 'paragraph'
-
-/**
- * Define a schema.
- *
- * @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>,
-    'list-item': props => <li {...props.attributes}>{props.children}</li>,
-    'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>,
-  },
-  marks: {
-    bold: {
-      fontWeight: 'bold'
-    },
-    category: props => {
-      const data = props.mark.data;
-      return <span style={{ backgroundColor: data.color }} {...props.attributes}>{props.children}</span>
-    },
-    italic: {
-      fontStyle: 'italic'
-    },
-    underlined: {
-      textDecoration: 'underlined'
-    }
-  }
-
-}
-
-const initialValue = Value.fromJSON({
-  document: {
-    nodes: [
-      {
-        object: 'block',
-        type: 'paragraph',
-        nodes: [
-          {
-            object: 'text',
-            leaves: [
-              {
-                text: '',
-              },
-            ],
-          },
-        ],
-      },
-    ],
-  },
-})
+import Toolbar from './Toolbar';
+import { getAutoSubmit } from '../../selectors/authSelectors';
 
 
 /**
@@ -89,35 +27,14 @@
   constructor(props) {
     super(props);
 
-    const annotationPlugin = AnnotationPlugin({
-      onChange: (text, start, end) => {
-        this.setState({
-          currentSelectionText: text,
-          currentSelectionStart: start,
-          currentSelectionEnd: end,
-        });
-      }
-    });
-
-    // plugins.push(annotationPlugin);
-
-
     this.state = {
-      value: props.note ? Value.fromJSON(initialValue) : Plain.deserialize(''),
+      value: props.note ? Value.fromJSON(JSON.parse(props.note.raw)) : Plain.deserialize(''),
       startedAt: null,
       finishedAt: null,
-      currentSelectionText: '',
-      currentSelectionStart: 0,
-      currentSelectionEnd: 0,
-      hoveringMenu: null,
-      isPortalOpen: false,
-      categories: [],
-      isCheckboxChecked: false,
       enterKeyValue: 0,
     };
 
     this.editorRef = React.createRef();
-    this.hoveringMenuRef = React.createRef();
   }
 
   get editor() {
@@ -128,107 +45,42 @@
   }
 
   componentDidMount = () => {
-    this.updateMenu();
     this.focus();
   }
 
-  componentDidUpdate = () => {
-    this.updateMenu();
-  }
-
-  getDocumentLength = (document) => {
-    return document.getBlocks().reduce((l, b) => l + b.text.length, 0)
-  }
-
    /**
    * On change, save the new state.
    *
    * @param {Change} change
    */
 
-  onChange = (change) => {
-
-    const operationTypes = (change && change.operations) ? change.operations.map((o) => o.type).toArray() : [];
-    console.log("CHANGE", change, operationTypes);
-    const { value } = change;
-
-    let newState = {
-      value: value,
-      startedAt: this.state.startedAt
-    };
-
-    const isEmpty = this.getDocumentLength(value.document) === 0;
-
-    // Reset timers when the text is empty
-    if (isEmpty) {
-      Object.assign(newState, {
-        startedAt: null,
-        finishedAt: null
-      });
-    } else {
-      Object.assign(newState, { finishedAt: now() });
-    }
-
-    // Store start time once when the first character is typed
-    if (!isEmpty && this.state.startedAt === null) {
-      Object.assign(newState, { startedAt: now() });
-    }
+  onChange = ({value, operations}) => {
 
     const oldState = R.clone(this.state);
 
-    const categories = value.marks.reduce((acc, mark) => {
-      if(mark.type === 'category') {
-        acc.push({
-          key: mark.data.get('key'),
-          name: mark.data.get('name'),
-          color: mark.data.get('color'),
-          text: mark.data.get('text'),
-          selection: {
-            start: mark.data.get('selection').start,
-            end: mark.data.get('selection').end,
-          },
-          comment: mark.data.get('comment')
-        })
+    const newState = {
+      value
+    };
+
+    (operations || []).some((op) => {
+      if(['insert_text', 'remove_text', 'add_mark', 'remove_mark', 'set_mark', 'insert_node', 'merge_node', 'move_node', 'remove_node', 'set_node', 'split_node'].indexOf(op.type)>=0) {
+        const tsnow = now();
+        if(this.state.startedAt == null) {
+          newState.startedAt = tsnow;
+        }
+        newState.finishedAt = tsnow;
+        return true;
       }
-      return acc;
-    },
-    []);
-
-    console.log("ON CHANGE categorie", categories);
-
-    newState['categories'] = categories;
+      return false;
+    });
 
     this.setState(newState, () => {
       if (typeof this.props.onChange === 'function') {
-        this.props.onChange(R.clone(this.state), oldState, newState);
+        this.props.onChange(R.clone(this.state), oldState, {value, operations});
       }
     })
-
   }
 
-  /**
-   * Check if the current selection has a mark with `type` in it.
-   *
-   * @param {String} type
-   * @return {Boolean}
-   */
-
-  hasMark = type => {
-    const { value } = this.state;
-    return value.activeMarks.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 { value } = this.state
-    return value.blocks.some(node => node.type === type)
-  }
 
   asPlain = () => {
     return Plain.serialize(this.state.value);
@@ -243,19 +95,15 @@
   }
 
   asCategories = () => {
-    return this.state.categories
-  }
-
-  removeCategory = (categories, key, text) => {
-    const categoryIndex = categories.findIndex(category => category.key === key && category.text === text)
-    return categories.delete(categoryIndex)
+    return this.state.value.document.getMarksByType('category').map((mark) => mark.data.toJS()).toArray();
   }
 
   clear = () => {
     const value = Plain.deserialize('');
-    this.onChange({
+    this.setState({
       value,
-    });
+      enterKeyValue: 0
+    })
   }
 
   focus = () => {
@@ -264,225 +112,33 @@
     }
   }
 
-  onClickCategoryButton = (openPortal, closePortal, isOpen, e) => {
-    e.preventDefault();
-    const { categories, value } = this.state
-
-    let newCategories = categories.slice(0);
-
-    // Can't use toggleMark here, because it expects the same object
-    // @see https://github.com/ianstormtaylor/slate/issues/873
-    if (this.hasMark('category')) {
-      const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category')
-      categoryMarks.forEach(mark => {
-        const key = mark.data.get('key');
-        const text = mark.data.get('text');
-
-        newCategories = R.reject(category => category.key === key && category.text === text, newCategories);
-        this.editor.removeMark(mark)
-      })
-      this.setState({
-        value: this.editor.value,
-        categories: newCategories
-      });
-      closePortal();
-    } else {
-      openPortal();
-    }
-    // } else {
-    //   isOpen ? closePortal() : openPortal();
-    // }
-  }
-
-  /**
-   * When a mark button is clicked, toggle the current mark.
-   *
-   * @param {Event} e
-   * @param {String} type
-   */
-
-  onClickMark = (e, type) => {
-    this.editor.toggleMark(type)
+  submitNote = () => {
+    this.setState({ enterKeyValue: 0 }, () => {
+      if (typeof this.props.submitNote === 'function') {
+        this.props.submitNote();
+      }
+    });
   }
 
   /**
-   * When a block button is clicked, toggle the block type.
+   * On key down, if it's a formatting command toggle a mark.
    *
    * @param {Event} e
-   * @param {String} type
+   * @param {Change} change
+   * @return {Change}
    */
 
-  onClickBlock = (e, type) => {
-    e.preventDefault()
-
-    const { editor } = this;
-    const { value } = editor;
-    const { document } = value;
-
-    // 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) {
-        editor
-          .setBlocks(isActive ? DEFAULT_NODE : type)
-          .unwrapBlock('bulleted-list')
-          .unwrapBlock('numbered-list')
-      }
-
-      else {
-       editor
-          .setBlocks(isActive ? DEFAULT_NODE : type)
-      }
-    }
-
-    // Handle the extra wrapping required for list buttons.
-    else {
-      const isList = this.hasBlock('list-item')
-      const isType = value.blocks.some((block) => {
-        return !!document.getClosest(block.key, parent => parent.type === type)
-      })
-
-      if (isList && isType) {
-       editor
-          .setBlocks(DEFAULT_NODE)
-          .unwrapBlock('bulleted-list')
-          .unwrapBlock('numbered-list')
+  onKeyUp = (e, editor, next) => {
 
-      } else if (isList) {
-        editor
-          .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
-          .wrapBlock(type)
-
-      } else {
-        editor
-          .setBlocks('list-item')
-          .wrapBlock(type)
-
-      }
-    }
-    // this.onChange(change)
-  }
-
-  onPortalOpen = () => {
-    console.log("onPORTAL OPEN", this);
-    this.updateMenu();
-    // When the portal opens, cache the menu element.
-    // this.setState({ hoveringMenu: this.portal.firstChild })
-  }
-
-  onPortalClose = (portal) => {
-    console.log("onPORTAL CLOSE", this);
-    // let { value } = this.state
-
-    // this.setState({
-    //   value: value.change,
-    //   isPortalOpen: false
-    // })
-  }
-
-  getSelectionParams = () => {
-
-    const { value } = this.editor
-    const { selection } = value
-    const { start, end} = selection
-
-    if (selection.isCollapsed) {
-      return {};
-    }
-
-    const nodes = [];
-    let hasStarted = false;
-    let hasEnded = false;
+    const { value } = this.state;
+    const noteText = value.document.text.trim();
 
-    // Keep only the relevant nodes,
-    // i.e. nodes which are contained within selection
-    value.document.nodes.forEach((node) => {
-      if (start.isInNode(node)) {
-        hasStarted = true;
-      }
-      if (hasStarted && !hasEnded) {
-        nodes.push(node);
-      }
-      if (end.isAtEndOfNode(node)) {
-        hasEnded = true;
-      }
-    });
-
-    // Concatenate the nodes text
-    const text = nodes.map((node) => {
-      let textStart = start.isInNode(node) ? start.offset : 0;
-      let textEnd = end.isInNode(node) ? end.offset : node.text.length;
-      return node.text.substring(textStart,textEnd);
-    }).join('\n');
-
-    return {
-      currentSelectionText: text,
-      currentSelectionStart: start.offset,
-      currentSelectionEnd: end.offset
-    };
-  }
-
-  onCategoryClick = (closePortal, category) => {
-
-    console.log("ON CATEGORY CLICK");
-    const { value } = this.state;
-    let { categories } = this.state;
-
-    const { currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.getSelectionParams();
-
-    if(!currentSelectionText) {
-      closePortal();
-      return;
+    if(e.key === "Enter" && noteText.length !== 0) {
+      this.setState({ enterKeyValue: this.state.enterKeyValue + 1 });
+    } else if ( e.getModifierState() || (e.key !== "Control" && e.key !== "Shift" && e.key !== "Meta" && e.key !== "Alt") ) {
+      this.setState({ enterKeyValue: 0 });
     }
-    console.log("ACTIVE MARKS", category, currentSelectionText, currentSelectionStart, currentSelectionEnd)
-
-    const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category')
-    categoryMarks.forEach(mark => this.editor.removeMark(mark));
-
-    this.editor.addMark({
-      type: 'category',
-      data: {
-        text: currentSelectionText,
-        selection: {
-          start: currentSelectionStart,
-          end: currentSelectionEnd,
-        },
-        color: category.color,
-        key: category.key,
-        name: category.name,
-        comment: category.comment
-      }
-    })
-
-    Object.assign(category, {
-      text: currentSelectionText,
-      selection: {
-        start: currentSelectionStart,
-        end: currentSelectionEnd,
-      },
-    });
-    categories.push(category);
-
-    console.log("CATEGORIES", categories)
-
-    this.setState({
-      categories: categories,
-      value: this.editor.value
-    }, closePortal);
-  }
-
-  onButtonClick = () => {
-    if (typeof this.props.onButtonClick === 'function') {
-      this.props.onButtonClick();
-    }
-  }
-
-  onCheckboxChange = (e) => {
-    if (typeof this.props.onCheckboxChange === 'function') {
-      this.props.onCheckboxChange(e);
-    }
+    return next();
   }
 
   /**
@@ -493,205 +149,55 @@
    * @return {Change}
    */
 
-  onKeyDown = (e, change) => {
-
-    const {value} = this.state;
+  onKeyDown = (e, editor, next) => {
 
-    if (e.key === 'Enter' && value.document.text !== '') {
-      this.setState({enterKeyValue: 1})
-    }
+    const { value, enterKeyValue } = this.state;
+    const { autoSubmit } = this.props;
+    const noteText = value.document.text.trim();
 
-    if (e.key !== 'Enter') {
-      this.setState({
-        enterKeyValue: 0,
-      })
-
+    // we prevent empty first lines
+    if(e.key === "Enter" && noteText.length === 0) {
+      e.preventDefault();
+      return next();
     }
 
-    //TODO review the double enter case.
-    if (e.key === 'Enter' && !this.props.isChecked && this.state.enterKeyValue === 1 && typeof this.props.onEnterKeyDown === 'function') {
+    // Enter submit the note
+    if(e.key === "Enter" && ( enterKeyValue === 2 || e.ctrlKey || autoSubmit ) && noteText.length !== 0) {
       e.preventDefault();
-      this.props.onEnterKeyDown();
-      this.setState({
-        enterKeyValue: 0,
-      })
-
-
-      return change
+      this.submitNote();
+      return next();
     }
 
-    else if (e.key === 'Enter' && value.document.text !== '' && this.props.isChecked && typeof this.props.onEnterKeyDown === 'function') {
-
-      e.preventDefault();
-      this.props.onEnterKeyDown();
-
-      return change
+    if (!e.ctrlKey) {
+      return next();
     }
 
-    if (!e.ctrlKey) return
-        // Decide what to do based on the key code...
-        switch (e.key) {
-          default: {
-            break;
-          }
-          // When "B" is pressed, add a "bold" mark to the text.
-          case 'b': {
-            e.preventDefault()
-            change.toggleMark('bold')
-
-            return true
-          }
-          case 'i': {
-            // When "U" is pressed, add an "italic" mark to the text.
-            e.preventDefault()
-            change.toggleMark('italic')
-
-            return true
-          }
-          case 'u': {
-            // When "U" is pressed, add an "underline" mark to the text.
-            e.preventDefault()
-            change.toggleMark('underlined')
-
-            return true
-          }
-          case 'Enter': {
-            // When "ENTER" is pressed, autosubmit the note.
-            if (value.document.text !== '' && typeof this.props.onEnterKeyDown === 'function') {
-              e.preventDefault()
-              this.props.onEnterKeyDown();
-              this.setState({
-                enterKeyValue: 0,
-              })
-
-              return true
-            }
-        }
-      }
-  }
-
-  /**
-   * Render.
-   *
-   * @return {Element}
-   */
-
-  render = () => {
-    return (
-      <div className="bg-secondary mb-5">
-        <div className="sticky-top">
-        {this.renderToolbar()}
-        </div>
-        {this.renderEditor()}
-    </div>
-    )
-  }
-
-  /**
-   * Render the toolbar.
-   *
-   * @return {Element}
-   */
-
-  renderToolbar = () => {
-    return (
-      <div className="menu toolbar-menu d-flex sticky-top bg-secondary">
-          {this.renderMarkButton('bold', 'format_bold')}
-          {this.renderMarkButton('italic', 'format_italic')}
-          {this.renderMarkButton('underlined', 'format_underlined')}
-          {this.renderCategoryButton()}
-
-          {this.renderBlockButton('numbered-list', 'format_list_numbered')}
-          {this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
-
-          {this.renderToolbarButtons()}
-      </div>
-    )
-  }
+    e.preventDefault();
 
-  renderToolbarCheckbox = () => {
-    return (
-      <div className="checkbox float-right">
-        <label className="mr-2">
-          <input type="checkbox" checked={this.props.isChecked} onChange={this.onCheckboxChange} /><small className="text-muted ml-1"><Trans i18nKey="slate_editor.press_enter_msg">Appuyer sur <kbd className="bg-irinotes-form text-muted ml-1">Entrée</kbd> pour ajouter une note</Trans></small>
-        </label>
-      </div>
-    )
-  }
-
-  renderToolbarButtons = () => {
-    const t = this.props.t;
-    return (
-      <div>
-        <button type="button" id="btn-editor" className="btn btn-primary btn-sm text-secondary font-weight-bold float-right text-capitalize" disabled={this.props.isButtonDisabled} onClick={this.onButtonClick}>
-          { this.props.note ? t('common.save') : t('common.add') }
-        </button>
-        { !this.props.note && this.renderToolbarCheckbox() }
-      </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)
-    const markActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
-
-    return (
-      // <span className="button text-primary" onMouseDown={onMouseDown} data-active={isActive}>
-      <span className={markActivation} onMouseDown={onMouseDown} data-active={isActive}>
+    // Decide what to do based on the key code...
+    switch (e.key) {
+      default: {
+        break;
+      }
+      // When "B" is pressed, add a "bold" mark to the text.
+      case 'b': {
+        editor.toggleMark('bold');
+        break;
+      }
+      case 'i': {
+        // When "U" is pressed, add an "italic" mark to the text.
+        editor.toggleMark('italic');
+        break;
+      }
+      case 'u': {
+        // When "U" is pressed, add an "underline" mark to the text.
+        editor.toggleMark('underlined');
+        break;
+      }
+    }
 
-        <span className="material-icons">{icon}</span>
-      </span>
-    )
-  }
-
-    /**
-   * Render a mark-toggling toolbar button.
-   *
-   * @param {String} type
-   * @param {String} icon
-   * @return {Element}
-   */
-
-  renderCategoryButton = () => {
-    const isActive = this.hasMark('category');
-    //const onMouseDown = e => this.onClickMark(e, type)
-    const markActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
+    return next();
 
-    return (
-      <PortalWithState
-        // closeOnOutsideClick
-        closeOnEsc
-        onOpen={this.onPortalOpen}
-        onClose={this.onPortalClose}
-      >
-        {({ openPortal, closePortal, isOpen, portal }) => {
-          console.log("PORTAL", isOpen);
-          const onMouseDown = R.partial(this.onClickCategoryButton, [openPortal, closePortal, isOpen]);
-          const onCategoryClick = R.partial(this.onCategoryClick, [closePortal,]);
-          return (
-            <React.Fragment>
-              <span className={markActivation} onMouseDown={onMouseDown} data-active={isActive}>
-                <span className="material-icons">label</span>
-              </span>
-              {portal(
-                <div className="hovering-menu" ref={this.hoveringMenuRef}>
-                  <CategoriesTooltip categories={this.props.annotationCategories || defaultAnnotationsCategories} onCategoryClick={onCategoryClick} />
-                </div>
-              )}
-            </React.Fragment>
-          )}
-        }
-      </PortalWithState>
-    )
   }
 
   // Add a `renderMark` method to render marks.
@@ -699,7 +205,6 @@
   renderMark = (props, editor, next) => {
     const { children, mark, attributes } = props
 
-    console.log("renderMark", mark, mark.type, mark.data.color);
     switch (mark.type) {
       case 'bold':
         return <strong {...attributes}>{children}</strong>
@@ -718,34 +223,6 @@
         return next();
     }
   }
-  /**
-   * Render a block-toggling toolbar button.
-   *
-   * @param {String} type
-   * @param {String} icon
-   * @return {Element}
-   */
-
-  renderBlockButton = (type, icon) => {
-    let isActive = this.hasBlock(type)
-
-    if (['numbered-list', 'bulleted-list'].includes(type)) {
-      const { value } = this.state;
-      const firstBlock = value.blocks.first();
-      if(firstBlock) {
-        const parent = value.document.getParent(firstBlock.key);
-        isActive = this.hasBlock('list-item') && parent && parent.type === type;
-      }
-    }
-    const onMouseDown = e => this.onClickBlock(e, type)
-    const blockActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
-
-    return (
-      <span className={blockActivation} onMouseDown={onMouseDown} data-active={isActive}>
-        <span className="material-icons">{icon}</span>
-      </span>
-    )
-  }
 
   renderNode = (props, editor, next) => {
     const { attributes, children, node } = props
@@ -766,87 +243,66 @@
       default:
         return next();
     }
-}
+  }
 
-  /**
-   * Render the Slate editor.
+    /**
+   * Render.
    *
    * @return {Element}
    */
 
-  renderEditor = () => {
-    const t = this.props.t;
-    return (
+  render = () => (
+    <div className="bg-secondary mb-5">
+      <div className="sticky-top">
+        <Toolbar
+          value={this.state.value}
+          editor={this.editor}
+          note={this.props.note}
+          annotationCategories={this.props.annotationCategories}
+          isButtonDisabled={this.props.isButtonDisabled}
+          submitNote={this.submitNote}
+        />
+      </div>
       <div className="editor-slatejs p-2">
-        {/* {this.renderHoveringMenu()} */}
         <Editor
           ref={this.editorRef}
           spellCheck
-          placeholder={t('slate_editor.placeholder')}
-          // schema={schema}
-          plugins={plugins}
+          placeholder={this.props.t('slate_editor.placeholder')}
           value={this.state.value}
           onChange={this.onChange}
-          // onKeyDown={this.onKeyDown}
+          onKeyDown={this.onKeyDown}
+          onKeyUp={this.onKeyUp}
           renderMark={this.renderMark}
           renderNode = {this.renderNode}
         />
       </div>
-    )
-  }
-
-  // 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={this.props.annotationCategories || defaultAnnotationsCategories} onCategoryClick={this.onCategoryClick} />
-  //       </div>
-  //     </Portal>
-  //   )
-  // }
-
-  updateMenu = () => {
+    </div>
+  );
 
-    // const { hoveringMenu } = this.state
-    const hoveringMenu = this.hoveringMenuRef.current;
-
-    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 + rect.height + window.scrollY + hoveringMenu.offsetHeight}px`
-    hoveringMenu.style.left = `${rect.left + window.scrollX - hoveringMenu.offsetWidth / 2 + rect.width / 2}px`
-  }
 
 }
 
 /**
  * Export.
  */
+function mapStateToProps(state, props) {
+
+  const autoSubmit = getAutoSubmit(state);
+
+  return {
+    autoSubmit,
+  };
+}
 
 export default withNamespaces("", {
   innerRef: (ref) => {
-    const editorRef = (ref && ref.props) ? ref.props.editorRef : null;
+    if(!ref) {
+      return;
+    }
+    const wrappedRef = ref.getWrappedInstance();
+    const editorRef = (wrappedRef && wrappedRef.props) ? wrappedRef.props.editorRef : null;
     if(editorRef && editorRef.hasOwnProperty('current')) {
-      editorRef.current = ref;
+      editorRef.current = wrappedRef;
     }
   }
-})(SlateEditor);
-// export default SlateEditor;
+})(connect(mapStateToProps, null, null, { withRef: true })(SlateEditor));
--- a/client/src/locales/en/translation.json	Tue Nov 13 16:46:15 2018 +0100
+++ b/client/src/locales/en/translation.json	Fri Nov 16 11:19:13 2018 +0100
@@ -47,6 +47,12 @@
         "create": "create",
         "cancel": "cancel",
         "add": "add",
-        "save": "save"
+        "save": "save",
+        "format_bold": "bold",
+        "format_italic": "italic",
+        "format_underlined": "underlined",
+        "format_category": "meta-categories",
+        "format_list_numbered": "numbered list",
+        "format_list_bulleted": "bulleted list"
     }
 }
--- a/client/src/locales/fr/translation.json	Tue Nov 13 16:46:15 2018 +0100
+++ b/client/src/locales/fr/translation.json	Fri Nov 16 11:19:13 2018 +0100
@@ -47,6 +47,13 @@
         "create": "créer",
         "cancel": "annuler",
         "add": "ajouter",
-        "save": "sauvegarder"
+        "save": "sauvegarder",
+        "format_bold": "gras",
+        "format_italic": "italique",
+        "format_underlined": "souligné",
+        "format_category": "meta-catégories",
+        "format_list_numbered": "liste numérotée",
+        "format_list_bulleted": "liste à puce"
+
     }
 }