superdesk/superdesk-client-core

View on GitHub
scripts/core/editor3/components/HighlightsPopup.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
import React from 'react';
import {EditorState} from 'draft-js';
import {render, unmountComponentAtNode} from 'react-dom';
import PropTypes from 'prop-types';
import {Provider} from 'react-redux';
import {List} from 'immutable';

import {CommentPopup} from './comments';
import {SuggestionPopup} from './suggestions/SuggestionPopup';
import {AnnotationPopup} from './annotations';
import {getSuggestionsTypes} from '../highlightsConfig';
import * as Highlights from '../helpers/highlights';
import {ReactContextForEditor3} from '../directive';

/**
 * @ngdoc react
 * @name HighlightsPopup
 * @description HighlightsPopup is a popup showing information about the highlight
 * that the cursor is on. Based on the highlight type, it renders the appropriate
 * component. Check the component() method for more information. HighlightsPopup
 * also handles positioning the popup relative to the editor's position and hiding
 * it when a user clicks outside the editor/popup context.
 */
export class HighlightsPopup extends React.Component<any, any> {
    static propTypes: any;
    static defaultProps: any;
    static contextType = ReactContextForEditor3;

    context: React.ContextType<typeof ReactContextForEditor3>;

    rendered: any;

    constructor(props) {
        super(props);

        this.onDocumentClick = this.onDocumentClick.bind(this);
        this.createHighlight = this.createHighlight.bind(this);
    }

    /**
     * @ngdoc method
     * @name HighlightsPopup#component
     * @description component returns the popup element to be rendered.
     * @returns {JSX}
     */
    component() {
        let highlightsAndSuggestions = [];
        let data;

        if (this.styleBasedHighlightsExist()) {
            this.getInlineStyleForCollapsedSelection()
                .filter(this.props.highlightsManager.styleNameBelongsToHighlight)
                .forEach((styleName) => {
                    const highlightType = this.props.highlightsManager.getHighlightTypeFromStyleName(styleName);

                    if (getSuggestionsTypes().indexOf(highlightType) !== -1) {
                        data = Highlights.getSuggestionData(this.props.editorState, styleName);
                    } else {
                        data = this.props.highlightsManager.getHighlightData(styleName);
                    }

                    highlightsAndSuggestions = [
                        ...highlightsAndSuggestions,
                        {
                            type: highlightType,
                            value: data,
                            highlightId: styleName,
                        },
                    ];
                });
        }
        // We need to create a new provider here because this component gets rendered
        // outside the editor tree and loses context.
        return (
            <Provider store={this.context}>
                <div>
                    {
                        highlightsAndSuggestions
                            .map((obj, i) => (
                                <div key={i}>
                                    {this.createHighlight(obj.type, obj.value, obj.highlightId)}
                                </div>
                            ))
                    }
                </div>
            </Provider>
        );
    }

    /**
     * @ngdoc method
     * @name HighlightsPopup#createHighlight
     * @param {String} type Highlight Type
     * @description Renders the active highlight of the given type
     * @returns {JSX}
     */
    createHighlight(type, h, highlightId) {
        if (type === 'ANNOTATION') {
            return (
                <AnnotationPopup
                    annotation={h}
                    highlightId={highlightId}
                    highlightsManager={this.props.highlightsManager}
                    editorNode={this.props.editorNode.current}
                    close={() => this.unmountCustom()}
                />
            );
        } else if (type === 'COMMENT') {
            return (
                <CommentPopup
                    comment={h}
                    highlightId={highlightId}
                    highlightsManager={this.props.highlightsManager}
                    onChange={this.props.onChange}
                    editorState={this.props.editorState}
                    editorNode={this.props.editorNode.current}
                />
            );
        } else if (getSuggestionsTypes().indexOf(type) !== -1) {
            return (
                <SuggestionPopup
                    suggestion={h}
                    editorNode={this.props.editorNode.current}
                />
            );
        } else {
            console.error('Invalid highlight type in HighlightsPopup: ', type);
        }
    }

    styleBasedHighlightsExist() {
        return this.getInlineStyleForCollapsedSelection()
            .some(this.props.highlightsManager.styleNameBelongsToHighlight);
    }

    /**
     * @ngdoc method
     * @name HighlightsPopup#renderCustom
     * @description Renders the popup into the app's React popup placeholder
     * and creates a document click handler which will hide the popup when
     * clicks occur outside of it.
     */
    renderCustom() {
        // force unmount the existing component so it's rendered again instead of being updated
        // when updating there are issues with positioning caused by
        // not being to determine the position of selected text
        // which may or may not be related to the implementation of draftjs' getVisibleSelectionRect
        render(<div />, document.getElementById('react-placeholder'));

        render(this.component(), document.getElementById('react-placeholder'));
        document.addEventListener('click', this.onDocumentClick, {
            // required in order to prevent closing the popup when you click an element which is INSIDE the popup
            // but is being removed after clicking
            capture: true,
        });
        this.rendered = true;
    }

    /**
     * @ngdoc method
     * @name HighlightsPopup#unmountCustom
     * @description Unmounts the popup.
     */
    unmountCustom() {
        const reactPlaceholder = document.getElementById('react-placeholder');

        // null in tests. TODO: refactor this not to rely on a global element.
        if (reactPlaceholder != null) {
            unmountComponentAtNode(reactPlaceholder);
        }

        document.removeEventListener('click', this.onDocumentClick);
        this.rendered = false;
    }

    /**
     * @ngdoc method
     * @name HighlightsPopup#onDocumentClick
     * @param {Event} e
     * @description Triggered when the document is clicked. It checks if the click
     * occurred on the popup or on the editor, and if it didn't, it unmounts the
     * component.
     */
    onDocumentClick(e) {
        const t = $(e.target);
        const {editorNode} = this.props;
        const onPopup = t.closest('.editor-popup').length || t.closest('.mentions-input__suggestions').length;
        const onEditor = t.closest(editorNode.current).length;
        const onModal = t.closest('.modal__dialog');

        if (!onPopup && !onEditor && !onModal) {
            // if the click occurred outside the editor, the popup and the modal, we close it
            this.unmountCustom();
        }
    }

    shouldComponentUpdate(nextProps) {
        const nextSelection = nextProps.editorState.getSelection();
        const selection = this.props.editorState.getSelection();
        const hadHighlightsChanged = this.props.highlightsManager.hadHighlightsChanged(
            this.props.editorState, nextProps.editorState);

        const cursorMoved = nextSelection.getAnchorOffset() !== selection.getAnchorOffset() ||
            nextSelection.getAnchorKey() !== selection.getAnchorKey();

        return cursorMoved || hadHighlightsChanged;
    }

    getInlineStyleForCollapsedSelection() {
        const {editorState} = this.props;
        const selection = editorState.getSelection();
        const content = editorState.getCurrentContent();

        if (selection.isCollapsed() === false) {
            return List();
        }

        const blockKey = selection.getStartKey();
        const block = content.getBlockForKey(blockKey);
        const offset = selection.getStartOffset();

        if (block.getLength() === offset) {
            return List();
        }

        const inlineStyle = block.getInlineStyleAt(offset);

        return inlineStyle;
    }

    shouldRender() {
        if (this.styleBasedHighlightsExist()) {
            return true;
        }

        return false;
    }
    componentDidUpdate() {
        // Waiting one cycle allows the selection to be rendered in the browser
        // so that we can correctly retrieve its position.
        setTimeout(() => this.shouldRender() ? this.renderCustom() : this.unmountCustom(), 0);
    }

    componentWillUnmount() {
        if (this.rendered) {
            this.unmountCustom();
        }
    }

    render() {
        return null;
    }
}

HighlightsPopup.propTypes = {
    editorState: PropTypes.instanceOf(EditorState),
    editorNode: PropTypes.object,
    highlightsManager: PropTypes.object.isRequired,
    onChange: PropTypes.func.isRequired,
};