ckeditor/ckeditor5-utils

View on GitHub
src/config.js

Summary

Maintainability
A
55 mins
Test Coverage
/**
 * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */

/**
 * @module utils/config
 */

import { isPlainObject, isElement, cloneDeepWith } from 'lodash-es';

/**
 * Handles a configuration dictionary.
 */
export default class Config {
    /**
     * Creates an instance of the {@link ~Config} class.
     *
     * @param {Object} [configurations] The initial configurations to be set. Usually, provided by the user.
     * @param {Object} [defaultConfigurations] The default configurations. Usually, provided by the system.
     */
    constructor( configurations, defaultConfigurations ) {
        /**
         * Store for the whole configuration.
         *
         * @private
         * @member {Object}
         */
        this._config = {};

        // Set default configuration.
        if ( defaultConfigurations ) {
            // Clone the configuration to make sure that the properties will not be shared
            // between editors and make the watchdog feature work correctly.
            this.define( cloneConfig( defaultConfigurations ) );
        }

        // Set initial configuration.
        if ( configurations ) {
            this._setObjectToTarget( this._config, configurations );
        }
    }

    /**
     * Set configuration values.
     *
     * It accepts both a name/value pair or an object, which properties and values will be used to set
     * configurations.
     *
     * It also accepts setting a "deep configuration" by using dots in the name. For example, `'resize.width'` sets
     * the value for the `width` configuration in the `resize` subset.
     *
     *        config.set( 'width', 500 );
     *        config.set( 'toolbar.collapsed', true );
     *
     *        // Equivalent to:
     *        config.set( {
     *            width: 500
     *            toolbar: {
     *                collapsed: true
     *            }
     *        } );
     *
     * Passing an object as the value will amend the configuration, not replace it.
     *
     *        config.set( 'toolbar', {
     *            collapsed: true,
     *        } );
     *
     *        config.set( 'toolbar', {
     *            color: 'red',
     *        } );
     *
     *        config.get( 'toolbar.collapsed' ); // true
     *        config.get( 'toolbar.color' ); // 'red'
     *
     * @param {String|Object} name The configuration name or an object from which take properties as
     * configuration entries. Configuration names are case-sensitive.
     * @param {*} value The configuration value. Used if a name is passed.
     */
    set( name, value ) {
        this._setToTarget( this._config, name, value );
    }

    /**
     * Does exactly the same as {@link #set} with one exception – passed configuration extends
     * existing one, but does not overwrite already defined values.
     *
     * This method is supposed to be called by plugin developers to setup plugin's configurations. It would be
     * rarely used for other needs.
     *
     * @param {String|Object} name The configuration name or an object from which take properties as
     * configuration entries. Configuration names are case-sensitive.
     * @param {*} value The configuration value. Used if a name is passed.
     */
    define( name, value ) {
        const isDefine = true;

        this._setToTarget( this._config, name, value, isDefine );
    }

    /**
     * Gets the value for a configuration entry.
     *
     *        config.get( 'name' );
     *
     * Deep configurations can be retrieved by separating each part with a dot.
     *
     *        config.get( 'toolbar.collapsed' );
     *
     * @param {String} name The configuration name. Configuration names are case-sensitive.
     * @returns {*} The configuration value or `undefined` if the configuration entry was not found.
     */
    get( name ) {
        return this._getFromSource( this._config, name );
    }

    /**
     * Iterates over all top level configuration names.
     *
     * @returns {Iterable.<String>}
     */
    * names() {
        for ( const name of Object.keys( this._config ) ) {
            yield name;
        }
    }

    /**
     * Saves passed configuration to the specified target (nested object).
     *
     * @private
     * @param {Object} target Nested config object.
     * @param {String|Object} name The configuration name or an object from which take properties as
     * configuration entries. Configuration names are case-sensitive.
     * @param {*} value The configuration value. Used if a name is passed.
     * @param {Boolean} [isDefine=false] Define if passed configuration should overwrite existing one.
     */
    _setToTarget( target, name, value, isDefine = false ) {
        // In case of an object, iterate through it and call `_setToTarget` again for each property.
        if ( isPlainObject( name ) ) {
            this._setObjectToTarget( target, name, isDefine );

            return;
        }

        // The configuration name should be split into parts if it has dots. E.g. `resize.width` -> [`resize`, `width`].
        const parts = name.split( '.' );

        // Take the name of the configuration out of the parts. E.g. `resize.width` -> `width`.
        name = parts.pop();

        // Iterate over parts to check if currently stored configuration has proper structure.
        for ( const part of parts ) {
            // If there is no object for specified part then create one.
            if ( !isPlainObject( target[ part ] ) ) {
                target[ part ] = {};
            }

            // Nested object becomes a target.
            target = target[ part ];
        }

        // In case of value is an object.
        if ( isPlainObject( value ) ) {
            // We take care of proper config structure.
            if ( !isPlainObject( target[ name ] ) ) {
                target[ name ] = {};
            }

            target = target[ name ];

            // And iterate through this object calling `_setToTarget` again for each property.
            this._setObjectToTarget( target, value, isDefine );

            return;
        }

        // Do nothing if we are defining configuration for non empty name.
        if ( isDefine && typeof target[ name ] != 'undefined' ) {
            return;
        }

        target[ name ] = value;
    }

    /**
     * Get specified configuration from specified source (nested object).
     *
     * @private
     * @param {Object} source level of nested object.
     * @param {String} name The configuration name. Configuration names are case-sensitive.
     * @returns {*} The configuration value or `undefined` if the configuration entry was not found.
     */
    _getFromSource( source, name ) {
        // The configuration name should be split into parts if it has dots. E.g. `resize.width` -> [`resize`, `width`].
        const parts = name.split( '.' );

        // Take the name of the configuration out of the parts. E.g. `resize.width` -> `width`.
        name = parts.pop();

        // Iterate over parts to check if currently stored configuration has proper structure.
        for ( const part of parts ) {
            if ( !isPlainObject( source[ part ] ) ) {
                source = null;
                break;
            }

            // Nested object becomes a source.
            source = source[ part ];
        }

        // Always returns undefined for non existing configuration.
        return source ? cloneConfig( source[ name ] ) : undefined;
    }

    /**
     * Iterates through passed object and calls {@link #_setToTarget} method with object key and value for each property.
     *
     * @private
     * @param {Object} target Nested config object.
     * @param {Object} configuration Configuration data set
     * @param {Boolean} [isDefine] Defines if passed configuration is default configuration or not.
     */
    _setObjectToTarget( target, configuration, isDefine ) {
        Object.keys( configuration ).forEach( key => {
            this._setToTarget( target, key, configuration[ key ], isDefine );
        } );
    }
}

// Clones configuration object or value.
// @param {*} source Source configuration
// @returns {*} Cloned configuration value.
function cloneConfig( source ) {
    return cloneDeepWith( source, leaveDOMReferences );
}

// A customized function for cloneDeepWith.
// It will leave references to DOM Elements instead of cloning them.
//
// @param {*} value
// @returns {Element|undefined}
function leaveDOMReferences( value ) {
    return isElement( value ) ? value : undefined;
}