/** * @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 core/plugincollection */ /* globals console */ import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; /** * Manages a list of CKEditor plugins, including loading, resolving dependencies and initialization. * * @mixes module:utils/emittermixin~EmitterMixin */ export default class PluginCollection { /** * Creates an instance of the plugin collection class. * Allows loading and initializing plugins and their dependencies. * Allows to provide a list of already loaded plugins. These plugins will not be destroyed along with this collection. * * @param {module:core/editor/editor~Editor|module:core/context~Context} context * @param {Array.} [availablePlugins] Plugins (constructors) which the collection will be able to use * when {@link module:core/plugincollection~PluginCollection#init} is used with plugin names (strings, instead of constructors). * Usually, the editor will pass its built-in plugins to the collection so they can later be * used in `config.plugins` or `config.removePlugins` by names. * @param {Iterable.} contextPlugins A list of already initialized plugins represented by a * `[ PluginConstructor, pluginInstance ]` pair. */ constructor( context, availablePlugins = [], contextPlugins = [] ) { /** * @protected * @type {module:core/editor/editor~Editor|module:core/context~Context} */ this._context = context; /** * @protected * @type {Map} */ this._plugins = new Map(); /** * A map of plugin constructors that can be retrieved by their names. * * @protected * @type {Map.} */ this._availablePlugins = new Map(); for ( const PluginConstructor of availablePlugins ) { if ( PluginConstructor.pluginName ) { this._availablePlugins.set( PluginConstructor.pluginName, PluginConstructor ); } } /** * Map of {@link module:core/contextplugin~ContextPlugin context plugins} which can be retrieved by their constructors or instances. * * @protected * @type {Map} */ this._contextPlugins = new Map(); for ( const [ PluginConstructor, pluginInstance ] of contextPlugins ) { this._contextPlugins.set( PluginConstructor, pluginInstance ); this._contextPlugins.set( pluginInstance, PluginConstructor ); // To make it possible to require a plugin by its name. if ( PluginConstructor.pluginName ) { this._availablePlugins.set( PluginConstructor.pluginName, PluginConstructor ); } } } /** * Iterable interface. * * Returns `[ PluginConstructor, pluginInstance ]` pairs. * * @returns {Iterable.} */ * [ Symbol.iterator ]() { for ( const entry of this._plugins ) { if ( typeof entry[ 0 ] == 'function' ) { yield entry; } } } /** * Gets the plugin instance by its constructor or name. * * // Check if 'Clipboard' plugin was loaded. * if ( editor.plugins.has( 'Clipboard' ) ) { * // Get clipboard plugin instance * const clipboard = editor.plugins.get( 'Clipboard' ); * * this.listenTo( clipboard, 'inputTransformation', ( evt, data ) => { * // Do something on clipboard input. * } ); * } * * **Note**: This method will throw error if plugin is not loaded. Use `{@link #has editor.plugins.has()}` * to check if plugin is available. * * @param {Function|String} key The plugin constructor or {@link module:core/plugin~PluginInterface.pluginName name}. * @returns {module:core/plugin~PluginInterface} */ get( key ) { const plugin = this._plugins.get( key ); if ( !plugin ) { /** * The plugin is not loaded and could not be obtained. * * Plugin classes (constructors) need to be provided to the editor and must be loaded before they can be obtained from * the plugin collection. * This is usually done in CKEditor 5 builds by setting the {@link module:core/editor/editor~Editor.builtinPlugins} * property. * * **Note**: You can use `{@link module:core/plugincollection~PluginCollection#has editor.plugins.has()}` * to check if plugin was loaded. * * @error plugincollection-plugin-not-loaded * @param {String} plugin The name of the plugin which is not loaded. */ const errorMsg = 'plugincollection-plugin-not-loaded: The requested plugin is not loaded.'; let pluginName = key; if ( typeof key == 'function' ) { pluginName = key.pluginName || key.name; } throw new CKEditorError( errorMsg, this._context, { plugin: pluginName } ); } return plugin; } /** * Checks if a plugin is loaded. * * // Check if the 'Clipboard' plugin was loaded. * if ( editor.plugins.has( 'Clipboard' ) ) { * // Now use the clipboard plugin instance: * const clipboard = editor.plugins.get( 'Clipboard' ); * * // ... * } * * @param {Function|String} key The plugin constructor or {@link module:core/plugin~PluginInterface.pluginName name}. * @returns {Boolean} */ has( key ) { return this._plugins.has( key ); } /** * Initializes a set of plugins and adds them to the collection. * * @param {Array.} plugins An array of {@link module:core/plugin~PluginInterface plugin constructors} * or {@link module:core/plugin~PluginInterface.pluginName plugin names}. The second option (names) works only if * `availablePlugins` were passed to the {@link #constructor}. * @param {Array.} [removePlugins] Names of plugins or plugin constructors * that should not be loaded (despite being specified in the `plugins` array). * @returns {Promise.} A promise which gets resolved once all plugins are loaded * and available in the collection. */ init( plugins, removePlugins = [] ) { const that = this; const context = this._context; const loading = new Set(); const loaded = []; const pluginConstructors = mapToAvailableConstructors( plugins ); const removePluginConstructors = mapToAvailableConstructors( removePlugins ); const missingPlugins = getMissingPluginNames( plugins ); if ( missingPlugins ) { /** * Some plugins are not available and could not be loaded. * * Plugin classes (constructors) need to be provided to the editor before they can be loaded by name. * This is usually done in CKEditor 5 builds by setting the {@link module:core/editor/editor~Editor.builtinPlugins} * property. * * **If you see this warning when using one of the {@glink builds/index CKEditor 5 Builds}**, it means * that you try to enable a plugin which was not included in that build. This may be due to a typo * in the plugin name or simply because that plugin is not a part of this build. In the latter scenario, * read more about {@glink builds/guides/development/custom-builds custom builds}. * * **If you see this warning when using one of the editor creators directly** (not a build), then it means * that you tried loading plugins by name. However, unlike CKEditor 4, CKEditor 5 does not implement a "plugin loader". * This means that CKEditor 5 does not know where to load the plugin modules from. Therefore, you need to * provide each plugin through reference (as a constructor function). Check out the examples in * {@glink builds/guides/integration/advanced-setup#scenario-2-building-from-source "Building from source"}. * * @error plugincollection-plugin-not-found * @param {Array.} plugins The name of the plugins which could not be loaded. */ const errorMsg = 'plugincollection-plugin-not-found: Some plugins are not available and could not be loaded.'; // Log the error so it's more visible on the console. Hopefully, for better DX. console.error( attachLinkToDocumentation( errorMsg ), { plugins: missingPlugins } ); return Promise.reject( new CKEditorError( errorMsg, context, { plugins: missingPlugins } ) ); } return Promise.all( pluginConstructors.map( loadPlugin ) ) .then( () => initPlugins( loaded, 'init' ) ) .then( () => initPlugins( loaded, 'afterInit' ) ) .then( () => loaded ); function loadPlugin( PluginConstructor ) { if ( removePluginConstructors.includes( PluginConstructor ) ) { return; } // The plugin is already loaded or being loaded - do nothing. if ( that._plugins.has( PluginConstructor ) || loading.has( PluginConstructor ) ) { return; } return instantiatePlugin( PluginConstructor ) .catch( err => { /** * It was not possible to load the plugin. * * This is a generic error logged to the console when a JavaSript error is thrown during the initialization * of one of the plugins. * * If you correctly handled the promise returned by the editor's `create()` method (like shown below), * you will find the original error logged to the console, too: * * ClassicEditor.create( document.getElementById( 'editor' ) ) * .then( editor => { * // ... * } ) * .catch( error => { * console.error( error ); * } ); * * @error plugincollection-load * @param {String} plugin The name of the plugin that could not be loaded. */ console.error( attachLinkToDocumentation( 'plugincollection-load: It was not possible to load the plugin.' ), { plugin: PluginConstructor } ); throw err; } ); } function initPlugins( loadedPlugins, method ) { return loadedPlugins.reduce( ( promise, plugin ) => { if ( !plugin[ method ] ) { return promise; } if ( that._contextPlugins.has( plugin ) ) { return promise; } return promise.then( plugin[ method ].bind( plugin ) ); }, Promise.resolve() ); } function instantiatePlugin( PluginConstructor ) { return new Promise( resolve => { loading.add( PluginConstructor ); if ( PluginConstructor.requires ) { PluginConstructor.requires.forEach( RequiredPluginConstructorOrName => { const RequiredPluginConstructor = getPluginConstructor( RequiredPluginConstructorOrName ); if ( PluginConstructor.isContextPlugin && !RequiredPluginConstructor.isContextPlugin ) { /** * If a plugin is a context plugin, all plugins it requires should also be context plugins * instead of plugins. In other words, if one plugin can be used in the context, * all its requirements should also be ready to be used in the context. Note that the context * provides only a part of the API provided by the editor. If one plugin needs a full * editor API, all plugins which require it are considered as plugins that need a full * editor API. * * @error plugincollection-context-required * @param {String} plugin The name of the required plugin. * @param {String} requiredBy The name of the parent plugin. */ throw new CKEditorError( 'plugincollection-context-required: Context plugin can not require plugin which is not a context plugin', null, { plugin: RequiredPluginConstructor.name, requiredBy: PluginConstructor.name } ); } if ( removePlugins.includes( RequiredPluginConstructor ) ) { /** * Cannot load a plugin because one of its dependencies is listed in the `removePlugins` option. * * @error plugincollection-required * @param {String} plugin The name of the required plugin. * @param {String} requiredBy The name of the parent plugin. */ throw new CKEditorError( 'plugincollection-required: Cannot load a plugin because one of its dependencies is listed in' + 'the `removePlugins` option.', context, { plugin: RequiredPluginConstructor.name, requiredBy: PluginConstructor.name } ); } loadPlugin( RequiredPluginConstructor ); } ); } const plugin = that._contextPlugins.get( PluginConstructor ) || new PluginConstructor( context ); that._add( PluginConstructor, plugin ); loaded.push( plugin ); resolve(); } ); } function getPluginConstructor( PluginConstructorOrName ) { if ( typeof PluginConstructorOrName == 'function' ) { return PluginConstructorOrName; } return that._availablePlugins.get( PluginConstructorOrName ); } function getMissingPluginNames( plugins ) { const missingPlugins = []; for ( const pluginNameOrConstructor of plugins ) { if ( !getPluginConstructor( pluginNameOrConstructor ) ) { missingPlugins.push( pluginNameOrConstructor ); } } return missingPlugins.length ? missingPlugins : null; } function mapToAvailableConstructors( plugins ) { return plugins .map( pluginNameOrConstructor => getPluginConstructor( pluginNameOrConstructor ) ) .filter( PluginConstructor => !!PluginConstructor ); } } /** * Destroys all loaded plugins. * * @returns {Promise} */ destroy() { const promises = []; for ( const [ , pluginInstance ] of this ) { if ( typeof pluginInstance.destroy == 'function' && !this._contextPlugins.has( pluginInstance ) ) { promises.push( pluginInstance.destroy() ); } } return Promise.all( promises ); } /** * Adds the plugin to the collection. Exposed mainly for testing purposes. * * @protected * @param {Function} PluginConstructor The plugin constructor. * @param {module:core/plugin~PluginInterface} plugin The instance of the plugin. */ _add( PluginConstructor, plugin ) { this._plugins.set( PluginConstructor, plugin ); const pluginName = PluginConstructor.pluginName; if ( !pluginName ) { return; } if ( this._plugins.has( pluginName ) ) { /** * Two plugins with the same {@link module:core/plugin~PluginInterface.pluginName} were loaded. * This will lead to runtime conflicts between these plugins. * * In practice, this warning usually means that new plugins were added to an existing CKEditor 5 build. * Plugins should always be added to a source version of the editor (`@ckeditor/ckeditor5-editor-*`), * not to an editor imported from one of the `@ckeditor/ckeditor5-build-*` packages. * * Check your import paths and the list of plugins passed to * {@link module:core/editor/editor~Editor.create `Editor.create()`} * or specified in {@link module:core/editor/editor~Editor.builtinPlugins `Editor.builtinPlugins`}. * * The second option is that your `node_modules/` directory contains duplicated versions of the same * CKEditor 5 packages. Normally, on clean installations, npm deduplicates packages in `node_modules/`, so * it may be enough to call `rm -rf node_modules && npm i`. However, if you installed conflicting versions * of packages, their dependencies may need to be installed in more than one version which may lead to this * warning. * * Technically speaking, this error occurs because after adding a plugin to an existing editor build * dependencies of this plugin are being duplicated. * They are already built into that editor build and now get added for the second time as dependencies * of the plugin you are installing. * * Read more about {@glink builds/guides/integration/installing-plugins installing plugins}. * * @error plugincollection-plugin-name-conflict * @param {String} pluginName The duplicated plugin name. * @param {Function} plugin1 The first plugin constructor. * @param {Function} plugin2 The second plugin constructor. */ throw new CKEditorError( 'plugincollection-plugin-name-conflict: Two plugins with the same name were loaded.', null, { pluginName, plugin1: this._plugins.get( pluginName ).constructor, plugin2: PluginConstructor } ); } this._plugins.set( pluginName, plugin ); } } mix( PluginCollection, EmitterMixin );