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