client/src/components/SlateEditor/index.js
changeset 168 ea92f4fe783d
parent 161 a642639dbc07
child 169 f98efa1bddd1
equal deleted inserted replaced
167:1f340f3597a8 168:ea92f4fe783d
       
     1 import { Value } from 'slate'
       
     2 import Plain from 'slate-plain-serializer'
       
     3 import { Editor } from 'slate-react'
       
     4 import React from 'react'
       
     5 import { Portal } from 'react-portal'
       
     6 import HtmlSerializer from './HtmlSerializer'
       
     7 import AnnotationPlugin from './AnnotationPlugin'
       
     8 import CategoriesTooltip from './CategoriesTooltip'
       
     9 import './SlateEditor.css';
       
    10 import { now } from '../../utils';
       
    11 import { defaultAnnotationsCategories } from '../../constants';
       
    12 
       
    13 const plugins = [];
       
    14 
       
    15 /**
       
    16  * Define the default node type.
       
    17  */
       
    18 
       
    19 const DEFAULT_NODE = 'paragraph'
       
    20 
       
    21 /**
       
    22  * Define a schema.
       
    23  *
       
    24  * @type {Object}
       
    25  */
       
    26 // TODO Check if we can move this to the plugin using the schema option
       
    27 // https://docs.slatejs.org/reference/plugins/plugin.html#schema
       
    28 const schema = {
       
    29 
       
    30   nodes: {
       
    31     'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
       
    32     'list-item': props => <li {...props.attributes}>{props.children}</li>,
       
    33     'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>,
       
    34   },
       
    35   marks: {
       
    36     bold: {
       
    37       fontWeight: 'bold'
       
    38     },
       
    39     category: props => {
       
    40       const data = props.mark.data;
       
    41       return <span style={{ backgroundColor: data.color }}>{props.children}</span>
       
    42     },
       
    43     italic: {
       
    44       fontStyle: 'italic'
       
    45     },
       
    46     underlined: {
       
    47       textDecoration: 'underlined'
       
    48     }
       
    49   }
       
    50 
       
    51 }
       
    52 
       
    53 const initialValue = Value.fromJSON({
       
    54   document: {
       
    55     nodes: [
       
    56       {
       
    57         object: 'block',
       
    58         type: 'paragraph',
       
    59         nodes: [
       
    60           {
       
    61             object: 'text',
       
    62             leaves: [
       
    63               {
       
    64                 text: '',
       
    65               },
       
    66             ],
       
    67           },
       
    68         ],
       
    69       },
       
    70     ],
       
    71   },
       
    72 })
       
    73 
       
    74 
       
    75 /**
       
    76  * The rich text example.
       
    77  *
       
    78  * @type {Component}
       
    79  */
       
    80 
       
    81 class SlateEditor extends React.Component {
       
    82 
       
    83   /**
       
    84    * Deserialize the initial editor state.
       
    85    *
       
    86    * @type {Object}
       
    87    */
       
    88   constructor(props) {
       
    89     super(props);
       
    90 
       
    91     const annotationPlugin = AnnotationPlugin({
       
    92       onChange: (text, start, end) => {
       
    93         this.setState({
       
    94           currentSelectionText: text,
       
    95           currentSelectionStart: start,
       
    96           currentSelectionEnd: end,
       
    97         });
       
    98       }
       
    99     });
       
   100 
       
   101     plugins.push(annotationPlugin);
       
   102 
       
   103 
       
   104     this.state = {
       
   105       value: props.note ? Value.fromJSON(initialValue) : Plain.deserialize(''),
       
   106       startedAt: null,
       
   107       finishedAt: null,
       
   108       currentSelectionText: '',
       
   109       currentSelectionStart: 0,
       
   110       currentSelectionEnd: 0,
       
   111       hoveringMenu: null,
       
   112       isPortalOpen: false,
       
   113       categories: [],
       
   114       isCheckboxChecked: false,
       
   115       enterKeyValue: 0,
       
   116     };
       
   117   }
       
   118 
       
   119   componentDidMount = () => {
       
   120     this.updateMenu();
       
   121     this.focus();
       
   122   }
       
   123 
       
   124   componentDidUpdate = () => {
       
   125     this.updateMenu();
       
   126   }
       
   127 
       
   128    /**
       
   129    * On change, save the new state.
       
   130    *
       
   131    * @param {Change} change
       
   132    */
       
   133 
       
   134   onChange = ({value}) => {
       
   135 
       
   136     let newState = {
       
   137       value: value,
       
   138       startedAt: this.state.startedAt
       
   139     };
       
   140 
       
   141     const isEmpty = value.document.length === 0;
       
   142 
       
   143     // Reset timers when the text is empty
       
   144     if (isEmpty) {
       
   145       Object.assign(newState, {
       
   146         startedAt: null,
       
   147         finishedAt: null
       
   148       });
       
   149     } else {
       
   150       Object.assign(newState, { finishedAt: now() });
       
   151     }
       
   152 
       
   153     // Store start time once when the first character is typed
       
   154     if (!isEmpty && this.state.startedAt === null) {
       
   155       Object.assign(newState, { startedAt: now() });
       
   156     }
       
   157 
       
   158     this.setState(newState)
       
   159 
       
   160     if (typeof this.props.onChange === 'function') {
       
   161       this.props.onChange(newState);
       
   162     }
       
   163   }
       
   164 
       
   165   /**
       
   166    * Check if the current selection has a mark with `type` in it.
       
   167    *
       
   168    * @param {String} type
       
   169    * @return {Boolean}
       
   170    */
       
   171 
       
   172   hasMark = type => {
       
   173     const { value } = this.state
       
   174     return value.activeMarks.some(mark => mark.type === type)
       
   175 }
       
   176 
       
   177   /**
       
   178    * Check if the any of the currently selected blocks are of `type`.
       
   179    *
       
   180    * @param {String} type
       
   181    * @return {Boolean}
       
   182    */
       
   183 
       
   184   hasBlock = type => {
       
   185     const { value } = this.state
       
   186     return value.blocks.some(node => node.type === type)
       
   187 }
       
   188 
       
   189   asPlain = () => {
       
   190     return Plain.serialize(this.state.value);
       
   191   }
       
   192 
       
   193   asRaw = () => {
       
   194     return JSON.stringify(this.state.value.toJSON());
       
   195   }
       
   196 
       
   197   asHtml = () => {
       
   198     return HtmlSerializer.serialize(this.state.value);
       
   199   }
       
   200 
       
   201   asCategories = () => {
       
   202     return this.state.categories
       
   203   }
       
   204 
       
   205   removeCategory = (categories, key, text) => {
       
   206     const categoryIndex = categories.findIndex(category => category.key === key && category.text === text)
       
   207     return categories.delete(categoryIndex)
       
   208   }
       
   209 
       
   210   clear = () => {
       
   211     const value = Plain.deserialize('');
       
   212     this.onChange({value});
       
   213   }
       
   214 
       
   215   focus = () => {
       
   216     this.refs.editor.focus();
       
   217   }
       
   218 
       
   219       /**
       
   220    * When a mark button is clicked, toggle the current mark.
       
   221    *
       
   222    * @param {Event} e
       
   223    * @param {String} type
       
   224    */
       
   225 
       
   226   onClickMark = (e, type) => {
       
   227 
       
   228     e.preventDefault()
       
   229     const { value } = this.state
       
   230     let { categories } = this.state
       
   231 
       
   232     let isPortalOpen = false;
       
   233 
       
   234     if (type === 'category') {
       
   235       // Can't use toggleMark here, because it expects the same object
       
   236       // @see https://github.com/ianstormtaylor/slate/issues/873
       
   237       if (this.hasMark('category')) {
       
   238         const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category')
       
   239         categoryMarks.forEach(mark => {
       
   240           const key = mark.data.key;
       
   241           const text = mark.data.text;
       
   242 
       
   243           categories = this.removeCategory(categories, key, text)
       
   244           const change = value.change().removeMark(mark)
       
   245           this.onChange(change)
       
   246         })
       
   247 
       
   248       } else {
       
   249         isPortalOpen = !this.state.isPortalOpen;
       
   250       }
       
   251     } else {
       
   252       const change = value.change().toggleMark(type)
       
   253       this.onChange(change)
       
   254     }
       
   255 
       
   256     this.setState({
       
   257       state: value.change,
       
   258       isPortalOpen: isPortalOpen,
       
   259       categories: categories
       
   260     })
       
   261   }
       
   262 
       
   263   /**
       
   264    * When a block button is clicked, toggle the block type.
       
   265    *
       
   266    * @param {Event} e
       
   267    * @param {String} type
       
   268    */
       
   269 
       
   270   onClickBlock = (e, type) => {
       
   271     e.preventDefault()
       
   272     const { value } = this.state
       
   273     const change = value.change()
       
   274     const { document } = value
       
   275 
       
   276     // Handle everything but list buttons.
       
   277     if (type !== 'bulleted-list' && type !== 'numbered-list') {
       
   278       const isActive = this.hasBlock(type)
       
   279       const isList = this.hasBlock('list-item')
       
   280 
       
   281       if (isList) {
       
   282         change
       
   283           .setBlocks(isActive ? DEFAULT_NODE : type)
       
   284           .unwrapBlock('bulleted-list')
       
   285           .unwrapBlock('numbered-list')
       
   286       }
       
   287 
       
   288       else {
       
   289        change
       
   290           .setBlocks(isActive ? DEFAULT_NODE : type)
       
   291       }
       
   292     }
       
   293 
       
   294     // Handle the extra wrapping required for list buttons.
       
   295     else {
       
   296       const isList = this.hasBlock('list-item')
       
   297       const isType = value.blocks.some((block) => {
       
   298         return !!document.getClosest(block.key, parent => parent.type === type)
       
   299       })
       
   300 
       
   301       if (isList && isType) {
       
   302        change
       
   303           .setBlocks(DEFAULT_NODE)
       
   304           .unwrapBlock('bulleted-list')
       
   305           .unwrapBlock('numbered-list')
       
   306 
       
   307       } else if (isList) {
       
   308         change
       
   309           .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
       
   310           .wrapBlock(type)
       
   311 
       
   312       } else {
       
   313         change
       
   314           .setBlocks('list-item')
       
   315           .wrapBlock(type)
       
   316 
       
   317       }
       
   318     }
       
   319 
       
   320 
       
   321     this.onChange(change)
       
   322   }
       
   323 
       
   324   onPortalOpen = (portal) => {
       
   325     // When the portal opens, cache the menu element.
       
   326     this.setState({ hoveringMenu: portal.firstChild })
       
   327   }
       
   328 
       
   329   onPortalClose = (portal) => {
       
   330     let { value } = this.state
       
   331 
       
   332     this.setState({
       
   333       value: value.change,
       
   334       isPortalOpen: false
       
   335     })
       
   336   }
       
   337 
       
   338   onCategoryClick = (category) => {
       
   339 
       
   340     const { value, currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.state;
       
   341     const change = value.change()
       
   342     let { categories } = this.state;
       
   343 
       
   344     const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category')
       
   345     categoryMarks.forEach(mark => change.removeMark(mark));
       
   346 
       
   347     change.addMark({
       
   348       type: 'category',
       
   349       data: {
       
   350         text: currentSelectionText,
       
   351         selection: {
       
   352           start: currentSelectionStart,
       
   353           end: currentSelectionEnd,
       
   354         },
       
   355         color: category.color,
       
   356         key: category.key
       
   357       }
       
   358     })
       
   359 
       
   360     Object.assign(category, {
       
   361       text: currentSelectionText,
       
   362       selection: {
       
   363         start: currentSelectionStart,
       
   364         end: currentSelectionEnd,
       
   365       },
       
   366     });
       
   367     categories = categories.push(category);
       
   368 
       
   369     this.onChange(change)
       
   370 
       
   371     this.setState({
       
   372       value: value,
       
   373       isPortalOpen: false,
       
   374       categories: categories
       
   375     });
       
   376   }
       
   377 
       
   378   onButtonClick = () => {
       
   379     if (typeof this.props.onButtonClick === 'function') {
       
   380       this.props.onButtonClick();
       
   381     }
       
   382   }
       
   383 
       
   384   onCheckboxChange = (e) => {
       
   385     if (typeof this.props.onCheckboxChange === 'function') {
       
   386       this.props.onCheckboxChange(e);
       
   387     }
       
   388   }
       
   389 
       
   390   /**
       
   391    * On key down, if it's a formatting command toggle a mark.
       
   392    *
       
   393    * @param {Event} e
       
   394    * @param {Change} change
       
   395    * @return {Change}
       
   396    */
       
   397 
       
   398   onKeyDown = (e, change) => {
       
   399 
       
   400     const {value} = this.state;
       
   401 
       
   402     // if (e.key === 'Enter' && value.document.text === '') {
       
   403     //   change.removeChild()
       
   404     // }
       
   405 
       
   406     if (e.key === 'Enter' && value.document.text !== '') {
       
   407       this.setState({enterKeyValue: 1})
       
   408     }
       
   409 
       
   410     if (e.key !== 'Enter') {
       
   411       this.setState({
       
   412         enterKeyValue: 0,
       
   413       })
       
   414 
       
   415     }
       
   416 
       
   417     if (e.key === 'Enter' && !this.props.isChecked && this.state.enterKeyValue === 1 && typeof this.props.onEnterKeyDown === 'function') {
       
   418       e.preventDefault();
       
   419       this.props.onEnterKeyDown();
       
   420       this.setState({
       
   421         enterKeyValue: 0,
       
   422       })
       
   423 
       
   424 
       
   425       return change
       
   426     }
       
   427 
       
   428     else if (e.key === 'Enter' && value.document.text !== '' && this.props.isChecked && typeof this.props.onEnterKeyDown === 'function') {
       
   429 
       
   430       e.preventDefault();
       
   431       this.props.onEnterKeyDown();
       
   432 
       
   433       return change
       
   434     }
       
   435 
       
   436     if (!e.ctrlKey) return
       
   437         // Decide what to do based on the key code...
       
   438         switch (e.key) {
       
   439           default: {
       
   440             break;
       
   441           }
       
   442           // When "B" is pressed, add a "bold" mark to the text.
       
   443           case 'b': {
       
   444             e.preventDefault()
       
   445             change.toggleMark('bold')
       
   446 
       
   447             return true
       
   448           }
       
   449           case 'i': {
       
   450             // When "U" is pressed, add an "italic" mark to the text.
       
   451             e.preventDefault()
       
   452             change.toggleMark('italic')
       
   453 
       
   454             return true
       
   455           }
       
   456           case 'u': {
       
   457             // When "U" is pressed, add an "underline" mark to the text.
       
   458             e.preventDefault()
       
   459             change.toggleMark('underlined')
       
   460 
       
   461             return true
       
   462           }
       
   463           case 'Enter': {
       
   464             // When "ENTER" is pressed, autosubmit the note.
       
   465             if (value.document.text !== '' && typeof this.props.onEnterKeyDown === 'function') {
       
   466               e.preventDefault()
       
   467               this.props.onEnterKeyDown();
       
   468               this.setState({
       
   469                 enterKeyValue: 0,
       
   470               })
       
   471 
       
   472               return true
       
   473             }
       
   474         }
       
   475       }
       
   476   }
       
   477 
       
   478   /**
       
   479    * Render.
       
   480    *
       
   481    * @return {Element}
       
   482    */
       
   483 
       
   484   render = () => {
       
   485     return (
       
   486       <div className="bg-secondary mb-5">
       
   487         <div className="sticky-top">
       
   488         {this.renderToolbar()}
       
   489         </div>
       
   490         {this.renderEditor()}
       
   491     </div>
       
   492     )
       
   493   }
       
   494 
       
   495   /**
       
   496    * Render the toolbar.
       
   497    *
       
   498    * @return {Element}
       
   499    */
       
   500 
       
   501   renderToolbar = () => {
       
   502     return (
       
   503       <div className="menu toolbar-menu d-flex sticky-top bg-secondary">
       
   504           {this.renderMarkButton('bold', 'format_bold')}
       
   505           {this.renderMarkButton('italic', 'format_italic')}
       
   506           {this.renderMarkButton('underlined', 'format_underlined')}
       
   507           {this.renderMarkButton('category', 'label')}
       
   508 
       
   509 
       
   510           {this.renderBlockButton('numbered-list', 'format_list_numbered')}
       
   511           {this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
       
   512 
       
   513           {this.renderToolbarButtons()}
       
   514       </div>
       
   515     )
       
   516   }
       
   517 
       
   518   renderToolbarCheckbox = () => {
       
   519     return (
       
   520       <div className="checkbox float-right">
       
   521         <label className="mr-2">
       
   522           <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>
       
   523         </label>
       
   524       </div>
       
   525     )
       
   526   }
       
   527 
       
   528   renderToolbarButtons = () => {
       
   529     return (
       
   530       <div>
       
   531         <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}>
       
   532           { this.props.note ? 'Sauvegarder' : 'Ajouter' }
       
   533         </button>
       
   534         { !this.props.note && this.renderToolbarCheckbox() }
       
   535       </div>
       
   536     );
       
   537   }
       
   538 
       
   539   /**
       
   540    * Render a mark-toggling toolbar button.
       
   541    *
       
   542    * @param {String} type
       
   543    * @param {String} icon
       
   544    * @return {Element}
       
   545    */
       
   546 
       
   547   renderMarkButton = (type, icon) => {
       
   548     const isActive = this.hasMark(type)
       
   549     const onMouseDown = e => this.onClickMark(e, type)
       
   550     const markActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
       
   551 
       
   552     return (
       
   553       // <span className="button text-primary" onMouseDown={onMouseDown} data-active={isActive}>
       
   554       <span className={markActivation} onMouseDown={onMouseDown} data-active={isActive}>
       
   555 
       
   556         <span className="material-icons">{icon}</span>
       
   557       </span>
       
   558     )
       
   559   }
       
   560 
       
   561     // Add a `renderMark` method to render marks.
       
   562 
       
   563     renderMark = props => {
       
   564       const { children, mark, attributes } = props
       
   565 
       
   566       switch (mark.type) {
       
   567         case 'bold':
       
   568           return <strong {...attributes}>{children}</strong>
       
   569         case 'code':
       
   570           return <code {...attributes}>{children}</code>
       
   571         case 'italic':
       
   572           return <em {...attributes}>{children}</em>
       
   573         case 'underlined':
       
   574           return <ins {...attributes}>{children}</ins>
       
   575         default:
       
   576           return {children};
       
   577       }
       
   578   }
       
   579   /**
       
   580    * Render a block-toggling toolbar button.
       
   581    *
       
   582    * @param {String} type
       
   583    * @param {String} icon
       
   584    * @return {Element}
       
   585    */
       
   586 
       
   587   renderBlockButton = (type, icon) => {
       
   588     let isActive = this.hasBlock(type)
       
   589 
       
   590     if (['numbered-list', 'bulleted-list'].includes(type)) {
       
   591       const { value } = this.state
       
   592       const parent = value.document.getParent(value.blocks.first().key)
       
   593       isActive = this.hasBlock('list-item') && parent && parent.type === type
       
   594     }
       
   595     const onMouseDown = e => this.onClickBlock(e, type)
       
   596     const blockActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
       
   597 
       
   598     return (
       
   599       <span className={blockActivation} onMouseDown={onMouseDown} data-active={isActive}>
       
   600         <span className="material-icons">{icon}</span>
       
   601       </span>
       
   602     )
       
   603   }
       
   604 
       
   605   renderNode = props => {
       
   606     const { attributes, children, node } = props
       
   607 
       
   608     switch (node.type) {
       
   609       case 'block-quote':
       
   610         return <blockquote {...attributes}>{children}</blockquote>
       
   611       case 'bulleted-list':
       
   612         return <ul {...attributes}>{children}</ul>
       
   613       case 'heading-one':
       
   614         return <h1 {...attributes}>{children}</h1>
       
   615       case 'heading-two':
       
   616         return <h2 {...attributes}>{children}</h2>
       
   617       case 'list-item':
       
   618         return <li {...attributes}>{children}</li>
       
   619       case 'numbered-list':
       
   620         return <ol {...attributes}>{children}</ol>
       
   621       default:
       
   622         return null;
       
   623     }
       
   624 }
       
   625 
       
   626   /**
       
   627    * Render the Slate editor.
       
   628    *
       
   629    * @return {Element}
       
   630    */
       
   631 
       
   632   renderEditor = () => {
       
   633     return (
       
   634       <div className="editor-slatejs p-2">
       
   635         {this.renderHoveringMenu()}
       
   636         <Editor
       
   637           ref="editor"
       
   638           spellCheck
       
   639           placeholder={'Votre espace de prise de note...'}
       
   640           schema={schema}
       
   641           plugins={plugins}
       
   642           value={this.state.value}
       
   643           onChange={this.onChange}
       
   644           onKeyDown={this.onKeyDown}
       
   645           renderMark={this.renderMark}
       
   646           renderNode = {this.renderNode}
       
   647         />
       
   648       </div>
       
   649     )
       
   650   }
       
   651 
       
   652   renderHoveringMenu = () => {
       
   653     return (
       
   654       <Portal ref="portal"
       
   655         isOpened={this.state.isPortalOpen} isOpen={this.state.isPortalOpen}
       
   656         onOpen={this.onPortalOpen}
       
   657         onClose={this.onPortalClose}
       
   658         closeOnOutsideClick={false} closeOnEsc={true}>
       
   659         <div className="hovering-menu">
       
   660           <CategoriesTooltip categories={this.props.annotationCategories || defaultAnnotationsCategories} onCategoryClick={this.onCategoryClick} />
       
   661         </div>
       
   662       </Portal>
       
   663     )
       
   664   }
       
   665 
       
   666   updateMenu = () => {
       
   667 
       
   668     const { hoveringMenu } = this.state
       
   669 
       
   670     if (!hoveringMenu) return
       
   671 
       
   672     // if (state.isBlurred || state.isCollapsed) {
       
   673     //   hoveringMenu.removeAttribute('style')
       
   674     //   return
       
   675     // }
       
   676 
       
   677     const selection = window.getSelection()
       
   678 
       
   679     if (selection.isCollapsed) {
       
   680       return
       
   681     }
       
   682 
       
   683     const range = selection.getRangeAt(0)
       
   684     const rect = range.getBoundingClientRect()
       
   685 
       
   686     hoveringMenu.style.opacity = 1
       
   687     hoveringMenu.style.top = `${rect.top + window.scrollY + hoveringMenu.offsetHeight}px`
       
   688     hoveringMenu.style.left = `${rect.left + window.scrollX - hoveringMenu.offsetWidth / 2 + rect.width / 2}px`
       
   689   }
       
   690 
       
   691 }
       
   692 
       
   693 /**
       
   694  * Export.
       
   695  */
       
   696 
       
   697 export default SlateEditor