client/src/components/SlateEditor.js
changeset 168 ea92f4fe783d
parent 167 1f340f3597a8
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 Immutable from 'immutable'
       
     7 import HtmlSerializer from '../HtmlSerializer'
       
     8 import AnnotationPlugin from '../AnnotationPlugin'
       
     9 import CategoriesTooltip from './CategoriesTooltip'
       
    10 import './SlateEditor.css';
       
    11 import { now } from '../utils';
       
    12 import { defaultAnnotationsCategories } from '../constants';
       
    13 
       
    14 const plugins = [];
       
    15 
       
    16 /**
       
    17  * Define the default node type.
       
    18  */
       
    19 
       
    20 const DEFAULT_NODE = 'paragraph'
       
    21 
       
    22 /**
       
    23  * Define a schema.
       
    24  *
       
    25  * @type {Object}
       
    26  */
       
    27 // TODO Check if we can move this to the plugin using the schema option
       
    28 // https://docs.slatejs.org/reference/plugins/plugin.html#schema
       
    29 const schema = {
       
    30 
       
    31   nodes: {
       
    32     'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
       
    33     'list-item': props => <li {...props.attributes}>{props.children}</li>,
       
    34     'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>,
       
    35   },
       
    36   marks: {
       
    37     bold: {
       
    38       fontWeight: 'bold'
       
    39     },
       
    40     category: props => {
       
    41       const data = props.mark.data;
       
    42       return <span style={{ backgroundColor: data.get('color') }}>{props.children}</span>
       
    43     },
       
    44     italic: {
       
    45       fontStyle: 'italic'
       
    46     },
       
    47     underlined: {
       
    48       textDecoration: 'underlined'
       
    49     }
       
    50   }
       
    51 
       
    52 }
       
    53 
       
    54 const initialValue = Value.fromJSON({
       
    55   document: {
       
    56     nodes: [
       
    57       {
       
    58         object: 'block',
       
    59         type: 'paragraph',
       
    60         nodes: [
       
    61           {
       
    62             object: 'text',
       
    63             leaves: [
       
    64               {
       
    65                 text: '',
       
    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: Immutable.List([]),
       
   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.get('key');
       
   241           const text = mark.data.get('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       isPortalOpen: false,
       
   373       categories: categories
       
   374     });
       
   375   }
       
   376 
       
   377   onButtonClick = () => {
       
   378     if (typeof this.props.onButtonClick === 'function') {
       
   379       this.props.onButtonClick();
       
   380     }
       
   381   }
       
   382 
       
   383   onCheckboxChange = (e) => {
       
   384     if (typeof this.props.onCheckboxChange === 'function') {
       
   385       this.props.onCheckboxChange(e);
       
   386     }
       
   387   }
       
   388 
       
   389   /**
       
   390    * On key down, if it's a formatting command toggle a mark.
       
   391    *
       
   392    * @param {Event} e
       
   393    * @param {Change} change
       
   394    * @return {Change}
       
   395    */
       
   396 
       
   397   onKeyDown = (e, change) => {
       
   398 
       
   399     const {value} = this.state;
       
   400 
       
   401     // if (e.key === 'Enter' && value.document.text === '') {
       
   402     //   change.removeChild()
       
   403     // }
       
   404 
       
   405     if (e.key === 'Enter' && value.document.text !== '') {
       
   406       this.setState({enterKeyValue: 1})
       
   407     }
       
   408 
       
   409     if (e.key !== 'Enter') {
       
   410       this.setState({
       
   411         enterKeyValue: 0,
       
   412       })
       
   413 
       
   414     }
       
   415 
       
   416     if (e.key === 'Enter' && !this.props.isChecked && this.state.enterKeyValue === 1 && typeof this.props.onEnterKeyDown === 'function') {
       
   417       e.preventDefault();
       
   418       this.props.onEnterKeyDown();
       
   419       this.setState({
       
   420         enterKeyValue: 0,
       
   421       })
       
   422 
       
   423 
       
   424       return change
       
   425     }
       
   426 
       
   427     else if (e.key === 'Enter' && value.document.text !== '' && this.props.isChecked && typeof this.props.onEnterKeyDown === 'function') {
       
   428 
       
   429       e.preventDefault();
       
   430       this.props.onEnterKeyDown();
       
   431 
       
   432       return change
       
   433     }
       
   434 
       
   435     if (!e.ctrlKey) return
       
   436         // Decide what to do based on the key code...
       
   437         switch (e.key) {
       
   438           default: {
       
   439             break;
       
   440           }
       
   441           // When "B" is pressed, add a "bold" mark to the text.
       
   442           case 'b': {
       
   443             e.preventDefault()
       
   444             change.toggleMark('bold')
       
   445 
       
   446             return true
       
   447           }
       
   448           case 'i': {
       
   449             // When "U" is pressed, add an "italic" mark to the text.
       
   450             e.preventDefault()
       
   451             change.toggleMark('italic')
       
   452 
       
   453             return true
       
   454           }
       
   455           case 'u': {
       
   456             // When "U" is pressed, add an "underline" mark to the text.
       
   457             e.preventDefault()
       
   458             change.toggleMark('underlined')
       
   459 
       
   460             return true
       
   461           }
       
   462           case 'Enter': {
       
   463             // When "ENTER" is pressed, autosubmit the note.
       
   464             if (value.document.text !== '' && typeof this.props.onEnterKeyDown === 'function') {
       
   465               e.preventDefault()
       
   466               this.props.onEnterKeyDown();
       
   467               this.setState({
       
   468                 enterKeyValue: 0,
       
   469               })
       
   470 
       
   471               return true
       
   472             }
       
   473         }
       
   474       }
       
   475   }
       
   476 
       
   477   /**
       
   478    * Render.
       
   479    *
       
   480    * @return {Element}
       
   481    */
       
   482 
       
   483   render = () => {
       
   484     return (
       
   485       <div className="bg-secondary mb-5">
       
   486         <div className="sticky-top">
       
   487         {this.renderToolbar()}
       
   488         </div>
       
   489         {this.renderEditor()}
       
   490     </div>
       
   491     )
       
   492   }
       
   493 
       
   494   /**
       
   495    * Render the toolbar.
       
   496    *
       
   497    * @return {Element}
       
   498    */
       
   499 
       
   500   renderToolbar = () => {
       
   501     return (
       
   502       <div className="menu toolbar-menu d-flex sticky-top bg-secondary">
       
   503           {this.renderMarkButton('bold', 'format_bold')}
       
   504           {this.renderMarkButton('italic', 'format_italic')}
       
   505           {this.renderMarkButton('underlined', 'format_underlined')}
       
   506           {this.renderMarkButton('category', 'label')}
       
   507 
       
   508 
       
   509           {this.renderBlockButton('numbered-list', 'format_list_numbered')}
       
   510           {this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
       
   511 
       
   512           {this.renderToolbarButtons()}
       
   513       </div>
       
   514     )
       
   515   }
       
   516 
       
   517   renderToolbarCheckbox = () => {
       
   518     return (
       
   519       <div className="checkbox float-right">
       
   520         <label className="mr-2">
       
   521           <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>
       
   522         </label>
       
   523       </div>
       
   524     )
       
   525   }
       
   526 
       
   527   renderSaveButton = () => {
       
   528     if (this.props.note) {
       
   529       return <button type="button" id="btn-editor" className="btn btn-primary btn-sm text-secondary font-weight-bold mr-2" disabled={this.props.isButtonDisabled} onClick={this.onButtonClick}>
       
   530       Sauvegarder</button>
       
   531     }
       
   532   }
       
   533 
       
   534   renderToolbarButtons = () => {
       
   535     return (
       
   536       <div>
       
   537         {/* <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}> */}
       
   538           {/* { this.props.note ? 'Sauvegarder' : 'Ajouter' } */}
       
   539           {this.renderSaveButton()}
       
   540         {/* </button> */}
       
   541         { !this.props.note && this.renderToolbarCheckbox() }
       
   542       </div>
       
   543     );
       
   544   }
       
   545 
       
   546   /**
       
   547    * Render a mark-toggling toolbar button.
       
   548    *
       
   549    * @param {String} type
       
   550    * @param {String} icon
       
   551    * @return {Element}
       
   552    */
       
   553 
       
   554   renderMarkButton = (type, icon) => {
       
   555     const isActive = this.hasMark(type)
       
   556     const onMouseDown = e => this.onClickMark(e, type)
       
   557     const markActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
       
   558 
       
   559     return (
       
   560       // <span className="button text-primary" onMouseDown={onMouseDown} data-active={isActive}>
       
   561       <span className={markActivation} onMouseDown={onMouseDown} data-active={isActive}>
       
   562 
       
   563         <span className="material-icons">{icon}</span>
       
   564       </span>
       
   565     )
       
   566   }
       
   567 
       
   568     // Add a `renderMark` method to render marks.
       
   569 
       
   570     renderMark = props => {
       
   571       const { children, mark, attributes } = props
       
   572 
       
   573       switch (mark.type) {
       
   574         default: {
       
   575           break;
       
   576         }
       
   577         case 'bold':
       
   578           return <strong {...attributes}>{children}</strong>
       
   579         case 'code':
       
   580           return <code {...attributes}>{children}</code>
       
   581         case 'italic':
       
   582           return <em {...attributes}>{children}</em>
       
   583         case 'underlined':
       
   584           return <ins {...attributes}>{children}</ins>
       
   585       }
       
   586   }
       
   587   /**
       
   588    * Render a block-toggling toolbar button.
       
   589    *
       
   590    * @param {String} type
       
   591    * @param {String} icon
       
   592    * @return {Element}
       
   593    */
       
   594 
       
   595   renderBlockButton = (type, icon) => {
       
   596     let isActive = this.hasBlock(type)
       
   597 
       
   598     if (['numbered-list', 'bulleted-list'].includes(type)) {
       
   599       const { value } = this.state
       
   600       const parent = value.document.getParent(value.blocks.first().key)
       
   601       isActive = this.hasBlock('list-item') && parent && parent.type === type
       
   602     }
       
   603     const onMouseDown = e => this.onClickBlock(e, type)
       
   604     const blockActivation = "button sticky-top" + ((!isActive)?" text-primary":" text-dark");
       
   605 
       
   606     return (
       
   607       <span className={blockActivation} onMouseDown={onMouseDown} data-active={isActive}>
       
   608         <span className="material-icons">{icon}</span>
       
   609       </span>
       
   610     )
       
   611   }
       
   612 
       
   613   renderNode = props => {
       
   614     const { attributes, children, node } = props
       
   615 
       
   616     switch (node.type) {
       
   617       default: {
       
   618         break;
       
   619       }
       
   620       case 'block-quote':
       
   621         return <blockquote {...attributes}>{children}</blockquote>
       
   622       case 'bulleted-list':
       
   623         return <ul {...attributes}>{children}</ul>
       
   624       case 'heading-one':
       
   625         return <h1 {...attributes}>{children}</h1>
       
   626       case 'heading-two':
       
   627         return <h2 {...attributes}>{children}</h2>
       
   628       case 'list-item':
       
   629         return <li {...attributes}>{children}</li>
       
   630       case 'numbered-list':
       
   631         return <ol {...attributes}>{children}</ol>
       
   632     }
       
   633 }
       
   634 
       
   635   /**
       
   636    * Render the Slate editor.
       
   637    *
       
   638    * @return {Element}
       
   639    */
       
   640 
       
   641   renderEditor = () => {
       
   642     return (
       
   643       <div className="editor-slatejs p-2">
       
   644         {this.renderHoveringMenu()}
       
   645         <Editor
       
   646           ref="editor"
       
   647           spellCheck
       
   648           placeholder={'Votre espace de prise de note...'}
       
   649           schema={schema}
       
   650           plugins={plugins}
       
   651           value={this.state.value}
       
   652           onChange={this.onChange}
       
   653           onKeyDown={this.onKeyDown}
       
   654           renderMark={this.renderMark}
       
   655           renderNode = {this.renderNode}
       
   656         />
       
   657       </div>
       
   658     )
       
   659   }
       
   660 
       
   661   renderHoveringMenu = () => {
       
   662     return (
       
   663       <Portal ref="portal"
       
   664         isOpened={this.state.isPortalOpen} isOpen={this.state.isPortalOpen}
       
   665         onOpen={this.onPortalOpen}
       
   666         onClose={this.onPortalClose}
       
   667         closeOnOutsideClick={false} closeOnEsc={true}>
       
   668         <div className="hovering-menu">
       
   669           <CategoriesTooltip categories={this.props.annotationCategories || defaultAnnotationsCategories} onCategoryClick={this.onCategoryClick} />
       
   670         </div>
       
   671       </Portal>
       
   672     )
       
   673   }
       
   674 
       
   675   updateMenu = () => {
       
   676 
       
   677     const { hoveringMenu } = this.state
       
   678 
       
   679     if (!hoveringMenu) return
       
   680 
       
   681     // if (state.isBlurred || state.isCollapsed) {
       
   682     //   hoveringMenu.removeAttribute('style')
       
   683     //   return
       
   684     // }
       
   685 
       
   686     const selection = window.getSelection()
       
   687 
       
   688     if (selection.isCollapsed) {
       
   689       return
       
   690     }
       
   691 
       
   692     const range = selection.getRangeAt(0)
       
   693     const rect = range.getBoundingClientRect()
       
   694 
       
   695     hoveringMenu.style.opacity = 1
       
   696     hoveringMenu.style.top = `${rect.top + window.scrollY + hoveringMenu.offsetHeight}px`
       
   697     hoveringMenu.style.left = `${rect.left + window.scrollX - hoveringMenu.offsetWidth / 2 + rect.width / 2}px`
       
   698   }
       
   699 
       
   700 }
       
   701 
       
   702 /**
       
   703  * Export.
       
   704  */
       
   705 
       
   706 export default SlateEditor