client/src/components/SlateEditor.js
author salimr <riwad.salim@yahoo.fr>
Tue, 14 Aug 2018 20:34:50 +0200
changeset 143 cfcbf4bc66f1
parent 138 a1fb2ced3049
child 157 5c3af4f10e92
permissions -rw-r--r--
Remove react-bootstrap from components except Modal, Collapse and Dropdown

import { Editor, Plain, Raw } from 'slate'
import React from 'react'
import Portal from 'react-portal'
import Immutable from 'immutable'
import HtmlSerializer from '../HtmlSerializer'
import AnnotationPlugin from '../AnnotationPlugin'
import CategoriesTooltip from './CategoriesTooltip'
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.get('color') }}>{props.children}</span>
    },
    italic: {
      fontStyle: 'italic'
    },
    underlined: {
      textDecoration: 'underline'
    }
  }
}

/**
 * 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 = {
      state: props.note ? Raw.deserialize(props.note.raw) : Plain.deserialize(''),
      startedAt: null,
      finishedAt: null,
      currentSelectionText: '',
      currentSelectionStart: 0,
      currentSelectionEnd: 0,
      hoveringMenu: null,
      isPortalOpen: false,
      categories: Immutable.List([]),
      isCheckboxChecked: false,
    };
  }

  componentDidMount = () => {
    this.updateMenu();
    this.focus();
  }

  componentDidUpdate = () => {
    this.updateMenu();
  }

  /**
   * Check if the current selection has a mark with `type` in it.
   *
   * @param {String} type
   * @return {Boolean}
   */

  hasMark = (type) => {
    const { state } = this.state
    return state.marks.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 { state } = this.state
    return state.blocks.some(node => node.type === type)
  }

  /**
   * On change, save the new state.
   *
   * @param {State} state
   */

  onChange = (state) => {

    let newState = {
      state: state,
      startedAt: this.state.startedAt
    };

    const isEmpty = state.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);
    }
  }

  asPlain = () => {
    return Plain.serialize(this.state.state);
  }

  asRaw = () => {
    return Raw.serialize(this.state.state);
  }

  asHtml = () => {
    return HtmlSerializer.serialize(this.state.state);
  }

  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 state = Plain.deserialize('');
    this.onChange(state);
  }

  focus = () => {
    this.refs.editor.focus();
  }

  /**
   * On key down, if it's a formatting command toggle a mark.
   *
   * @param {Event} e
   * @param {Object} data
   * @param {State} state
   * @return {State}
   */

  onKeyDown = (e, data, state) => {

    if (data.key === 'enter' && this.props.isChecked && typeof this.props.onEnterKeyDown === 'function') {
      e.preventDefault();
      this.props.onEnterKeyDown();

      return state;
    }

    if (!data.isMod) return
    let mark

    switch (data.key) {
      case 'b':
        mark = 'bold'
        break
      case 'i':
        mark = 'italic'
        break
      case 'u':
        mark = 'underlined'
        break
      default:
        return
    }

    state = state
      .transform()
      .toggleMark(mark)
      .apply()

    e.preventDefault()
    return state
  }

  /**
   * When a mark button is clicked, toggle the current mark.
   *
   * @param {Event} e
   * @param {String} type
   */

  onClickMark = (e, type) => {
    e.preventDefault()
    const { state } = this.state
    let { categories } = this.state
    const transform = state.transform()

    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 = state.marks.filter(mark => mark.type === 'category')
        categoryMarks.forEach(mark => {
          const key = mark.data.get('key');
          const text = mark.data.get('text');

          categories = this.removeCategory(categories, key, text)
          transform.removeMark(mark)
        })

      } else {
        isPortalOpen = !this.state.isPortalOpen;
      }
    } else {
      transform.toggleMark(type)
    }

    this.setState({
      state: transform.apply(),
      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()
    let { state } = this.state
    const transform = state.transform()
    const { document } = state

    // 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) {
        transform
          .setBlock(isActive ? DEFAULT_NODE : type)
          .unwrapBlock('bulleted-list')
          .unwrapBlock('numbered-list')
      }

      else {
        transform
          .setBlock(isActive ? DEFAULT_NODE : type)
      }
    }

    // Handle the extra wrapping required for list buttons.
    else {
      const isList = this.hasBlock('list-item')
      const isType = state.blocks.some((block) => {
        return !!document.getClosest(block.key, parent => parent.type === type)
      })

      if (isList && isType) {
        transform
          .setBlock(DEFAULT_NODE)
          .unwrapBlock('bulleted-list')
          .unwrapBlock('numbered-list')
      } else if (isList) {
        transform
          .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
          .wrapBlock(type)
      } else {
        transform
          .setBlock('list-item')
          .wrapBlock(type)
      }
    }

    state = transform.apply()
    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();

    this.setState({
      state: transform.apply(),
      isPortalOpen: false
    })
  }

  onCategoryClick = (category) => {

    const { state, currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.state;
    let { categories } = this.state;
    const transform = state.transform();

    const categoryMarks = state.marks.filter(mark => mark.type === 'category')
    categoryMarks.forEach(mark => transform.removeMark(mark));

    transform.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.setState({
      state: transform.apply(),
      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);
    }
  }

  /**
   * Render.
   *
   * @return {Element}
   */

  render = () => {
    return (
      <div>
        {this.renderToolbar()}
        {this.renderEditor()}
      </div>
    )
  }

  /**
   * Render the toolbar.
   *
   * @return {Element}
   */

  renderToolbar = () => {
    return (
      <div className="menu toolbar-menu">
        {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">
        <label>
          <input type="checkbox" checked={this.props.isChecked} onChange={this.onCheckboxChange} /> <kbd>Entrée</kbd>= Ajouter une note
        </label>
      </div>
    )
  }

  renderToolbarButtons = () => {
    return (
      <div>
        { !this.props.note && this.renderToolbarCheckbox() }
        <button type="button" className="btn btn-primary btn-lg" disabled={this.props.isButtonDisabled} onClick={this.onButtonClick}>
          { this.props.note ? 'Save note' : 'Ajouter' }
        </button>
      </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)

    return (
      <span className="button" onMouseDown={onMouseDown} data-active={isActive}>
        <span className="material-icons">{icon}</span>
      </span>
    )
  }

  /**
   * Render a block-toggling toolbar button.
   *
   * @param {String} type
   * @param {String} icon
   * @return {Element}
   */

  renderBlockButton = (type, icon) => {
    const isActive = this.hasBlock(type)
    const onMouseDown = e => this.onClickBlock(e, type)

    return (
      <span className="button" onMouseDown={onMouseDown} data-active={isActive}>
        <span className="material-icons">{icon}</span>
      </span>
    )
  }

  /**
   * Render the Slate editor.
   *
   * @return {Element}
   */

  renderEditor = () => {
    return (
      <div className="editor-slatejs">
        {this.renderHoveringMenu()}
        <Editor
          ref="editor"
          spellCheck
          placeholder={'Enter some rich text...'}
          schema={schema}
          plugins={plugins}
          state={this.state.state}
          onChange={this.onChange}
          onKeyDown={this.onKeyDown}
        />
      </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