Katochimoto/xblocks-core

View on GitHub
src/xblocks/element.js

Summary

Maintainability
A
45 mins
Test Coverage
/**
 * @module xblocks-core/element
 */

import ReactDOM from 'react-dom';
import merge from 'lodash/merge';
import clone from 'lodash/clone';
import keys from 'lodash/keys';
import forEach from 'lodash/forEach';
import omit from 'lodash/omit';
import get from 'lodash/get';
import context from '../context';
import { typeConversion } from './dom/attrs';
import { dispatch } from './event';
import lazy from './utils/lazy';
import importStyle from './utils/importStyle';
import createShadowMountPoint from './utils/createShadowMountPoint';
import appComponent from './utils/Component';
import Constants from './constants';
import { contentNode } from './dom';

/**
 * Xblock element constructor.
 * @alias module:xblocks-core/element~XBElement
 * @param {HTMLElement} node the node of a custom element
 * @constructor
 */
export function XBElement(node) {
    node[ Constants.BLOCK ] = this;

    this.isShadow = false;
    this._node = node;
    this._mountPoint = node;

    if (node.isShadowSupported) {
        const mountPoint = createShadowMountPoint(node);

        if (mountPoint) {
            this._mountPoint = mountPoint;
            this.isShadow = true;
        }
    }

    this._observerOptions = {
        attributeFilter: keys(node.xprops),
        attributeOldValue: false,
        attributes: true,
        characterData: !this.isShadow,
        characterDataOldValue: false,
        childList: !this.isShadow
        // subtree: !this.isShadow
    };

    this._init();
}

/**
 * The component is mounted in the shadow root.
 * @type {boolean}
 */
XBElement.prototype.isShadow = false;

/**
 * The node of a react component.
 * @type {HTMLElement}
 * @protected
 */
XBElement.prototype._mountPoint = null;

/**
 * The node of a custom element.
 * @type {HTMLElement}
 * @protected
 */
XBElement.prototype._node = null;

/**
 * React component.
 * @type {Constructor}
 * @protected
 */
XBElement.prototype._component = null;

/**
 * Instance MutationObserver.
 * @type {MutationObserver}
 * @protected
 */
XBElement.prototype._observer = null;

/**
 * Unmounts a component and removes it from the DOM.
 * @fires module:xblocks-core/element~XBElement~event:xb-destroy
 */
XBElement.prototype.destroy = function () {
    const node = this._node;
    const mountPoint = this._mountPoint;
    const content = node.content;

    this._observer.disconnect();
    this._observer = null;
    this._component = null;
    this._node = null;
    this._mountPoint = null;

    ReactDOM.unmountComponentAtNode(mountPoint);

    // replace initial content after destroy react component
    // fix:
    // element.parentNode.removeChild(element);
    // document.body.appendChild(element);
    node.content = content;
    node[ Constants.BLOCK ] = undefined;

    dispatch(node, 'xb-destroy');
};

/**
 * Update react view.
 * @example
 * var element = document.createElement('xb-test');
 * element.xblock.update();
 * @param {Object} [props] added attributes
 * @param {array} [removeProps] remote attributes
 * @param {function} [callback] the callback function
 */
XBElement.prototype.update = function (props, removeProps, callback) {
    this._observer.disconnect();

    const node = this._node;
    // merge of new and current properties and the exclusion of remote properties
    const nextProps = omit(merge({}, this.getMountedProps(), node.props, props), removeProps);

    typeConversion(nextProps, node.xprops);

    const that = this;
    const renderCallback = function () {
        that._component = this;
        that._callbackUpdate(callback);
    };

    importStyle(node, node.componentStyle);
    this._component = ReactDOM.render(appComponent(nextProps), this._mountPoint, renderCallback);
};

/**
 * Returns true if the component is rendered into the DOM, false otherwise.
 * @see http://facebook.github.io/react/docs/component-api.html#ismounted
 * @returns {boolean}
 */
XBElement.prototype.isMounted = function () {
    return Boolean(this._component && this._component.isMounted());
};

/**
 * Installing a new content react component.
 * @param {string} content
 */
XBElement.prototype.setMountedContent = function (content) {
    if (this.isMounted()) {
        if (this.isShadow) {
            this._node.innerHTML = content;

        } else {
            this.update({ children: content });
        }
    }
};

/**
 * Receiving the content components react.
 * @returns {?string}
 */
XBElement.prototype.getMountedContent = function () {
    if (this.isMounted()) {
        if (this.isShadow) {
            return this._node.innerHTML;

        } else {
            return this._component.props.children;
        }
    }
};

/**
 * Get components react.
 * @returns {?ReactCompositeComponent.createClass.Constructor}
 */
XBElement.prototype.getUserComponent = function () {
    return get(this, '_component.userComponent', null);
};

/**
 * Gets the attributes of the components.
 * @returns {?Object}
 */
XBElement.prototype.getMountedProps = function () {
    return this.isMounted() ? this._component.props : null;
};

/**
 * @protected
 */
XBElement.prototype._init = function () {
    const node = this._node;
    const props = clone(node.props);

    typeConversion(props, node.xprops);

    props._container = node;
    props.children = node.content;

    const that = this;
    const renderCallback = function () {
        that._component = this;
        that._callbackInit();
    };

    importStyle(node, node.componentStyle);
    this._component = ReactDOM.render(appComponent(props), this._mountPoint, renderCallback);
};

/**
 * @protected
 * @fires module:xblocks-core/element~XBElement~event:xb-created
 */
XBElement.prototype._callbackInit = function () {
    const node = this._node;
    node.upgrade();
    this._observer = new context.MutationObserver(::this._callbackMutation);
    this._observer.observe(node, this._observerOptions);

    dispatch(node, 'xb-created');
    lazy(globalInitEvent, node);
};

/**
 * @param {function} [callback] the callback function
 * @protected
 * @fires module:xblocks-core/element~XBElement~event:xb-update
 */
XBElement.prototype._callbackUpdate = function (callback) {
    const node = this._node;
    node.upgrade();
    this._observer.observe(node, this._observerOptions);

    dispatch(node, 'xb-update');
    lazy(globalUpdateEvent, node);

    if (callback) {
        callback.call(this);
    }
};

/**
 * @param {MutationRecord[]} records
 * @protected
 */
XBElement.prototype._callbackMutation = function (records) {
    const removeAttrs = records
        .filter(filterAttributesRemove, this)
        .map(mapAttributesName);

    const isChildListMutation = records.some(childListMutationIterate);
    const props = {};

    if (isChildListMutation) {
        const node = this._node;
        const doc = node.ownerDocument;
        const mutationContent = doc.createElement('div');
        const targetContent = mutationContent.appendChild(doc.createElement('div'));

        records
            .filter(childListMutationIterate)
            .reduce(reduceChildListMutation, mutationContent);

        if (targetContent.parentNode) {
            targetContent.outerHTML = contentNode(node).innerHTML;
        }

        props.children = mutationContent.innerHTML;
    }

    this.update(props, removeAttrs);
};

/**
 * Verification of the attribute that was removed.
 * @example
 * // false
 * filterAttributesRemove({ type: 'attributes', attributeName: 'test' })
 * @param {MutationRecord} record
 * @returns {boolean}
 * @this XBElement
 * @private
 */
function filterAttributesRemove(record) {
    return (record.type === 'attributes' && !this._node.hasAttribute(record.attributeName));
}

/**
 * The allocation of attribute names
 * @example
 * // "test"
 * mapAttributesName({ attributeName: 'test' });
 * @param {MutationRecord} record
 * @returns {string}
 * @private
 */
function mapAttributesName(record) {
    return record.attributeName;
}

/**
 * Verification of the mutation sub-elements.
 * @example
 * // true
 * childListMutationIterate({ type: 'childList', attributeName: 'test' })
 * @param {MutationRecord} record
 * @returns {boolean}
 * @private
 */
function childListMutationIterate(record) {
    return (record.type === 'childList' || record.type === 'characterData');
}

/**
 * The allocation of the changed nodes.
 * @param {HTMLElement} mutationContent
 * @param {MutationRecord} record
 * @returns {HTMLElement}
 * @private
 */
function reduceChildListMutation(mutationContent, record) {
    const isAdded = Boolean(record.addedNodes.length);
    const isNext = Boolean(record.nextSibling);
    const isPrev = Boolean(record.previousSibling);
    const isRemoved = Boolean(record.removedNodes.length);

    // innerHTML or replace
    if (isAdded && (isRemoved || (!isRemoved && !isNext && !isPrev))) {
        while (mutationContent.firstChild) {
            mutationContent.removeChild(mutationContent.firstChild);
        }

        forEach(record.addedNodes, function (node) {
            mutationContent.appendChild(node);
        });

    // appendChild
    } else if (isAdded && !isRemoved && !isNext && isPrev) {
        forEach(record.addedNodes, function (node) {
            mutationContent.appendChild(node);
        });

    // insertBefore
    } else if (isAdded && !isRemoved && isNext && !isPrev) {
        forEach(record.addedNodes, function (node) {
            mutationContent.insertBefore(node, mutationContent.firstChild);
        });
    }

    return mutationContent;
}

/**
 * Call global events "xb-created"
 * @example
 * globalInitEvent([]);
 * @param {array} records
 * @private
 */
function globalInitEvent(records) {
    dispatch(context, 'xb-created', { detail: { records } });
}

/**
 * Call global events "xb-update"
 *
 * @example
 * globalUpdateEvent([]);
 *
 * @param {array} records
 * @private
 */
function globalUpdateEvent(records) {
    dispatch(context, 'xb-update', { detail: { records } });
}

/**
 * Created event
 * @event module:xblocks-core/element~XBElement~event:xb-created
 * @type {CustomEvent}
 */

/**
 * Destroy event
 * @event module:xblocks-core/element~XBElement~event:xb-destroy
 * @type {CustomEvent}
 */

/**
 * Updated event
 * @event module:xblocks-core/element~XBElement~event:xb-update
 * @type {CustomEvent}
 */