diff -r 1f340f3597a8 -r ea92f4fe783d client/src/components/SlateEditor/index.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/client/src/components/SlateEditor/index.js Mon Oct 08 18:35:47 2018 +0200 @@ -0,0 +1,697 @@ +import { Value } from 'slate' +import Plain from 'slate-plain-serializer' +import { Editor } from 'slate-react' +import React from 'react' +import { Portal } from 'react-portal' +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 => , + 'list-item': props =>
  • {props.children}
  • , + 'numbered-list': props =>
      {props.children}
    , + }, + marks: { + bold: { + fontWeight: 'bold' + }, + category: props => { + const data = props.mark.data; + return {props.children} + }, + italic: { + fontStyle: 'italic' + }, + underlined: { + textDecoration: 'underlined' + } + } + +} + +const initialValue = Value.fromJSON({ + document: { + nodes: [ + { + object: 'block', + type: 'paragraph', + nodes: [ + { + object: 'text', + leaves: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, +}) + + +/** + * The rich text example. + * + * @type {Component} + */ + +class SlateEditor extends React.Component { + + /** + * Deserialize the initial editor state. + * + * @type {Object} + */ + constructor(props) { + super(props); + + const annotationPlugin = AnnotationPlugin({ + onChange: (text, start, end) => { + this.setState({ + currentSelectionText: text, + currentSelectionStart: start, + currentSelectionEnd: end, + }); + } + }); + + plugins.push(annotationPlugin); + + + this.state = { + 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, + }; + } + + componentDidMount = () => { + this.updateMenu(); + this.focus(); + } + + componentDidUpdate = () => { + this.updateMenu(); + } + + /** + * On change, save the new state. + * + * @param {Change} change + */ + + onChange = ({value}) => { + + let newState = { + value: value, + startedAt: this.state.startedAt + }; + + const isEmpty = value.document.length === 0; + + // Reset timers when the text is empty + if (isEmpty) { + Object.assign(newState, { + startedAt: null, + finishedAt: null + }); + } else { + Object.assign(newState, { finishedAt: now() }); + } + + // Store start time once when the first character is typed + if (!isEmpty && this.state.startedAt === null) { + Object.assign(newState, { startedAt: now() }); + } + + this.setState(newState) + + if (typeof this.props.onChange === 'function') { + this.props.onChange(newState); + } + } + + /** + * 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 = () => { + this.refs.editor.focus(); + } + + /** + * When a mark button is clicked, toggle the current mark. + * + * @param {Event} e + * @param {String} type + */ + + onClickMark = (e, type) => { + + e.preventDefault() + const { value } = this.state + let { categories } = this.state + + let isPortalOpen = false; + + if (type === 'category') { + // Can't use toggleMark here, because it expects the same object + // @see https://github.com/ianstormtaylor/slate/issues/873 + if (this.hasMark('category')) { + const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category') + categoryMarks.forEach(mark => { + const key = mark.data.key; + const text = mark.data.text; + + categories = this.removeCategory(categories, key, text) + const change = value.change().removeMark(mark) + this.onChange(change) + }) + + } else { + isPortalOpen = !this.state.isPortalOpen; + } + } else { + const change = value.change().toggleMark(type) + this.onChange(change) + } + + this.setState({ + state: value.change, + isPortalOpen: isPortalOpen, + categories: categories + }) + } + + /** + * When a block button is clicked, toggle the block type. + * + * @param {Event} e + * @param {String} type + */ + + onClickBlock = (e, type) => { + e.preventDefault() + const { value } = this.state + const change = value.change() + 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) { + change + .setBlocks(isActive ? DEFAULT_NODE : type) + .unwrapBlock('bulleted-list') + .unwrapBlock('numbered-list') + } + + else { + change + .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) { + change + .setBlocks(DEFAULT_NODE) + .unwrapBlock('bulleted-list') + .unwrapBlock('numbered-list') + + } else if (isList) { + change + .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list') + .wrapBlock(type) + + } else { + change + .setBlocks('list-item') + .wrapBlock(type) + + } + } + + + this.onChange(change) + } + + onPortalOpen = (portal) => { + // When the portal opens, cache the menu element. + this.setState({ hoveringMenu: portal.firstChild }) + } + + onPortalClose = (portal) => { + let { value } = this.state + + this.setState({ + value: value.change, + isPortalOpen: false + }) + } + + onCategoryClick = (category) => { + + const { value, currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.state; + const change = value.change() + let { categories } = this.state; + + const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category') + categoryMarks.forEach(mark => change.removeMark(mark)); + + change.addMark({ + type: 'category', + data: { + text: currentSelectionText, + selection: { + start: currentSelectionStart, + end: currentSelectionEnd, + }, + color: category.color, + key: category.key + } + }) + + Object.assign(category, { + text: currentSelectionText, + selection: { + start: currentSelectionStart, + end: currentSelectionEnd, + }, + }); + categories = categories.push(category); + + this.onChange(change) + + this.setState({ + value: value, + isPortalOpen: false, + categories: categories + }); + } + + onButtonClick = () => { + if (typeof this.props.onButtonClick === 'function') { + this.props.onButtonClick(); + } + } + + onCheckboxChange = (e) => { + if (typeof this.props.onCheckboxChange === 'function') { + this.props.onCheckboxChange(e); + } + } + + /** + * 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 === '') { + // change.removeChild() + // } + + if (e.key === 'Enter' && value.document.text !== '') { + this.setState({enterKeyValue: 1}) + } + + if (e.key !== 'Enter') { + this.setState({ + enterKeyValue: 0, + }) + + } + + 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 ( +
    +
    + {this.renderToolbar()} +
    + {this.renderEditor()} +
    + ) + } + + /** + * Render the toolbar. + * + * @return {Element} + */ + + renderToolbar = () => { + return ( +
    + {this.renderMarkButton('bold', 'format_bold')} + {this.renderMarkButton('italic', 'format_italic')} + {this.renderMarkButton('underlined', 'format_underlined')} + {this.renderMarkButton('category', 'label')} + + + {this.renderBlockButton('numbered-list', 'format_list_numbered')} + {this.renderBlockButton('bulleted-list', 'format_list_bulleted')} + + {this.renderToolbarButtons()} +
    + ) + } + + renderToolbarCheckbox = () => { + return ( +
    + +
    + ) + } + + renderToolbarButtons = () => { + return ( +
    + + { !this.props.note && this.renderToolbarCheckbox() } +
    + ); + } + + /** + * 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 ( + // + + + {icon} + + ) + } + + // Add a `renderMark` method to render marks. + + renderMark = props => { + const { children, mark, attributes } = props + + switch (mark.type) { + case 'bold': + return {children} + case 'code': + return {children} + case 'italic': + return {children} + case 'underlined': + return {children} + default: + return {children}; + } + } + /** + * 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 parent = value.document.getParent(value.blocks.first().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 ( + + {icon} + + ) + } + + renderNode = props => { + const { attributes, children, node } = props + + switch (node.type) { + case 'block-quote': + return
    {children}
    + case 'bulleted-list': + return + case 'heading-one': + return

    {children}

    + case 'heading-two': + return

    {children}

    + case 'list-item': + return
  • {children}
  • + case 'numbered-list': + return
      {children}
    + default: + return null; + } +} + + /** + * Render the Slate editor. + * + * @return {Element} + */ + + renderEditor = () => { + return ( +
    + {this.renderHoveringMenu()} + +
    + ) + } + + renderHoveringMenu = () => { + return ( + +
    + +
    +
    + ) + } + + updateMenu = () => { + + const { hoveringMenu } = this.state + + if (!hoveringMenu) return + + // if (state.isBlurred || state.isCollapsed) { + // hoveringMenu.removeAttribute('style') + // return + // } + + const selection = window.getSelection() + + if (selection.isCollapsed) { + return + } + + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + + hoveringMenu.style.opacity = 1 + hoveringMenu.style.top = `${rect.top + window.scrollY + hoveringMenu.offsetHeight}px` + hoveringMenu.style.left = `${rect.left + window.scrollX - hoveringMenu.offsetWidth / 2 + rect.width / 2}px` + } + +} + +/** + * Export. + */ + +export default SlateEditor