client/src/components/SlateEditor/index.js
changeset 172 4b780ebbedc6
parent 171 03334a31130a
child 173 0e6703cd0968
--- a/client/src/components/SlateEditor/index.js	Thu Nov 08 16:03:28 2018 +0100
+++ b/client/src/components/SlateEditor/index.js	Tue Nov 13 16:46:15 2018 +0100
@@ -2,7 +2,7 @@
 import Plain from 'slate-plain-serializer';
 import { Editor } from 'slate-react';
 import React from 'react';
-import { Portal } from 'react-portal';
+import { PortalWithState } from 'react-portal';
 import { Trans, withNamespaces } from 'react-i18next';
 import * as R from 'ramda';
 import HtmlSerializer from './HtmlSerializer';
@@ -40,7 +40,7 @@
     },
     category: props => {
       const data = props.mark.data;
-      return <span style={{ backgroundColor: data.color }}>{props.children}</span>
+      return <span style={{ backgroundColor: data.color }} {...props.attributes}>{props.children}</span>
     },
     italic: {
       fontStyle: 'italic'
@@ -75,7 +75,6 @@
 
 
 /**
- * The rich text example.
  *
  * @type {Component}
  */
@@ -100,7 +99,7 @@
       }
     });
 
-    plugins.push(annotationPlugin);
+    // plugins.push(annotationPlugin);
 
 
     this.state = {
@@ -117,7 +116,15 @@
       enterKeyValue: 0,
     };
 
-    this.editor = React.createRef();
+    this.editorRef = React.createRef();
+    this.hoveringMenuRef = React.createRef();
+  }
+
+  get editor() {
+    if(this.editorRef) {
+      return this.editorRef.current;
+    }
+    return null;
   }
 
   componentDidMount = () => {
@@ -129,20 +136,28 @@
     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 = ({value}) => {
+  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 = value.document.length === 0;
+    const isEmpty = this.getDocumentLength(value.document) === 0;
 
     // Reset timers when the text is empty
     if (isEmpty) {
@@ -160,11 +175,35 @@
     }
 
     const oldState = R.clone(this.state);
-    this.setState(newState)
 
-    if (typeof this.props.onChange === 'function') {
-      this.props.onChange(R.clone(this.state), oldState, newState);
-    }
+    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')
+        })
+      }
+      return acc;
+    },
+    []);
+
+    console.log("ON CHANGE categorie", categories);
+
+    newState['categories'] = categories;
+
+    this.setState(newState, () => {
+      if (typeof this.props.onChange === 'function') {
+        this.props.onChange(R.clone(this.state), oldState, newState);
+      }
+    })
+
   }
 
   /**
@@ -175,8 +214,8 @@
    */
 
   hasMark = type => {
-    const { value } = this.state
-    return value.activeMarks.some(mark => mark.type === type)
+    const { value } = this.state;
+    return value.activeMarks.some(mark => mark.type === type);
   }
 
   /**
@@ -214,15 +253,47 @@
 
   clear = () => {
     const value = Plain.deserialize('');
-    this.onChange({value});
+    this.onChange({
+      value,
+    });
   }
 
   focus = () => {
-    if(this.editor.current) {
-      this.editor.current.focus();
+    if(this.editor) {
+      this.editor.focus();
     }
   }
 
+  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.
    *
@@ -231,40 +302,7 @@
    */
 
   onClickMark = (e, type) => {
-
-    e.preventDefault()
-    const { value } = this.state
-    let { categories } = this.state
-
-    let isPortalOpen = false;
-
-    if (type === 'category') {
-      // 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.key;
-          const text = mark.data.text;
-
-          categories = this.removeCategory(categories, key, text)
-          const change = value.change().removeMark(mark)
-          this.onChange(change)
-        })
-
-      } else {
-        isPortalOpen = !this.state.isPortalOpen;
-      }
-    } else {
-      const change = value.change().toggleMark(type)
-      this.onChange(change)
-    }
-
-    this.setState({
-      state: value.change,
-      isPortalOpen: isPortalOpen,
-      categories: categories
-    })
+    this.editor.toggleMark(type)
   }
 
   /**
@@ -276,9 +314,10 @@
 
   onClickBlock = (e, type) => {
     e.preventDefault()
-    const { value } = this.state
-    const change = value.change()
-    const { document } = value
+
+    const { editor } = this;
+    const { value } = editor;
+    const { document } = value;
 
     // Handle everything but list buttons.
     if (type !== 'bulleted-list' && type !== 'numbered-list') {
@@ -286,14 +325,14 @@
       const isList = this.hasBlock('list-item')
 
       if (isList) {
-        change
+        editor
           .setBlocks(isActive ? DEFAULT_NODE : type)
           .unwrapBlock('bulleted-list')
           .unwrapBlock('numbered-list')
       }
 
       else {
-       change
+       editor
           .setBlocks(isActive ? DEFAULT_NODE : type)
       }
     }
@@ -306,52 +345,103 @@
       })
 
       if (isList && isType) {
-       change
+       editor
           .setBlocks(DEFAULT_NODE)
           .unwrapBlock('bulleted-list')
           .unwrapBlock('numbered-list')
 
       } else if (isList) {
-        change
+        editor
           .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
           .wrapBlock(type)
 
       } else {
-        change
+        editor
           .setBlocks('list-item')
           .wrapBlock(type)
 
       }
     }
-
-
-    this.onChange(change)
+    // this.onChange(change)
   }
 
-  onPortalOpen = (portal) => {
+  onPortalOpen = () => {
+    console.log("onPORTAL OPEN", this);
+    this.updateMenu();
     // When the portal opens, cache the menu element.
-    this.setState({ hoveringMenu: portal.firstChild })
+    // this.setState({ hoveringMenu: this.portal.firstChild })
   }
 
   onPortalClose = (portal) => {
-    let { value } = this.state
+    console.log("onPORTAL CLOSE", this);
+    // let { value } = this.state
 
-    this.setState({
-      value: value.change,
-      isPortalOpen: false
-    })
+    // this.setState({
+    //   value: value.change,
+    //   isPortalOpen: false
+    // })
   }
 
-  onCategoryClick = (category) => {
+  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, currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.state;
-    const change = value.change()
+    // 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;
+    }
+    console.log("ACTIVE MARKS", category, currentSelectionText, currentSelectionStart, currentSelectionEnd)
+
     const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category')
-    categoryMarks.forEach(mark => change.removeMark(mark));
+    categoryMarks.forEach(mark => this.editor.removeMark(mark));
 
-    change.addMark({
+    this.editor.addMark({
       type: 'category',
       data: {
         text: currentSelectionText,
@@ -360,7 +450,9 @@
           end: currentSelectionEnd,
         },
         color: category.color,
-        key: category.key
+        key: category.key,
+        name: category.name,
+        comment: category.comment
       }
     })
 
@@ -371,15 +463,14 @@
         end: currentSelectionEnd,
       },
     });
-    categories = categories.push(category);
+    categories.push(category);
 
-    this.onChange(change)
+    console.log("CATEGORIES", categories)
 
     this.setState({
-      value: value,
-      isPortalOpen: false,
-      categories: categories
-    });
+      categories: categories,
+      value: this.editor.value
+    }, closePortal);
   }
 
   onButtonClick = () => {
@@ -508,8 +599,7 @@
           {this.renderMarkButton('bold', 'format_bold')}
           {this.renderMarkButton('italic', 'format_italic')}
           {this.renderMarkButton('underlined', 'format_underlined')}
-          {this.renderMarkButton('category', 'label')}
-
+          {this.renderCategoryButton()}
 
           {this.renderBlockButton('numbered-list', 'format_list_numbered')}
           {this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
@@ -563,23 +653,70 @@
     )
   }
 
-    // Add a `renderMark` method to render marks.
+    /**
+   * Render a mark-toggling toolbar button.
+   *
+   * @param {String} type
+   * @param {String} icon
+   * @return {Element}
+   */
 
-    renderMark = props => {
-      const { children, mark, attributes } = props
+  renderCategoryButton = () => {
+    const isActive = this.hasMark('category');
+    //const onMouseDown = e => this.onClickMark(e, type)
+    const markActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
 
-      switch (mark.type) {
-        case 'bold':
-          return <strong {...attributes}>{children}</strong>
-        case 'code':
-          return <code {...attributes}>{children}</code>
-        case 'italic':
-          return <em {...attributes}>{children}</em>
-        case 'underlined':
-          return <ins {...attributes}>{children}</ins>
-        default:
-          return {children};
-      }
+    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.
+
+  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>
+      case 'code':
+        return <code {...attributes}>{children}</code>
+      case 'italic':
+        return <em {...attributes}>{children}</em>
+      case 'underlined':
+        return <ins {...attributes}>{children}</ins>
+      case 'category':
+        let spanStyle = {
+          backgroundColor: mark.data.get('color')
+        };
+        return <span {...attributes} style={ spanStyle } >{children}</span>
+      default:
+        return next();
+    }
   }
   /**
    * Render a block-toggling toolbar button.
@@ -593,9 +730,12 @@
     let isActive = this.hasBlock(type)
 
     if (['numbered-list', 'bulleted-list'].includes(type)) {
-      const { value } = this.state
-      const parent = value.document.getParent(value.blocks.first().key)
-      isActive = this.hasBlock('list-item') && parent && parent.type === 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");
@@ -607,7 +747,7 @@
     )
   }
 
-  renderNode = props => {
+  renderNode = (props, editor, next) => {
     const { attributes, children, node } = props
 
     switch (node.type) {
@@ -624,7 +764,7 @@
       case 'numbered-list':
         return <ol {...attributes}>{children}</ol>
       default:
-        return null;
+        return next();
     }
 }
 
@@ -638,16 +778,16 @@
     const t = this.props.t;
     return (
       <div className="editor-slatejs p-2">
-        {this.renderHoveringMenu()}
+        {/* {this.renderHoveringMenu()} */}
         <Editor
-          ref={this.editor}
+          ref={this.editorRef}
           spellCheck
           placeholder={t('slate_editor.placeholder')}
-          schema={schema}
+          // schema={schema}
           plugins={plugins}
           value={this.state.value}
           onChange={this.onChange}
-          onKeyDown={this.onKeyDown}
+          // onKeyDown={this.onKeyDown}
           renderMark={this.renderMark}
           renderNode = {this.renderNode}
         />
@@ -655,23 +795,24 @@
     )
   }
 
-  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>
-    )
-  }
+  // 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 = () => {
 
-    const { hoveringMenu } = this.state
+    // const { hoveringMenu } = this.state
+    const hoveringMenu = this.hoveringMenuRef.current;
 
     if (!hoveringMenu) return
 
@@ -690,7 +831,7 @@
     const rect = range.getBoundingClientRect()
 
     hoveringMenu.style.opacity = 1
-    hoveringMenu.style.top = `${rect.top + window.scrollY + hoveringMenu.offsetHeight}px`
+    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`
   }