client/src/components/SlateEditor/index.js
changeset 168 ea92f4fe783d
parent 161 a642639dbc07
child 169 f98efa1bddd1
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/SlateEditor/index.js	Mon Oct 08 18:35:47 2018 +0200
@@ -0,0 +1,697 @@
+import { Value } from 'slate'
+import Plain from 'slate-plain-serializer'
+import { Editor } from 'slate-react'
+import React from 'react'
+import { Portal } from 'react-portal'
+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.children}</span>
+    },
+    italic: {
+      fontStyle: 'italic'
+    },
+    underlined: {
+      textDecoration: 'underlined'
+    }
+  }
+
+}
+
+const initialValue = Value.fromJSON({
+  document: {
+    nodes: [
+      {
+        object: 'block',
+        type: 'paragraph',
+        nodes: [
+          {
+            object: 'text',
+            leaves: [
+              {
+                text: '',
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  },
+})
+
+
+/**
+ * The rich text example.
+ *
+ * @type {Component}
+ */
+
+class SlateEditor extends React.Component {
+
+  /**
+   * Deserialize the initial editor state.
+   *
+   * @type {Object}
+   */
+  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(''),
+      startedAt: null,
+      finishedAt: null,
+      currentSelectionText: '',
+      currentSelectionStart: 0,
+      currentSelectionEnd: 0,
+      hoveringMenu: null,
+      isPortalOpen: false,
+      categories: [],
+      isCheckboxChecked: false,
+      enterKeyValue: 0,
+    };
+  }
+
+  componentDidMount = () => {
+    this.updateMenu();
+    this.focus();
+  }
+
+  componentDidUpdate = () => {
+    this.updateMenu();
+  }
+
+   /**
+   * On change, save the new state.
+   *
+   * @param {Change} change
+   */
+
+  onChange = ({value}) => {
+
+    let newState = {
+      value: value,
+      startedAt: this.state.startedAt
+    };
+
+    const isEmpty = value.document.length === 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() });
+    }
+
+    this.setState(newState)
+
+    if (typeof this.props.onChange === 'function') {
+      this.props.onChange(newState);
+    }
+  }
+
+  /**
+   * 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);
+  }
+
+  asRaw = () => {
+    return JSON.stringify(this.state.value.toJSON());
+  }
+
+  asHtml = () => {
+    return HtmlSerializer.serialize(this.state.value);
+  }
+
+  asCategories = () => {
+    return this.state.categories
+  }
+
+  removeCategory = (categories, key, text) => {
+    const categoryIndex = categories.findIndex(category => category.key === key && category.text === text)
+    return categories.delete(categoryIndex)
+  }
+
+  clear = () => {
+    const value = Plain.deserialize('');
+    this.onChange({value});
+  }
+
+  focus = () => {
+    this.refs.editor.focus();
+  }
+
+      /**
+   * When a mark button is clicked, toggle the current mark.
+   *
+   * @param {Event} e
+   * @param {String} type
+   */
+
+  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
+    })
+  }
+
+  /**
+   * When a block button is clicked, toggle the block type.
+   *
+   * @param {Event} e
+   * @param {String} type
+   */
+
+  onClickBlock = (e, type) => {
+    e.preventDefault()
+    const { value } = this.state
+    const change = value.change()
+    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) {
+        change
+          .setBlocks(isActive ? DEFAULT_NODE : type)
+          .unwrapBlock('bulleted-list')
+          .unwrapBlock('numbered-list')
+      }
+
+      else {
+       change
+          .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) {
+       change
+          .setBlocks(DEFAULT_NODE)
+          .unwrapBlock('bulleted-list')
+          .unwrapBlock('numbered-list')
+
+      } else if (isList) {
+        change
+          .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
+          .wrapBlock(type)
+
+      } else {
+        change
+          .setBlocks('list-item')
+          .wrapBlock(type)
+
+      }
+    }
+
+
+    this.onChange(change)
+  }
+
+  onPortalOpen = (portal) => {
+    // When the portal opens, cache the menu element.
+    this.setState({ hoveringMenu: portal.firstChild })
+  }
+
+  onPortalClose = (portal) => {
+    let { value } = this.state
+
+    this.setState({
+      value: value.change,
+      isPortalOpen: false
+    })
+  }
+
+  onCategoryClick = (category) => {
+
+    const { value, currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.state;
+    const change = value.change()
+    let { categories } = this.state;
+
+    const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category')
+    categoryMarks.forEach(mark => change.removeMark(mark));
+
+    change.addMark({
+      type: 'category',
+      data: {
+        text: currentSelectionText,
+        selection: {
+          start: currentSelectionStart,
+          end: currentSelectionEnd,
+        },
+        color: category.color,
+        key: category.key
+      }
+    })
+
+    Object.assign(category, {
+      text: currentSelectionText,
+      selection: {
+        start: currentSelectionStart,
+        end: currentSelectionEnd,
+      },
+    });
+    categories = categories.push(category);
+
+    this.onChange(change)
+
+    this.setState({
+      value: value,
+      isPortalOpen: false,
+      categories: categories
+    });
+  }
+
+  onButtonClick = () => {
+    if (typeof this.props.onButtonClick === 'function') {
+      this.props.onButtonClick();
+    }
+  }
+
+  onCheckboxChange = (e) => {
+    if (typeof this.props.onCheckboxChange === 'function') {
+      this.props.onCheckboxChange(e);
+    }
+  }
+
+  /**
+   * On key down, if it's a formatting command toggle a mark.
+   *
+   * @param {Event} e
+   * @param {Change} change
+   * @return {Change}
+   */
+
+  onKeyDown = (e, change) => {
+
+    const {value} = this.state;
+
+    // if (e.key === 'Enter' && value.document.text === '') {
+    //   change.removeChild()
+    // }
+
+    if (e.key === 'Enter' && value.document.text !== '') {
+      this.setState({enterKeyValue: 1})
+    }
+
+    if (e.key !== 'Enter') {
+      this.setState({
+        enterKeyValue: 0,
+      })
+
+    }
+
+    if (e.key === 'Enter' && !this.props.isChecked && this.state.enterKeyValue === 1 && typeof this.props.onEnterKeyDown === 'function') {
+      e.preventDefault();
+      this.props.onEnterKeyDown();
+      this.setState({
+        enterKeyValue: 0,
+      })
+
+
+      return change
+    }
+
+    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
+        // 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.renderMarkButton('category', 'label')}
+
+
+          {this.renderBlockButton('numbered-list', 'format_list_numbered')}
+          {this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
+
+          {this.renderToolbarButtons()}
+      </div>
+    )
+  }
+
+  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"> Appuyer sur <kbd className="bg-danger text-muted ml-1">Entrée</kbd> pour ajouter une note</small>
+        </label>
+      </div>
+    )
+  }
+
+  renderToolbarButtons = () => {
+    return (
+      <div>
+        <button type="button" id="btn-editor" className="btn btn-primary btn-sm text-secondary font-weight-bold float-right" disabled={this.props.isButtonDisabled} onClick={this.onButtonClick}>
+          { this.props.note ? 'Sauvegarder' : 'Ajouter' }
+        </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}>
+
+        <span className="material-icons">{icon}</span>
+      </span>
+    )
+  }
+
+    // Add a `renderMark` method to render marks.
+
+    renderMark = props => {
+      const { children, mark, attributes } = props
+
+      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};
+      }
+  }
+  /**
+   * 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 parent = value.document.getParent(value.blocks.first().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 => {
+    const { attributes, children, node } = props
+
+    switch (node.type) {
+      case 'block-quote':
+        return <blockquote {...attributes}>{children}</blockquote>
+      case 'bulleted-list':
+        return <ul {...attributes}>{children}</ul>
+      case 'heading-one':
+        return <h1 {...attributes}>{children}</h1>
+      case 'heading-two':
+        return <h2 {...attributes}>{children}</h2>
+      case 'list-item':
+        return <li {...attributes}>{children}</li>
+      case 'numbered-list':
+        return <ol {...attributes}>{children}</ol>
+      default:
+        return null;
+    }
+}
+
+  /**
+   * Render the Slate editor.
+   *
+   * @return {Element}
+   */
+
+  renderEditor = () => {
+    return (
+      <div className="editor-slatejs p-2">
+        {this.renderHoveringMenu()}
+        <Editor
+          ref="editor"
+          spellCheck
+          placeholder={'Votre espace de prise de note...'}
+          schema={schema}
+          plugins={plugins}
+          value={this.state.value}
+          onChange={this.onChange}
+          onKeyDown={this.onKeyDown}
+          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 = () => {
+
+    const { hoveringMenu } = 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`
+  }
+
+}
+
+/**
+ * Export.
+ */
+
+export default SlateEditor