client/src/components/SlateEditor/index.js
author ymh <ymh.work@gmail.com>
Tue, 13 Nov 2018 16:46:15 +0100
changeset 172 4b780ebbedc6
parent 171 03334a31130a
child 173 0e6703cd0968
permissions -rw-r--r--
- Upgrade libraries - Make things work again

import { Value } from 'slate';
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 * 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: '',
              },
            ],
          },
        ],
      },
    ],
  },
})


/**
 *
 * @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,
    };

    this.editorRef = React.createRef();
    this.hoveringMenuRef = React.createRef();
  }

  get editor() {
    if(this.editorRef) {
      return this.editorRef.current;
    }
    return null;
  }

  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() });
    }

    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')
        })
      }
      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);
      }
    })

  }

  /**
   * 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 = () => {
    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.
   *
   * @param {Event} e
   * @param {String} type
   */

  onClickMark = (e, type) => {
    this.editor.toggleMark(type)
  }

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

  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')

      } 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;

    // 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 => 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);
    }
  }

  /**
   * 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 !== '') {
      this.setState({enterKeyValue: 1})
    }

    if (e.key !== 'Enter') {
      this.setState({
        enterKeyValue: 0,
      })

    }

    //TODO review the double enter case.
    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.renderCategoryButton()}

          {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"><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}>

        <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 (
      <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.
   *
   * @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

    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 next();
    }
}

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

  renderEditor = () => {
    const t = this.props.t;
    return (
      <div className="editor-slatejs p-2">
        {/* {this.renderHoveringMenu()} */}
        <Editor
          ref={this.editorRef}
          spellCheck
          placeholder={t('slate_editor.placeholder')}
          // 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
    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.
 */

export default withNamespaces("", {
  innerRef: (ref) => {
    const editorRef = (ref && ref.props) ? ref.props.editorRef : null;
    if(editorRef && editorRef.hasOwnProperty('current')) {
      editorRef.current = ref;
    }
  }
})(SlateEditor);
// export default SlateEditor;