UNPKG

22.1 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5
6/**
7 * @module core/plugincollection
8 */
9
10import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
11import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
12import mix from '@ckeditor/ckeditor5-utils/src/mix';
13
14/**
15 * Manages a list of CKEditor plugins, including loading, resolving dependencies and initialization.
16 *
17 * @mixes module:utils/emittermixin~EmitterMixin
18 */
19export default class PluginCollection {
20 /**
21 * Creates an instance of the plugin collection class.
22 * Allows loading and initializing plugins and their dependencies.
23 * Allows providing a list of already loaded plugins. These plugins will not be destroyed along with this collection.
24 *
25 * @param {module:core/editor/editor~Editor|module:core/context~Context} context
26 * @param {Array.<Function>} [availablePlugins] Plugins (constructors) which the collection will be able to use
27 * when {@link module:core/plugincollection~PluginCollection#init} is used with the plugin names (strings, instead of constructors).
28 * Usually, the editor will pass its built-in plugins to the collection so they can later be
29 * used in `config.plugins` or `config.removePlugins` by names.
30 * @param {Iterable.<Array>} contextPlugins A list of already initialized plugins represented by a
31 * `[ PluginConstructor, pluginInstance ]` pair.
32 */
33 constructor( context, availablePlugins = [], contextPlugins = [] ) {
34 /**
35 * @protected
36 * @type {module:core/editor/editor~Editor|module:core/context~Context}
37 */
38 this._context = context;
39
40 /**
41 * @protected
42 * @type {Map}
43 */
44 this._plugins = new Map();
45
46 /**
47 * A map of plugin constructors that can be retrieved by their names.
48 *
49 * @protected
50 * @type {Map.<String|Function,Function>}
51 */
52 this._availablePlugins = new Map();
53
54 for ( const PluginConstructor of availablePlugins ) {
55 if ( PluginConstructor.pluginName ) {
56 this._availablePlugins.set( PluginConstructor.pluginName, PluginConstructor );
57 }
58 }
59
60 /**
61 * Map of {@link module:core/contextplugin~ContextPlugin context plugins} which can be retrieved by their constructors or instances.
62 *
63 * @protected
64 * @type {Map<Function,Function>}
65 */
66 this._contextPlugins = new Map();
67
68 for ( const [ PluginConstructor, pluginInstance ] of contextPlugins ) {
69 this._contextPlugins.set( PluginConstructor, pluginInstance );
70 this._contextPlugins.set( pluginInstance, PluginConstructor );
71
72 // To make it possible to require a plugin by its name.
73 if ( PluginConstructor.pluginName ) {
74 this._availablePlugins.set( PluginConstructor.pluginName, PluginConstructor );
75 }
76 }
77 }
78
79 /**
80 * Iterable interface.
81 *
82 * Returns `[ PluginConstructor, pluginInstance ]` pairs.
83 *
84 * @returns {Iterable.<Array>}
85 */
86 * [ Symbol.iterator ]() {
87 for ( const entry of this._plugins ) {
88 if ( typeof entry[ 0 ] == 'function' ) {
89 yield entry;
90 }
91 }
92 }
93
94 /**
95 * Gets the plugin instance by its constructor or name.
96 *
97 * // Check if 'Clipboard' plugin was loaded.
98 * if ( editor.plugins.has( 'ClipboardPipeline' ) ) {
99 * // Get clipboard plugin instance
100 * const clipboard = editor.plugins.get( 'ClipboardPipeline' );
101 *
102 * this.listenTo( clipboard, 'inputTransformation', ( evt, data ) => {
103 * // Do something on clipboard input.
104 * } );
105 * }
106 *
107 * **Note**: This method will throw an error if a plugin is not loaded. Use `{@link #has editor.plugins.has()}`
108 * to check if a plugin is available.
109 *
110 * @param {Function|String} key The plugin constructor or {@link module:core/plugin~PluginInterface.pluginName name}.
111 * @returns {module:core/plugin~PluginInterface}
112 */
113 get( key ) {
114 const plugin = this._plugins.get( key );
115
116 if ( !plugin ) {
117 let pluginName = key;
118
119 if ( typeof key == 'function' ) {
120 pluginName = key.pluginName || key.name;
121 }
122
123 /**
124 * The plugin is not loaded and could not be obtained.
125 *
126 * Plugin classes (constructors) need to be provided to the editor and must be loaded before they can be obtained from
127 * the plugin collection.
128 * This is usually done in CKEditor 5 builds by setting the {@link module:core/editor/editor~Editor.builtinPlugins}
129 * property.
130 *
131 * **Note**: You can use `{@link module:core/plugincollection~PluginCollection#has editor.plugins.has()}`
132 * to check if a plugin was loaded.
133 *
134 * @error plugincollection-plugin-not-loaded
135 * @param {String} plugin The name of the plugin which is not loaded.
136 */
137 throw new CKEditorError( 'plugincollection-plugin-not-loaded', this._context, { plugin: pluginName } );
138 }
139
140 return plugin;
141 }
142
143 /**
144 * Checks if a plugin is loaded.
145 *
146 * // Check if the 'Clipboard' plugin was loaded.
147 * if ( editor.plugins.has( 'ClipboardPipeline' ) ) {
148 * // Now use the clipboard plugin instance:
149 * const clipboard = editor.plugins.get( 'ClipboardPipeline' );
150 *
151 * // ...
152 * }
153 *
154 * @param {Function|String} key The plugin constructor or {@link module:core/plugin~PluginInterface.pluginName name}.
155 * @returns {Boolean}
156 */
157 has( key ) {
158 return this._plugins.has( key );
159 }
160
161 /**
162 * Initializes a set of plugins and adds them to the collection.
163 *
164 * @param {Array.<Function|String>} plugins An array of {@link module:core/plugin~PluginInterface plugin constructors}
165 * or {@link module:core/plugin~PluginInterface.pluginName plugin names}.
166 * @param {Array.<String|Function>} [pluginsToRemove] Names of the plugins or plugin constructors
167 * that should not be loaded (despite being specified in the `plugins` array).
168 * @param {Array.<Function>} [pluginsSubstitutions] An array of {@link module:core/plugin~PluginInterface plugin constructors}
169 * that will be used to replace plugins of the same names that were passed in `plugins` or that are in their dependency tree.
170 * A useful option for replacing built-in plugins while creating tests (for mocking their APIs). Plugins that will be replaced
171 * must follow these rules:
172 * * The new plugin must be a class.
173 * * The new plugin must be named.
174 * * Both plugins must not depend on other plugins.
175 * @returns {Promise.<module:core/plugin~LoadedPlugins>} A promise which gets resolved once all plugins are loaded
176 * and available in the collection.
177 */
178 init( plugins, pluginsToRemove = [], pluginsSubstitutions = [] ) {
179 // Plugin initialization procedure consists of 2 main steps:
180 // 1) collecting all available plugin constructors,
181 // 2) verification whether all required plugins can be instantiated.
182 //
183 // In the first step, all plugin constructors, available in the provided `plugins` array and inside
184 // plugin's dependencies (from the `Plugin.requires` array), are recursively collected and added to the existing
185 // `this._availablePlugins` map, but without any verification at the given moment. Performing the verification
186 // at this point (during the plugin constructor searching) would cause false errors to occur, that some plugin
187 // is missing but in fact it may be defined further in the array as the dependency of other plugin. After
188 // traversing the entire dependency tree, it will be checked if all required "top level" plugins are available.
189 //
190 // In the second step, the list of plugins that have not been explicitly removed is traversed to get all the
191 // plugin constructors to be instantiated in the correct order and to validate against some rules. Finally, if
192 // no plugin is missing and no other error has been found, they all will be instantiated.
193 const that = this;
194 const context = this._context;
195
196 findAvailablePluginConstructors( plugins );
197
198 validatePlugins( plugins );
199
200 const pluginsToLoad = plugins.filter( plugin => !isPluginRemoved( plugin, pluginsToRemove ) );
201
202 const pluginConstructors = [ ...getPluginConstructors( pluginsToLoad ) ];
203
204 substitutePlugins( pluginConstructors, pluginsSubstitutions );
205
206 const pluginInstances = loadPlugins( pluginConstructors );
207
208 return initPlugins( pluginInstances, 'init' )
209 .then( () => initPlugins( pluginInstances, 'afterInit' ) )
210 .then( () => pluginInstances );
211
212 function isPluginConstructor( plugin ) {
213 return typeof plugin === 'function';
214 }
215
216 function isContextPlugin( plugin ) {
217 return isPluginConstructor( plugin ) && plugin.isContextPlugin;
218 }
219
220 function isPluginRemoved( plugin, pluginsToRemove ) {
221 return pluginsToRemove.some( removedPlugin => {
222 if ( removedPlugin === plugin ) {
223 return true;
224 }
225
226 if ( getPluginName( plugin ) === removedPlugin ) {
227 return true;
228 }
229
230 if ( getPluginName( removedPlugin ) === plugin ) {
231 return true;
232 }
233
234 return false;
235 } );
236 }
237
238 function getPluginName( plugin ) {
239 return isPluginConstructor( plugin ) ?
240 plugin.pluginName || plugin.name :
241 plugin;
242 }
243
244 function findAvailablePluginConstructors( plugins, processed = new Set() ) {
245 plugins.forEach( plugin => {
246 if ( !isPluginConstructor( plugin ) ) {
247 return;
248 }
249
250 if ( processed.has( plugin ) ) {
251 return;
252 }
253
254 processed.add( plugin );
255
256 if ( plugin.pluginName && !that._availablePlugins.has( plugin.pluginName ) ) {
257 that._availablePlugins.set( plugin.pluginName, plugin );
258 }
259
260 if ( plugin.requires ) {
261 findAvailablePluginConstructors( plugin.requires, processed );
262 }
263 } );
264 }
265
266 function getPluginConstructors( plugins, processed = new Set() ) {
267 return plugins
268 .map( plugin => {
269 return isPluginConstructor( plugin ) ?
270 plugin :
271 that._availablePlugins.get( plugin );
272 } )
273 .reduce( ( result, plugin ) => {
274 if ( processed.has( plugin ) ) {
275 return result;
276 }
277
278 processed.add( plugin );
279
280 if ( plugin.requires ) {
281 validatePlugins( plugin.requires, plugin );
282
283 getPluginConstructors( plugin.requires, processed ).forEach( plugin => result.add( plugin ) );
284 }
285
286 return result.add( plugin );
287 }, new Set() );
288 }
289
290 function validatePlugins( plugins, parentPluginConstructor = null ) {
291 plugins
292 .map( plugin => {
293 return isPluginConstructor( plugin ) ?
294 plugin :
295 that._availablePlugins.get( plugin ) || plugin;
296 } )
297 .forEach( plugin => {
298 checkMissingPlugin( plugin, parentPluginConstructor );
299 checkContextPlugin( plugin, parentPluginConstructor );
300 checkRemovedPlugin( plugin, parentPluginConstructor );
301 } );
302 }
303
304 function checkMissingPlugin( plugin, parentPluginConstructor ) {
305 if ( isPluginConstructor( plugin ) ) {
306 return;
307 }
308
309 if ( parentPluginConstructor ) {
310 /**
311 * A required "soft" dependency was not found on the plugin list.
312 *
313 * When configuring the editor, either prior to building (via
314 * {@link module:core/editor/editor~Editor.builtinPlugins `Editor.builtinPlugins`}) or when
315 * creating a new instance of the editor (e.g. via
316 * {@link module:core/editor/editorconfig~EditorConfig#plugins `config.plugins`}), you need to provide
317 * some of the dependencies for other plugins that you used.
318 *
319 * This error is thrown when one of these dependencies was not provided. The name of the missing plugin
320 * can be found in `missingPlugin` and the plugin that required it in `requiredBy`.
321 *
322 * In order to resolve it, you need to import the missing plugin and add it to the
323 * current list of plugins (`Editor.builtinPlugins` or `config.plugins`/`config.extraPlugins`).
324 *
325 * Soft requirements were introduced in version 26.0.0. If you happen to stumble upon this error
326 * when upgrading to version 26.0.0, read also the
327 * {@glink updating/migration-to-26 Migration to 26.0.0} guide.
328 *
329 * @error plugincollection-soft-required
330 * @param {String} missingPlugin The name of the required plugin.
331 * @param {String} requiredBy The name of the plugin that requires the other plugin.
332 */
333 throw new CKEditorError(
334 'plugincollection-soft-required',
335 context,
336 { missingPlugin: plugin, requiredBy: getPluginName( parentPluginConstructor ) }
337 );
338 }
339
340 /**
341 * A plugin is not available and could not be loaded.
342 *
343 * Plugin classes (constructors) need to be provided to the editor before they can be loaded by name.
344 * This is usually done in CKEditor 5 builds by setting the {@link module:core/editor/editor~Editor.builtinPlugins}
345 * property.
346 *
347 * **If you see this warning when using one of the {@glink installation/getting-started/predefined-builds
348 * CKEditor 5 Builds}**,
349 * it means that you try to enable a plugin which was not included in that build. This may be due to a typo
350 * in the plugin name or simply because that plugin is not a part of this build. In the latter scenario,
351 * read more about {@glink installation/getting-started/quick-start custom builds}.
352 *
353 * **If you see this warning when using one of the editor creators directly** (not a build), then it means
354 * that you tried loading plugins by name. However, unlike CKEditor 4, CKEditor 5 does not implement a "plugin loader".
355 * This means that CKEditor 5 does not know where to load the plugin modules from. Therefore, you need to
356 * provide each plugin through a reference (as a constructor function). Check out the examples in
357 * {@glink installation/advanced/alternative-setups/integrating-from-source "Building from source"}.
358 *
359 * @error plugincollection-plugin-not-found
360 * @param {String} plugin The name of the plugin which could not be loaded.
361 */
362 throw new CKEditorError(
363 'plugincollection-plugin-not-found',
364 context,
365 { plugin }
366 );
367 }
368
369 function checkContextPlugin( plugin, parentPluginConstructor ) {
370 if ( !isContextPlugin( parentPluginConstructor ) ) {
371 return;
372 }
373
374 if ( isContextPlugin( plugin ) ) {
375 return;
376 }
377
378 /**
379 * If a plugin is a context plugin, all plugins it requires should also be context plugins
380 * instead of plugins. In other words, if one plugin can be used in the context,
381 * all its requirements should also be ready to be used in the context. Note that the context
382 * provides only a part of the API provided by the editor. If one plugin needs a full
383 * editor API, all plugins which require it are considered as plugins that need a full
384 * editor API.
385 *
386 * @error plugincollection-context-required
387 * @param {String} plugin The name of the required plugin.
388 * @param {String} requiredBy The name of the parent plugin.
389 */
390 throw new CKEditorError(
391 'plugincollection-context-required',
392 context,
393 { plugin: getPluginName( plugin ), requiredBy: getPluginName( parentPluginConstructor ) }
394 );
395 }
396
397 function checkRemovedPlugin( plugin, parentPluginConstructor ) {
398 if ( !parentPluginConstructor ) {
399 return;
400 }
401
402 if ( !isPluginRemoved( plugin, pluginsToRemove ) ) {
403 return;
404 }
405
406 /**
407 * Cannot load a plugin because one of its dependencies is listed in the `removePlugins` option.
408 *
409 * @error plugincollection-required
410 * @param {String} plugin The name of the required plugin.
411 * @param {String} requiredBy The name of the parent plugin.
412 */
413 throw new CKEditorError(
414 'plugincollection-required',
415 context,
416 { plugin: getPluginName( plugin ), requiredBy: getPluginName( parentPluginConstructor ) }
417 );
418 }
419
420 function loadPlugins( pluginConstructors ) {
421 return pluginConstructors.map( PluginConstructor => {
422 const pluginInstance = that._contextPlugins.get( PluginConstructor ) || new PluginConstructor( context );
423
424 that._add( PluginConstructor, pluginInstance );
425
426 return pluginInstance;
427 } );
428 }
429
430 function initPlugins( pluginInstances, method ) {
431 return pluginInstances.reduce( ( promise, plugin ) => {
432 if ( !plugin[ method ] ) {
433 return promise;
434 }
435
436 if ( that._contextPlugins.has( plugin ) ) {
437 return promise;
438 }
439
440 return promise.then( plugin[ method ].bind( plugin ) );
441 }, Promise.resolve() );
442 }
443
444 // Replaces plugin constructors with the specified set of plugins.
445 //
446 // @param {Array.<Function>} pluginConstructors
447 // @param {Array.<Function>} pluginsSubstitutions
448 function substitutePlugins( pluginConstructors, pluginsSubstitutions ) {
449 for ( const pluginItem of pluginsSubstitutions ) {
450 if ( typeof pluginItem != 'function' ) {
451 /**
452 * The plugin replacing an existing plugin must be a function.
453 *
454 * @error plugincollection-replace-plugin-invalid-type
455 */
456 throw new CKEditorError( 'plugincollection-replace-plugin-invalid-type', null, { pluginItem } );
457 }
458 const pluginName = pluginItem.pluginName;
459
460 if ( !pluginName ) {
461 /**
462 * The plugin replacing an existing plugin must have a name.
463 *
464 * @error plugincollection-replace-plugin-missing-name
465 */
466 throw new CKEditorError( 'plugincollection-replace-plugin-missing-name', null, { pluginItem } );
467 }
468
469 if ( pluginItem.requires && pluginItem.requires.length ) {
470 /**
471 * The plugin replacing an existing plugin cannot depend on other plugins.
472 *
473 * @error plugincollection-plugin-for-replacing-cannot-have-dependencies
474 */
475 throw new CKEditorError( 'plugincollection-plugin-for-replacing-cannot-have-dependencies', null, { pluginName } );
476 }
477
478 const pluginToReplace = that._availablePlugins.get( pluginName );
479
480 if ( !pluginToReplace ) {
481 /**
482 * The replaced plugin does not exist in the
483 * {@link module:core/plugincollection~PluginCollection available plugins} collection.
484 *
485 * @error plugincollection-plugin-for-replacing-not-exist
486 */
487 throw new CKEditorError( 'plugincollection-plugin-for-replacing-not-exist', null, { pluginName } );
488 }
489
490 const indexInPluginConstructors = pluginConstructors.indexOf( pluginToReplace );
491
492 if ( indexInPluginConstructors === -1 ) {
493 // The Context feature can substitute plugins as well.
494 // It may happen that the editor will be created with the given context, where the plugin for substitute
495 // was already replaced. In such a case, we don't want to do it again.
496 if ( that._contextPlugins.has( pluginToReplace ) ) {
497 return;
498 }
499
500 /**
501 * The replaced plugin will not be loaded so it cannot be replaced.
502 *
503 * @error plugincollection-plugin-for-replacing-not-loaded
504 */
505 throw new CKEditorError( 'plugincollection-plugin-for-replacing-not-loaded', null, { pluginName } );
506 }
507
508 if ( pluginToReplace.requires && pluginToReplace.requires.length ) {
509 /**
510 * The replaced plugin cannot depend on other plugins.
511 *
512 * @error plugincollection-replaced-plugin-cannot-have-dependencies
513 */
514 throw new CKEditorError( 'plugincollection-replaced-plugin-cannot-have-dependencies', null, { pluginName } );
515 }
516
517 pluginConstructors.splice( indexInPluginConstructors, 1, pluginItem );
518 that._availablePlugins.set( pluginName, pluginItem );
519 }
520 }
521 }
522
523 /**
524 * Destroys all loaded plugins.
525 *
526 * @returns {Promise}
527 */
528 destroy() {
529 const promises = [];
530
531 for ( const [ , pluginInstance ] of this ) {
532 if ( typeof pluginInstance.destroy == 'function' && !this._contextPlugins.has( pluginInstance ) ) {
533 promises.push( pluginInstance.destroy() );
534 }
535 }
536
537 return Promise.all( promises );
538 }
539
540 /**
541 * Adds the plugin to the collection. Exposed mainly for testing purposes.
542 *
543 * @protected
544 * @param {Function} PluginConstructor The plugin constructor.
545 * @param {module:core/plugin~PluginInterface} plugin The instance of the plugin.
546 */
547 _add( PluginConstructor, plugin ) {
548 this._plugins.set( PluginConstructor, plugin );
549
550 const pluginName = PluginConstructor.pluginName;
551
552 if ( !pluginName ) {
553 return;
554 }
555
556 if ( this._plugins.has( pluginName ) ) {
557 /**
558 * Two plugins with the same {@link module:core/plugin~PluginInterface.pluginName} were loaded.
559 * This will lead to runtime conflicts between these plugins.
560 *
561 * In practice, this warning usually means that new plugins were added to an existing CKEditor 5 build.
562 * Plugins should always be added to a source version of the editor (`@ckeditor/ckeditor5-editor-*`),
563 * not to an editor imported from one of the `@ckeditor/ckeditor5-build-*` packages.
564 *
565 * Check your import paths and the list of plugins passed to
566 * {@link module:core/editor/editor~Editor.create `Editor.create()`}
567 * or specified in {@link module:core/editor/editor~Editor.builtinPlugins `Editor.builtinPlugins`}.
568 *
569 * The second option is that your `node_modules/` directory contains duplicated versions of the same
570 * CKEditor 5 packages. Normally, on clean installations, npm deduplicates packages in `node_modules/`, so
571 * it may be enough to call `rm -rf node_modules && npm i`. However, if you installed conflicting versions
572 * of some packages, their dependencies may need to be installed in more than one version which may lead to this
573 * warning.
574 *
575 * Technically speaking, this error occurs because after adding a plugin to an existing editor build
576 * the dependencies of this plugin are being duplicated.
577 * They are already built into that editor build and now get added for the second time as dependencies
578 * of the plugin you are installing.
579 *
580 * Read more about {@glink installation/getting-started/installing-plugins installing plugins}.
581 *
582 * @error plugincollection-plugin-name-conflict
583 * @param {String} pluginName The duplicated plugin name.
584 * @param {Function} plugin1 The first plugin constructor.
585 * @param {Function} plugin2 The second plugin constructor.
586 */
587 throw new CKEditorError(
588 'plugincollection-plugin-name-conflict',
589 null,
590 { pluginName, plugin1: this._plugins.get( pluginName ).constructor, plugin2: PluginConstructor }
591 );
592 }
593
594 this._plugins.set( pluginName, plugin );
595 }
596}
597
598mix( PluginCollection, EmitterMixin );