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