瀏覽代碼

Merge pull request #204 from ckeditor/context

Feature: Introduced the concept of editor contexts and context plugins. Contexts provide a common, higher-level environment for solutions which use multiple editors and/or plugins that work outside an editor. Closes ckeditor/ckeditor5#5891.
Piotrek Koszuliński 5 年之前
父節點
當前提交
ce18450e1d

+ 287 - 0
packages/ckeditor5-core/src/context.js

@@ -0,0 +1,287 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module core/context
+ */
+
+import Config from '@ckeditor/ckeditor5-utils/src/config';
+import PluginCollection from './plugincollection';
+import Locale from '@ckeditor/ckeditor5-utils/src/locale';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * Provides a common, higher level environment for solutions which use multiple {@link module:core/editor/editor~Editor editors}
+ * or/and plugins that work outside of an editor. Use it instead of {@link module:core/editor/editor~Editor.create `Editor.create()`}
+ * in advanced application integrations.
+ *
+ * All configuration options passed to a `Context` will be used as default options for editor instances initialized in that context.
+ *
+ * {@link module:core/contextplugin~ContextPlugin `ContextPlugin`s} passed to a `Context` instance will be shared among all
+ * editor instances initialized in that context. These will be the same plugin instances for all the editors.
+ *
+ * **Note:** `Context` can only be initialized with {@link module:core/contextplugin~ContextPlugin `ContextPlugin`s}
+ * (e.g. [comments](https://ckeditor.com/collaboration/comments/)). Regular {@link module:core/plugin~Plugin `Plugin`s} require an
+ * editor instance to work and cannot be added to a `Context`.
+ *
+ * **Note:** You can add `ContextPlugin` to an editor instance, though.
+ *
+ * If you are using multiple editor instances on one page and use any `ContextPlugin`s, create `Context` to share configuration and plugins
+ * among those editors. Some plugins will use the information about all existing editors to better integrate between them.
+ *
+ * If you are using plugins that do not require an editor to work (e.g. [comments](https://ckeditor.com/collaboration/comments/))
+ * enable and configure them using `Context`.
+ *
+ * If you are using only a single editor on each page use {@link module:core/editor/editor~Editor.create `Editor.create()`} instead.
+ * In such case, `Context` instance will be created by the editor instance in a transparent way.
+ *
+ * See {@link module:core/context~Context.create `Context.create()`} for usage examples.
+ */
+export default class Context {
+	/**
+	 * Creates a context instance with a given configuration.
+	 *
+	 * Usually, not to be used directly. See the static {@link module:core/context~Context.create `create()`} method.
+	 *
+	 * @param {Object} [config={}] The context config.
+	 */
+	constructor( config ) {
+		/**
+		 * Holds all configurations specific to this context instance.
+		 *
+		 * @readonly
+		 * @type {module:utils/config~Config}
+		 */
+		this.config = new Config( config );
+
+		/**
+		 * The plugins loaded and in use by this context instance.
+		 *
+		 * @readonly
+		 * @type {module:core/plugincollection~PluginCollection}
+		 */
+		this.plugins = new PluginCollection( this );
+
+		const languageConfig = this.config.get( 'language' ) || {};
+
+		/**
+		 * @readonly
+		 * @type {module:utils/locale~Locale}
+		 */
+		this.locale = new Locale( {
+			uiLanguage: typeof languageConfig === 'string' ? languageConfig : languageConfig.ui,
+			contentLanguage: this.config.get( 'language.content' )
+		} );
+
+		/**
+		 * Shorthand for {@link module:utils/locale~Locale#t}.
+		 *
+		 * @see module:utils/locale~Locale#t
+		 * @method #t
+		 */
+		this.t = this.locale.t;
+
+		/**
+		 * List of editors to which this context instance is injected.
+		 *
+		 * @private
+		 * @type {Set.<module:core/editor/editor~Editor>}
+		 */
+		this._editors = new Set();
+
+		/**
+		 * Reference to the editor which created the context.
+		 * Null when the context was created outside of the editor.
+		 *
+		 * It is used to destroy the context when removing the editor that has created the context.
+		 *
+		 * @private
+		 * @type {module:core/editor/editor~Editor|null}
+		 */
+		this._contextOwner = null;
+	}
+
+	/**
+	 * Loads and initializes plugins specified in the config.
+	 *
+	 * @returns {Promise.<module:core/plugin~LoadedPlugins>} A promise which resolves
+	 * once the initialization is completed providing an array of loaded plugins.
+	 */
+	initPlugins() {
+		const plugins = this.config.get( 'plugins' ) || [];
+
+		for ( const Plugin of plugins ) {
+			if ( typeof Plugin != 'function' ) {
+				/**
+				 * Only constructor is allowed as a {@link module:core/contextplugin~ContextPlugin}.
+				 *
+				 * @error context-initplugins-constructor-only
+				 */
+				throw new CKEditorError(
+					'context-initplugins-constructor-only: Only constructor is allowed as a Context plugin.',
+					null,
+					{ Plugin }
+				);
+			}
+
+			if ( Plugin.isContextPlugin !== true ) {
+				/**
+				 * Only plugin marked as a {@link module:core/contextplugin~ContextPlugin} is allowed to be used with a context.
+				 *
+				 * @error context-initplugins-invalid-plugin
+				 */
+				throw new CKEditorError(
+					'context-initplugins-invalid-plugin: Only plugin marked as a ContextPlugin is allowed.',
+					null,
+					{ Plugin }
+				);
+			}
+		}
+
+		return this.plugins.init( plugins );
+	}
+
+	/**
+	 * Destroys the context instance, and all editors used with the context.
+	 * Releasing all resources used by the context.
+	 *
+	 * @returns {Promise} A promise that resolves once the context instance is fully destroyed.
+	 */
+	destroy() {
+		return Promise.all( Array.from( this._editors, editor => editor.destroy() ) )
+			.then( () => this.plugins.destroy() );
+	}
+
+	/**
+	 * Adds a reference to the editor which is used with this context.
+	 *
+	 * When the given editor has created the context then the reference to this editor will be stored
+	 * as a {@link ~Context#_contextOwner}.
+	 *
+	 * This method should be used only by the editor.
+	 *
+	 * @protected
+	 * @param {module:core/editor/editor~Editor} editor
+	 * @param {Boolean} isContextOwner Stores the given editor as a context owner.
+	 */
+	_addEditor( editor, isContextOwner ) {
+		if ( this._contextOwner ) {
+			/**
+			 * Cannot add multiple editors to the context which is created by the editor.
+			 *
+			 * @error context-addEditor-private-context
+			 */
+			throw new CKEditorError(
+				'context-addEditor-private-context: Cannot add multiple editors to the context which is created by the editor.'
+			);
+		}
+
+		this._editors.add( editor );
+
+		if ( isContextOwner ) {
+			this._contextOwner = editor;
+		}
+	}
+
+	/**
+	 * Removes a reference to the editor which was used with this context.
+	 * When the context was created by the given editor then the context will be destroyed.
+	 *
+	 * This method should be used only by the editor.
+	 *
+	 * @protected
+	 * @param {module:core/editor/editor~Editor} editor
+	 * @return {Promise} A promise that resolves once the editor is removed from the context or when the context has been destroyed.
+	 */
+	_removeEditor( editor ) {
+		this._editors.delete( editor );
+
+		if ( this._contextOwner === editor ) {
+			return this.destroy();
+		}
+
+		return Promise.resolve();
+	}
+
+	/**
+	 * Returns context configuration which will be copied to editors created using this context.
+	 *
+	 * The configuration returned by this method has removed plugins configuration - plugins are shared with all editors
+	 * through another mechanism.
+	 *
+	 * This method should be used only by the editor.
+	 *
+	 * @protected
+	 * @returns {Object} Configuration as a plain object.
+	 */
+	_getEditorConfig() {
+		const result = {};
+
+		for ( const name of this.config.names() ) {
+			if ( ![ 'plugins', 'removePlugins', 'extraPlugins' ].includes( name ) ) {
+				result[ name ] = this.config.get( name );
+			}
+		}
+
+		return result;
+	}
+
+	/**
+	 * Creates and initializes a new context instance.
+	 *
+	 *		const commonConfig = { ... }; // Configuration for all the plugins and editors.
+	 *		const editorPlugins = [ ... ]; // Regular `Plugin`s here.
+	 *
+	 *		Context
+	 *			.create( {
+	 *				// Only `ContextPlugin`s here.
+	 *				plugins: [ ... ],
+	 *
+	 *				// Configure language for all the editors (it cannot be overwritten).
+	 *				language: { ... },
+	 *
+	 *				// Configuration for context plugins.
+	 *				comments: { ... },
+	 *				...
+	 *
+	 *				// Default configuration for editor plugins.
+	 *				toolbar: { ... },
+	 *				image: { ... },
+	 *				...
+	 *			} )
+	 *			.then( context => {
+	 *				const promises = [];
+	 *
+	 *				promises.push( ClassicEditor.create(
+	 *					document.getElementById( 'editor1' ),
+	 *					{
+	 *						editorPlugins,
+	 *						context
+	 *					}
+	 *				) );
+	 *
+	 *				promises.push( ClassicEditor.create(
+	 *					document.getElementById( 'editor2' ),
+	 *					{
+	 *						editorPlugins,
+	 *						context,
+	 *						toolbar: { ... } // You can overwrite context's configuration.
+	 *					}
+	 *				) );
+	 *
+	 *				return Promise.all( promises );
+	 *			} );
+	 *
+	 * @param {Object} [config] The context config.
+	 * @returns {Promise} A promise resolved once the context is ready. The promise resolves with the created context instance.
+	 */
+	static create( config ) {
+		return new Promise( resolve => {
+			const context = new this( config );
+
+			resolve( context.initPlugins().then( () => context ) );
+		} );
+	}
+}

+ 61 - 0
packages/ckeditor5-core/src/contextplugin.js

@@ -0,0 +1,61 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module core/contextplugin
+ */
+
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+
+/**
+ * The base class for {@link module:core/context~Context} plugin classes.
+ *
+ * A context plugin can either be initialized for an {@link module:core/editor/editor~Editor editor} or for
+ * a {@link module:core/context~Context context}. In other words, it can either
+ * work within one editor instance or with one or more editor instances that use a single context.
+ * It is the context plugin's role to implement handling for both modes.
+ *
+ * A couple of rules for interaction between editor plugins and context plugins:
+ *
+ * * a context plugin can require another context plugin,
+ * * an {@link module:core/plugin~Plugin editor plugin} can require a context plugin,
+ * * a context plugin MUST NOT require an {@link module:core/plugin~Plugin editor plugin}.
+ *
+ * @implements module:core/plugin~PluginInterface
+ * @mixes module:utils/observablemixin~ObservableMixin
+ */
+export default class ContextPlugin {
+	/**
+	 * Creates a new plugin instance.
+	 *
+	 * @param {module:core/context~Context|module:core/editor/editor~Editor} context
+	 */
+	constructor( context ) {
+		/**
+		 * The context instance.
+		 *
+		 * @readonly
+		 * @type {module:core/context~Context|module:core/editor/editor~Editor}
+		 */
+		this.context = context;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		this.stopListening();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get isContextPlugin() {
+		return true;
+	}
+}
+
+mix( ContextPlugin, ObservableMixin );

+ 33 - 25
packages/ckeditor5-core/src/editor/editor.js

@@ -7,11 +7,11 @@
  * @module core/editor/editor
  */
 
+import Context from '../context';
 import Config from '@ckeditor/ckeditor5-utils/src/config';
 import EditingController from '@ckeditor/ckeditor5-engine/src/controller/editingcontroller';
 import PluginCollection from '../plugincollection';
 import CommandCollection from '../commandcollection';
-import Locale from '@ckeditor/ckeditor5-utils/src/locale';
 import DataController from '@ckeditor/ckeditor5-engine/src/controller/datacontroller';
 import Conversion from '@ckeditor/ckeditor5-engine/src/conversion/conversion';
 import Model from '@ckeditor/ckeditor5-engine/src/model/model';
@@ -48,9 +48,19 @@ export default class Editor {
 	 *
 	 * Usually, not to be used directly. See the static {@link module:core/editor/editor~Editor.create `create()`} method.
 	 *
-	 * @param {Object} [config] The editor config.
+	 * @param {Object} [config={}] The editor config.
 	 */
-	constructor( config ) {
+	constructor( config = {} ) {
+		/**
+		 * The editor context.
+		 * When it is not provided through the configuration then the editor creates it.
+		 *
+		 * @protected
+		 * @type {module:core/context~Context}
+		 */
+		this._context = config.context || new Context( { language: config.language } );
+		this._context._addEditor( this, !config.context );
+
 		const availablePlugins = this.constructor.builtinPlugins;
 
 		/**
@@ -63,8 +73,8 @@ export default class Editor {
 		 * @member {module:utils/config~Config}
 		 */
 		this.config = new Config( config, this.constructor.defaultConfig );
-
 		this.config.define( 'plugins', availablePlugins );
+		this.config.define( this._context._getEditorConfig() );
 
 		/**
 		 * The plugins loaded and in use by this editor instance.
@@ -74,7 +84,21 @@ export default class Editor {
 		 * @readonly
 		 * @member {module:core/plugincollection~PluginCollection}
 		 */
-		this.plugins = new PluginCollection( this, availablePlugins );
+		this.plugins = new PluginCollection( this, availablePlugins, this._context.plugins );
+
+		/**
+		 * @readonly
+		 * @type {module:utils/locale~Locale}
+		 */
+		this.locale = this._context.locale;
+
+		/**
+		 * Shorthand for {@link module:utils/locale~Locale#t}.
+		 *
+		 * @see module:utils/locale~Locale#t
+		 * @method #t
+		 */
+		this.t = this.locale.t;
 
 		/**
 		 * Commands registered to the editor.
@@ -92,25 +116,6 @@ export default class Editor {
 		 */
 		this.commands = new CommandCollection();
 
-		const languageConfig = this.config.get( 'language' ) || {};
-
-		/**
-		 * @readonly
-		 * @member {module:utils/locale~Locale}
-		 */
-		this.locale = new Locale( {
-			uiLanguage: typeof languageConfig === 'string' ? languageConfig : languageConfig.ui,
-			contentLanguage: this.config.get( 'language.content' )
-		} );
-
-		/**
-		 * Shorthand for {@link module:utils/locale~Locale#t}.
-		 *
-		 * @see module:utils/locale~Locale#t
-		 * @method #t
-		 */
-		this.t = this.locale.t;
-
 		/**
 		 * Indicates the editor life-cycle state.
 		 *
@@ -257,7 +262,10 @@ export default class Editor {
 				this.data.destroy();
 				this.editing.destroy();
 				this.keystrokes.destroy();
-			} );
+			} )
+			// Remove the editor from the context.
+			// When the context was created by this editor then then the context will be destroyed.
+			.then( () => this._context._removeEditor( this ) );
 	}
 
 	/**

+ 3 - 3
packages/ckeditor5-core/src/pendingactions.js

@@ -7,7 +7,7 @@
  * @module core/pendingactions
  */
 
-import Plugin from './plugin';
+import ContextPlugin from './contextplugin';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -50,9 +50,9 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * and by features like {@link module:autosave/autosave~Autosave} to detect whether there are any ongoing actions.
  * Read more about saving the data in the {@glink builds/guides/integration/saving-data Saving and getting data} guide.
  *
- * @extends module:core/plugin~Plugin
+ * @extends module:core/contextplugin~ContextPlugin
  */
-export default class PendingActions extends Plugin {
+export default class PendingActions extends ContextPlugin {
 	/**
 	 * @inheritDoc
 	 */

+ 15 - 0
packages/ckeditor5-core/src/plugin.js

@@ -46,6 +46,13 @@ export default class Plugin {
 	destroy() {
 		this.stopListening();
 	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get isContextPlugin() {
+		return false;
+	}
 }
 
 mix( Plugin, ObservableMixin );
@@ -181,6 +188,14 @@ mix( Plugin, ObservableMixin );
  */
 
 /**
+ * A flag which defines if plugin is allowed or not allowed to be use directly by a {@link module:core/context~Context}.
+ *
+ * @static
+ * @readonly
+ * @member {Boolean} module:core/plugin~PluginInterface.isContextPlugin
+ */
+
+/**
  * Array of loaded plugins.
  *
  * @typedef {Array.<module:core/plugin~PluginInterface>} module:core/plugin~LoadedPlugins

+ 67 - 21
packages/ckeditor5-core/src/plugincollection.js

@@ -23,37 +23,56 @@ export default class PluginCollection {
 	/**
 	 * Creates an instance of the PluginCollection class.
 	 * Allows loading and initializing plugins and their dependencies.
+	 * Allows to provide a list of already loaded plugins, these plugins won't be destroyed along with this collection.
 	 *
-	 * @param {module:core/editor/editor~Editor} editor
+	 * @param {module:core/editor/editor~Editor|module:core/context~Context} context
 	 * @param {Array.<Function>} [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.<Array>} contextPlugins List of already initialized plugins represented by a
+	 * `[ PluginConstructor, pluginInstance ]` pair.
 	 */
-	constructor( editor, availablePlugins = [] ) {
+	constructor( context, availablePlugins = [], contextPlugins = [] ) {
 		/**
 		 * @protected
-		 * @member {module:core/editor/editor~Editor} module:core/plugin~PluginCollection#_editor
+		 * @type {module:core/editor/editor~Editor|module:core/context~Context}
 		 */
-		this._editor = editor;
+		this._context = context;
+
+		/**
+		 * @protected
+		 * @type {Map}
+		 */
+		this._plugins = new Map();
 
 		/**
 		 * Map of plugin constructors which can be retrieved by their names.
 		 *
 		 * @protected
-		 * @member {Map.<String|Function,Function>} module:core/plugin~PluginCollection#_availablePlugins
+		 * @type {Map.<String|Function,Function>}
 		 */
 		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
-		 * @member {Map} module:core/plugin~PluginCollection#_plugins
+		 * @type {Map<Function,Function>}
 		 */
-		this._plugins = new Map();
+		this._contextPlugins = new Map();
 
-		for ( const PluginConstructor of availablePlugins ) {
-			this._availablePlugins.set( PluginConstructor, PluginConstructor );
+		for ( const [ PluginConstructor, pluginInstance ] of contextPlugins ) {
+			this._contextPlugins.set( PluginConstructor, pluginInstance );
+			this._contextPlugins.set( pluginInstance, PluginConstructor );
 
+			// To make it possible to require plugin by its name.
 			if ( PluginConstructor.pluginName ) {
 				this._availablePlugins.set( PluginConstructor.pluginName, PluginConstructor );
 			}
@@ -120,7 +139,7 @@ export default class PluginCollection {
 				pluginName = key.pluginName || key.name;
 			}
 
-			throw new CKEditorError( errorMsg, this._editor, { plugin: pluginName } );
+			throw new CKEditorError( errorMsg, this._context, { plugin: pluginName } );
 		}
 
 		return plugin;
@@ -157,7 +176,7 @@ export default class PluginCollection {
 	 */
 	init( plugins, removePlugins = [] ) {
 		const that = this;
-		const editor = this._editor;
+		const context = this._context;
 		const loading = new Set();
 		const loaded = [];
 
@@ -192,7 +211,7 @@ export default class PluginCollection {
 			// 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, this._editor, { plugins: missingPlugins } ) );
+			return Promise.reject( new CKEditorError( errorMsg, context, { plugins: missingPlugins } ) );
 		}
 
 		return Promise.all( pluginConstructors.map( loadPlugin ) )
@@ -246,6 +265,10 @@ export default class PluginCollection {
 					return promise;
 				}
 
+				if ( that._contextPlugins.has( plugin ) ) {
+					return promise;
+				}
+
 				return promise.then( plugin[ method ].bind( plugin ) );
 			}, Promise.resolve() );
 		}
@@ -258,19 +281,39 @@ export default class PluginCollection {
 					PluginConstructor.requires.forEach( RequiredPluginConstructorOrName => {
 						const RequiredPluginConstructor = getPluginConstructor( RequiredPluginConstructorOrName );
 
+						if ( PluginConstructor.isContextPlugin && !RequiredPluginConstructor.isContextPlugin ) {
+							/**
+							 * If a plugin is a `ContextPlugin` all plugins it requires should also be a `ContextPlugin`,
+							 * instead of `Plugin`. In other words, if one plugin can be used in the `Context`,
+							 * all its requirements also should be ready to be used in the`Context`. Note that 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 which 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 {Function} plugin The required plugin.
-							 * @param {Function} requiredBy The parent plugin.
+							 * @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.',
-								editor,
-								{ plugin: RequiredPluginConstructor, requiredBy: PluginConstructor }
+								context,
+								{ plugin: RequiredPluginConstructor.name, requiredBy: PluginConstructor.name }
 							);
 						}
 
@@ -278,7 +321,7 @@ export default class PluginCollection {
 					} );
 				}
 
-				const plugin = new PluginConstructor( editor );
+				const plugin = that._contextPlugins.get( PluginConstructor ) || new PluginConstructor( context );
 				that._add( PluginConstructor, plugin );
 				loaded.push( plugin );
 
@@ -319,10 +362,13 @@ export default class PluginCollection {
 	 * @returns {Promise}
 	 */
 	destroy() {
-		const promises = Array.from( this )
-			.map( ( [ , pluginInstance ] ) => pluginInstance )
-			.filter( pluginInstance => typeof pluginInstance.destroy == 'function' )
-			.map( pluginInstance => pluginInstance.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 );
 	}

+ 264 - 0
packages/ckeditor5-core/tests/context.js

@@ -0,0 +1,264 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Context from '../src/context';
+import ContextPlugin from '../src/contextplugin';
+import Plugin from '../src/plugin';
+import Config from '@ckeditor/ckeditor5-utils/src/config';
+import Locale from '@ckeditor/ckeditor5-utils/src/locale';
+import VirtualTestEditor from './_utils/virtualtesteditor';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'Context', () => {
+	describe( 'config', () => {
+		it( 'should be created', () => {
+			const context = new Context();
+
+			expect( context.config ).to.instanceof( Config );
+		} );
+
+		it( 'should be with given configuration', () => {
+			const context = new Context( { foo: 'bar' } );
+
+			expect( context.config.get( 'foo' ) ).to.equal( 'bar' );
+		} );
+	} );
+
+	describe( '_getEditorConfig()', () => {
+		it( 'should return the configuration without plugin config', () => {
+			class FooPlugin extends ContextPlugin {}
+			class BarPlugin extends ContextPlugin {}
+			class BomPlugin extends ContextPlugin {}
+
+			const context = new Context( {
+				language: { ui: 'pl', content: 'ar' },
+				plugins: [ FooPlugin, BarPlugin ],
+				extraPlugins: [ BomPlugin ],
+				removePlugins: [ FooPlugin ],
+				foo: 1,
+				bar: 'bom'
+			} );
+
+			expect( context._getEditorConfig() ).to.be.deep.equal( {
+				language: { ui: 'pl', content: 'ar' },
+				foo: 1,
+				bar: 'bom'
+			} );
+		} );
+	} );
+
+	describe( 'locale', () => {
+		it( 'is instantiated and t() is exposed', () => {
+			const context = new Context();
+
+			expect( context.locale ).to.be.instanceof( Locale );
+			expect( context.t ).to.equal( context.locale.t );
+		} );
+
+		it( 'is configured with the config.language (UI and the content)', () => {
+			const context = new Context( { language: 'pl' } );
+
+			expect( context.locale.uiLanguage ).to.equal( 'pl' );
+			expect( context.locale.contentLanguage ).to.equal( 'pl' );
+		} );
+
+		it( 'is configured with the config.language (different for UI and the content)', () => {
+			const context = new Context( { language: { ui: 'pl', content: 'ar' } } );
+
+			expect( context.locale.uiLanguage ).to.equal( 'pl' );
+			expect( context.locale.contentLanguage ).to.equal( 'ar' );
+		} );
+
+		it( 'is configured with the config.language (just the content)', () => {
+			const context = new Context( { language: { content: 'ar' } } );
+
+			expect( context.locale.uiLanguage ).to.equal( 'en' );
+			expect( context.locale.contentLanguage ).to.equal( 'ar' );
+		} );
+	} );
+
+	describe( 'plugins', () => {
+		it( 'should throw when plugin added to the context is not marked as a ContextPlugin (Plugin)', async () => {
+			class EditorPlugin extends Plugin {}
+
+			let caughtError;
+
+			try {
+				await Context.create( { plugins: [ EditorPlugin ] } );
+			} catch ( error ) {
+				caughtError = error;
+			}
+
+			expect( caughtError ).to.instanceof( CKEditorError );
+			expect( caughtError.message )
+				.match( /^context-initplugins-invalid-plugin: Only plugin marked as a ContextPlugin is allowed./ );
+		} );
+
+		it( 'should throw when plugin added to the context is not marked as a ContextPlugin (Function)', async () => {
+			function EditorPlugin() {}
+
+			let caughtError;
+
+			try {
+				await Context.create( { plugins: [ EditorPlugin ] } );
+			} catch ( error ) {
+				caughtError = error;
+			}
+
+			expect( caughtError ).to.instanceof( CKEditorError );
+			expect( caughtError.message )
+				.match( /^context-initplugins-invalid-plugin: Only plugin marked as a ContextPlugin is allowed./ );
+		} );
+
+		it( 'should throw when plugin is added to the context by name', async () => {
+			let caughtError;
+
+			try {
+				await Context.create( { plugins: [ 'ContextPlugin' ] } );
+			} catch ( error ) {
+				caughtError = error;
+			}
+
+			expect( caughtError ).to.instanceof( CKEditorError );
+			expect( caughtError.message )
+				.match( /^context-initplugins-constructor-only: Only constructor is allowed as a Context plugin./ );
+		} );
+
+		it( 'should not throw when plugin as a function, marked as a ContextPlugin is added to the context', async () => {
+			function EditorPlugin() {}
+			EditorPlugin.isContextPlugin = true;
+
+			let caughtError;
+
+			try {
+				await Context.create( { plugins: [ EditorPlugin ] } );
+			} catch ( error ) {
+				caughtError = error;
+			}
+
+			expect( caughtError ).to.equal( undefined );
+		} );
+
+		it( 'should share the same instance of plugin within editors using the same context', async () => {
+			class ContextPluginA extends ContextPlugin {}
+			class ContextPluginB extends ContextPlugin {}
+			class EditorPluginA extends Plugin {}
+
+			const context = await Context.create( { plugins: [ ContextPluginA, ContextPluginB ] } );
+			const editorA = await VirtualTestEditor.create( { context, plugins: [ ContextPluginA, EditorPluginA ] } );
+			const editorB = await VirtualTestEditor.create( { context, plugins: [ ContextPluginB, EditorPluginA ] } );
+
+			expect( editorA.plugins.get( ContextPluginA ) ).to.equal( context.plugins.get( ContextPluginA ) );
+			expect( editorA.plugins.has( ContextPluginB ) ).to.equal( false );
+			expect( editorB.plugins.get( ContextPluginB ) ).to.equal( context.plugins.get( ContextPluginB ) );
+			expect( editorB.plugins.has( ContextPluginA ) ).to.equal( false );
+
+			expect( context.plugins.has( EditorPluginA ) ).to.equal( false );
+			expect( editorA.plugins.get( EditorPluginA ) ).to.not.equal( editorB.plugins.get( EditorPluginA ) );
+
+			await context.destroy();
+		} );
+
+		it( 'should share the same instance of plugin (dependencies) within editors using the same context', async () => {
+			class ContextPluginA extends ContextPlugin {}
+			class ContextPluginB extends ContextPlugin {}
+			class EditorPluginA extends Plugin {
+				static get requires() {
+					return [ ContextPluginA ];
+				}
+			}
+			class EditorPluginB extends Plugin {
+				static get requires() {
+					return [ ContextPluginB ];
+				}
+			}
+
+			const context = await Context.create( { plugins: [ ContextPluginA, ContextPluginB ] } );
+			const editorA = await VirtualTestEditor.create( { context, plugins: [ EditorPluginA ] } );
+			const editorB = await VirtualTestEditor.create( { context, plugins: [ EditorPluginB ] } );
+
+			expect( context.plugins.get( ContextPluginA ) ).to.equal( editorA.plugins.get( ContextPluginA ) );
+			expect( context.plugins.get( ContextPluginB ) ).to.equal( editorB.plugins.get( ContextPluginB ) );
+
+			await context.destroy();
+		} );
+
+		it( 'should not initialize twice plugin added to the context and the editor', async () => {
+			const initSpy = sinon.spy();
+			const afterInitSpy = sinon.spy();
+
+			class ContextPluginA extends ContextPlugin {
+				init() {
+					initSpy();
+				}
+
+				afterInit() {
+					afterInitSpy();
+				}
+			}
+
+			const context = await Context.create( { plugins: [ ContextPluginA ] } );
+			const editor = await VirtualTestEditor.create( { context, plugins: [ ContextPluginA ] } );
+
+			expect( context.plugins.get( ContextPluginA ) ).to.equal( editor.plugins.get( ContextPluginA ) );
+			sinon.assert.calledOnce( initSpy );
+			sinon.assert.calledOnce( afterInitSpy );
+
+			await context.destroy();
+		} );
+
+		it( 'should be able to add context plugin to the editor using pluginName property', async () => {
+			class ContextPluginA extends ContextPlugin {
+				static get pluginName() {
+					return 'ContextPluginA';
+				}
+			}
+
+			class ContextPluginB extends ContextPlugin {
+				static get pluginName() {
+					return 'ContextPluginB';
+				}
+
+				static get requires() {
+					return [ ContextPluginA ];
+				}
+			}
+
+			const context = await Context.create( { plugins: [ ContextPluginB ] } );
+			const editor = await VirtualTestEditor.create( { context, plugins: [ 'ContextPluginA' ] } );
+
+			expect( editor.plugins.has( ContextPluginA ) ).to.equal( true );
+			expect( editor.plugins.has( ContextPluginB ) ).to.equal( false );
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		it( 'should destroy plugins', async () => {
+			const context = await Context.create();
+			const spy = sinon.spy( context.plugins, 'destroy' );
+
+			await context.destroy();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should destroy all editors with injected context', async () => {
+			const context = await Context.create();
+			const editorA = await VirtualTestEditor.create( { context } );
+			const editorB = await VirtualTestEditor.create( { context } );
+			const editorC = await VirtualTestEditor.create();
+
+			sinon.spy( editorA, 'destroy' );
+			sinon.spy( editorB, 'destroy' );
+			sinon.spy( editorC, 'destroy' );
+
+			await context.destroy();
+
+			sinon.assert.calledOnce( editorA.destroy );
+			sinon.assert.calledOnce( editorB.destroy );
+			sinon.assert.notCalled( editorC.destroy );
+		} );
+	} );
+} );

+ 39 - 0
packages/ckeditor5-core/tests/contextplugin.js

@@ -0,0 +1,39 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ContextPlugin from '../src/contextplugin';
+
+describe( 'ContextPlugin', () => {
+	const contextMock = {};
+
+	it( 'should be marked as a context plugin', () => {
+		expect( ContextPlugin.isContextPlugin ).to.true;
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should set the `context` property', () => {
+			const plugin = new ContextPlugin( contextMock );
+
+			expect( plugin ).to.have.property( 'context' ).to.equal( contextMock );
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		it( 'should be defined', () => {
+			const plugin = new ContextPlugin( contextMock );
+
+			expect( plugin.destroy ).to.be.a( 'function' );
+		} );
+
+		it( 'should stop listening', () => {
+			const plugin = new ContextPlugin( contextMock );
+			const stopListeningSpy = sinon.spy( plugin, 'stopListening' );
+
+			plugin.destroy();
+
+			sinon.assert.calledOnce( stopListeningSpy );
+		} );
+	} );
+} );

+ 103 - 21
packages/ckeditor5-core/tests/editor/editor.js

@@ -3,9 +3,10 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals window, setTimeout */
+/* globals document, window, setTimeout */
 
 import Editor from '../../src/editor/editor';
+import Context from '../../src/context';
 import Plugin from '../../src/plugin';
 import Config from '@ckeditor/ckeditor5-utils/src/config';
 import EditingController from '@ckeditor/ckeditor5-engine/src/controller/editingcontroller';
@@ -180,41 +181,122 @@ describe( 'Editor', () => {
 		} );
 	} );
 
-	describe( 'plugins', () => {
-		it( 'should be empty on new editor', () => {
+	describe( 'context integration', () => {
+		it( 'should create a new context when it is not provided through config', () => {
 			const editor = new TestEditor();
 
-			expect( getPlugins( editor ) ).to.be.empty;
+			expect( editor._context ).to.be.an.instanceof( Context );
 		} );
-	} );
 
-	describe( 'locale', () => {
-		it( 'is instantiated and t() is exposed', () => {
+		it( 'should use context given through config', async () => {
+			const context = await Context.create();
+			const editor = new TestEditor( { context } );
+
+			expect( editor._context ).to.equal( context );
+		} );
+
+		it( 'should throw when try to use context created by one editor with the other editor', () => {
 			const editor = new TestEditor();
 
-			expect( editor.locale ).to.be.instanceof( Locale );
-			expect( editor.t ).to.equal( editor.locale.t );
+			expectToThrowCKEditorError( () => {
+				// eslint-disable-next-line no-new
+				new TestEditor( { context: editor._context } );
+			}, /^context-addEditor-private-context/ );
+		} );
+
+		it( 'should destroy context created by the editor at the end of the editor destroy chain', async () => {
+			const editor = await TestEditor.create();
+			const editorPluginsDestroySpy = sinon.spy( editor.plugins, 'destroy' );
+			const contextDestroySpy = sinon.spy( editor._context, 'destroy' );
+
+			await editor.destroy();
+
+			sinon.assert.calledOnce( contextDestroySpy );
+			expect( editorPluginsDestroySpy.calledBefore( contextDestroySpy ) ).to.true;
+		} );
+
+		it( 'should not destroy context along with the editor when context was injected to the editor', async () => {
+			const context = await Context.create();
+			const editor = await TestEditor.create( { context } );
+			const contextDestroySpy = sinon.spy( editor._context, 'destroy' );
+
+			await editor.destroy();
+
+			sinon.assert.notCalled( contextDestroySpy );
+		} );
+
+		it( 'should add context plugins to the editor plugins', async () => {
+			class ContextPlugin {
+				static get isContextPlugin() {
+					return true;
+				}
+			}
+
+			const context = await Context.create( { plugins: [ ContextPlugin ] } );
+			const editor = new TestEditor( { context } );
+
+			expect( editor.plugins._contextPlugins.has( ContextPlugin ) ).to.equal( true );
+		} );
+
+		it( 'should get configuration from the context', async () => {
+			const context = await Context.create( { cfoo: 'bar' } );
+			const editor = await TestEditor.create( { context } );
+
+			expect( editor.config.get( 'cfoo' ) ).to.equal( 'bar' );
+		} );
+
+		it( 'should not overwrite the default configuration', async () => {
+			const context = await Context.create( { cfoo: 'bar' } );
+			const editor = await TestEditor.create( { context, 'cfoo': 'bom' } );
+
+			expect( editor.config.get( 'cfoo' ) ).to.equal( 'bom' );
+		} );
+
+		it( 'should not copy plugins configuration', async () => {
+			class ContextPlugin {
+				static get isContextPlugin() {
+					return true;
+				}
+			}
+
+			const context = await Context.create( { plugins: [ ContextPlugin ] } );
+			const editor = await TestEditor.create( { context } );
+
+			expect( editor.config.get( 'plugins' ) ).to.be.undefined;
+		} );
+
+		it( 'should pass DOM element using reference, not copy', async () => {
+			const element = document.createElement( 'div' );
+			const context = await Context.create( { efoo: element } );
+			const editor = await TestEditor.create( { context } );
+
+			expect( editor.config.get( 'efoo' ) ).to.equal( element );
 		} );
+	} );
 
-		it( 'is configured with the config.language (UI and the content)', () => {
-			const editor = new TestEditor( { language: 'pl' } );
+	describe( 'plugins', () => {
+		it( 'should be empty on new editor', () => {
+			const editor = new TestEditor();
 
-			expect( editor.locale.uiLanguage ).to.equal( 'pl' );
-			expect( editor.locale.contentLanguage ).to.equal( 'pl' );
+			expect( getPlugins( editor ) ).to.be.empty;
 		} );
+	} );
 
-		it( 'is configured with the config.language (different for UI and the content)', () => {
-			const editor = new TestEditor( { language: { ui: 'pl', content: 'ar' } } );
+	describe( 'locale', () => {
+		it( 'should use Context#locale and Context#t', () => {
+			const editor = new TestEditor();
 
-			expect( editor.locale.uiLanguage ).to.equal( 'pl' );
-			expect( editor.locale.contentLanguage ).to.equal( 'ar' );
+			expect( editor.locale ).to.equal( editor._context.locale ).to.instanceof( Locale );
+			expect( editor.t ).to.equal( editor._context.t );
 		} );
 
-		it( 'is configured with the config.language (just the content)', () => {
-			const editor = new TestEditor( { language: { content: 'ar' } } );
+		it( 'should use locale instance with a proper configuration', () => {
+			const editor = new TestEditor( {
+				language: 'pl'
+			} );
 
-			expect( editor.locale.uiLanguage ).to.equal( 'en' );
-			expect( editor.locale.contentLanguage ).to.equal( 'ar' );
+			expect( editor.locale ).to.have.property( 'uiLanguage', 'pl' );
+			expect( editor.locale ).to.have.property( 'contentLanguage', 'pl' );
 		} );
 	} );
 

+ 4 - 0
packages/ckeditor5-core/tests/pendingactions.js

@@ -27,6 +27,10 @@ describe( 'PendingActions', () => {
 		expect( PendingActions ).to.have.property( 'pluginName', 'PendingActions' );
 	} );
 
+	it( 'should be marked as a context plugin', () => {
+		expect( PendingActions.isContextPlugin ).to.true;
+	} );
+
 	describe( 'init()', () => {
 		it( 'should have hasAny observable', () => {
 			const spy = sinon.spy();

+ 15 - 11
packages/ckeditor5-core/tests/plugin.js

@@ -13,28 +13,32 @@ describe( 'Plugin', () => {
 		editor = new Editor();
 	} );
 
+	it( 'should not be marked as a context plugin', () => {
+		expect( Plugin.isContextPlugin ).to.false;
+	} );
+
 	describe( 'constructor()', () => {
 		it( 'should set the `editor` property', () => {
 			const plugin = new Plugin( editor );
 
 			expect( plugin ).to.have.property( 'editor' ).to.equal( editor );
 		} );
+	} );
 
-		describe( 'destroy()', () => {
-			it( 'should be defined', () => {
-				const plugin = new Plugin( editor );
+	describe( 'destroy()', () => {
+		it( 'should be defined', () => {
+			const plugin = new Plugin( editor );
 
-				expect( plugin.destroy ).to.be.a( 'function' );
-			} );
+			expect( plugin.destroy ).to.be.a( 'function' );
+		} );
 
-			it( 'should stop listening', () => {
-				const plugin = new Plugin( editor );
-				const stopListeningSpy = sinon.spy( plugin, 'stopListening' );
+		it( 'should stop listening', () => {
+			const plugin = new Plugin( editor );
+			const stopListeningSpy = sinon.spy( plugin, 'stopListening' );
 
-				plugin.destroy();
+			plugin.destroy();
 
-				expect( stopListeningSpy.calledOnce ).to.equal( true );
-			} );
+			expect( stopListeningSpy.calledOnce ).to.equal( true );
 		} );
 	} );
 } );

+ 80 - 0
packages/ckeditor5-core/tests/plugincollection.js

@@ -8,6 +8,7 @@
 import Editor from '../src/editor/editor';
 import PluginCollection from '../src/plugincollection';
 import Plugin from '../src/plugin';
+import ContextPlugin from '../src/contextplugin';
 import { expectToThrowCKEditorError, assertCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 let editor, availablePlugins;
@@ -338,6 +339,51 @@ describe( 'PluginCollection', () => {
 				} );
 		} );
 
+		it( 'should throw when context plugin requires not a context plugin', async () => {
+			class FooContextPlugin extends ContextPlugin {}
+			FooContextPlugin.requires = [ PluginA ];
+
+			const plugins = new PluginCollection( editor, [ FooContextPlugin, PluginA ] );
+
+			const consoleErrorStub = sinon.stub( console, 'error' );
+			let error;
+
+			try {
+				await plugins.init( [ FooContextPlugin ] );
+			} catch ( err ) {
+				error = err;
+			}
+
+			assertCKEditorError( error, /^plugincollection-context-required/ );
+			sinon.assert.calledOnce( consoleErrorStub );
+		} );
+
+		it( 'should not throw when non context plugin requires context plugin', async () => {
+			class FooContextPlugin extends ContextPlugin {}
+
+			class BarPlugin extends Plugin {}
+			BarPlugin.requires = [ FooContextPlugin ];
+
+			const plugins = new PluginCollection( editor, [ FooContextPlugin, BarPlugin ] );
+
+			await plugins.init( [ BarPlugin ] );
+
+			expect( getPlugins( plugins ) ).to.length( 2 );
+		} );
+
+		it( 'should not throw when context plugin requires context plugin', async () => {
+			class FooContextPlugin extends ContextPlugin {}
+
+			class BarContextPlugin extends ContextPlugin {}
+			BarContextPlugin.requires = [ FooContextPlugin ];
+
+			const plugins = new PluginCollection( editor, [ FooContextPlugin, BarContextPlugin ] );
+
+			await plugins.init( [ BarContextPlugin ] );
+
+			expect( getPlugins( plugins ) ).to.length( 2 );
+		} );
+
 		it( 'should reject when loaded plugin requires not allowed plugins', () => {
 			const consoleErrorStub = sinon.stub( console, 'error' );
 			const plugins = new PluginCollection( editor, availablePlugins );
@@ -405,6 +451,40 @@ describe( 'PluginCollection', () => {
 					sinon.assert.calledOnce( consoleErrorStub );
 				} );
 		} );
+
+		it( 'should get plugin from external plugins instead of creating new instance', async () => {
+			const externalPlugins = new PluginCollection( editor );
+			await externalPlugins.init( [ PluginA, PluginB ] );
+
+			const plugins = new PluginCollection( editor, [], Array.from( externalPlugins ) );
+			await plugins.init( [ PluginA ] );
+
+			expect( getPlugins( plugins ) ).to.length( 1 );
+			expect( plugins.get( PluginA ) ).to.equal( externalPlugins.get( PluginA ) ).to.instanceof( PluginA );
+		} );
+
+		it( 'should get plugin by name from external plugins instead of creating new instance', async () => {
+			const externalPlugins = new PluginCollection( editor );
+			await externalPlugins.init( [ PluginA, PluginB ] );
+
+			const plugins = new PluginCollection( editor, [], Array.from( externalPlugins ) );
+			await plugins.init( [ 'A' ] );
+
+			expect( getPlugins( plugins ) ).to.length( 1 );
+			expect( plugins.get( PluginA ) ).to.equal( externalPlugins.get( PluginA ) ).to.instanceof( PluginA );
+		} );
+
+		it( 'should get dependency of plugin from external plugins instead of creating new instance', async () => {
+			const externalPlugins = new PluginCollection( editor );
+			await externalPlugins.init( [ PluginA, PluginB ] );
+
+			const plugins = new PluginCollection( editor, [], Array.from( externalPlugins ) );
+			await plugins.init( [ PluginC ] );
+
+			expect( getPlugins( plugins ) ).to.length( 2 );
+			expect( plugins.get( PluginB ) ).to.equal( externalPlugins.get( PluginB ) ).to.instanceof( PluginB );
+			expect( plugins.get( PluginC ) ).to.instanceof( PluginC );
+		} );
 	} );
 
 	describe( 'get()', () => {