@ckeditor/ckeditor5-core
Version:
The core architecture of CKEditor 5 – the best browser-based rich text editor.
1,165 lines (1,159 loc) • 117 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { ObservableMixin, insertToPriorityArray, EmitterMixin, CKEditorError, Config, Locale, Collection, KeystrokeHandler, env, global, uid, parseBase64EncodedObject, toArray, crc32, releaseDate, logError, setDataInElement } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { get, set, isFunction } from 'es-toolkit/compat';
import { Model, StylesProcessor, DataController, EditingController, Conversion } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { EditorWatchdog, ContextWatchdog } from '@ckeditor/ckeditor5-watchdog/dist/index.js';
/**
* The base class for CKEditor plugin classes.
*/ class Plugin extends /* #__PURE__ */ ObservableMixin() {
/**
* The editor instance.
*
* Note that most editors implement the {@link module:core/editor/editor~Editor#ui} property.
* However, editors with an external UI (i.e. Bootstrap-based) or a headless editor may not have this property or
* throw an error when accessing it.
*
* Because of above, to make plugins more universal, it is recommended to split features into:
* - The "editing" part that uses the {@link module:core/editor/editor~Editor} class without `ui` property.
* - The "UI" part that uses the {@link module:core/editor/editor~Editor} class and accesses `ui` property.
*/ editor;
/**
* Holds identifiers for {@link #forceDisabled} mechanism.
*/ _disableStack = new Set();
/**
* @inheritDoc
*/ constructor(editor){
super();
this.editor = editor;
this.set('isEnabled', true);
}
/**
* Disables the plugin.
*
* Plugin may be disabled by multiple features or algorithms (at once). When disabling a plugin, unique id should be passed
* (e.g. feature name). The same identifier should be used when {@link #clearForceDisabled enabling back} the plugin.
* The plugin becomes enabled only after all features {@link #clearForceDisabled enabled it back}.
*
* Disabling and enabling a plugin:
*
* ```ts
* plugin.isEnabled; // -> true
* plugin.forceDisabled( 'MyFeature' );
* plugin.isEnabled; // -> false
* plugin.clearForceDisabled( 'MyFeature' );
* plugin.isEnabled; // -> true
* ```
*
* Plugin disabled by multiple features:
*
* ```ts
* plugin.forceDisabled( 'MyFeature' );
* plugin.forceDisabled( 'OtherFeature' );
* plugin.clearForceDisabled( 'MyFeature' );
* plugin.isEnabled; // -> false
* plugin.clearForceDisabled( 'OtherFeature' );
* plugin.isEnabled; // -> true
* ```
*
* Multiple disabling with the same identifier is redundant:
*
* ```ts
* plugin.forceDisabled( 'MyFeature' );
* plugin.forceDisabled( 'MyFeature' );
* plugin.clearForceDisabled( 'MyFeature' );
* plugin.isEnabled; // -> true
* ```
*
* **Note:** some plugins or algorithms may have more complex logic when it comes to enabling or disabling certain plugins,
* so the plugin might be still disabled after {@link #clearForceDisabled} was used.
*
* @param id Unique identifier for disabling. Use the same id when {@link #clearForceDisabled enabling back} the plugin.
*/ forceDisabled(id) {
this._disableStack.add(id);
if (this._disableStack.size == 1) {
this.on('set:isEnabled', forceDisable$1, {
priority: 'highest'
});
this.isEnabled = false;
}
}
/**
* Clears forced disable previously set through {@link #forceDisabled}. See {@link #forceDisabled}.
*
* @param id Unique identifier, equal to the one passed in {@link #forceDisabled} call.
*/ clearForceDisabled(id) {
this._disableStack.delete(id);
if (this._disableStack.size == 0) {
this.off('set:isEnabled', forceDisable$1);
this.isEnabled = true;
}
}
/**
* @inheritDoc
*/ destroy() {
this.stopListening();
}
/**
* @inheritDoc
*/ static get isContextPlugin() {
return false;
}
/**
* @inheritDoc
* @internal
*/ static get isOfficialPlugin() {
return false;
}
/**
* @inheritDoc
* @internal
*/ static get isPremiumPlugin() {
return false;
}
}
/**
* Helper function that forces plugin to be disabled.
*/ function forceDisable$1(evt) {
evt.return = false;
evt.stop();
}
/**
* Base class for the CKEditor commands.
*
* Commands are the main way to manipulate the editor contents and state. They are mostly used by UI elements (or by other
* commands) to make changes in the model. Commands are available in every part of the code that has access to
* the {@link module:core/editor/editor~Editor editor} instance.
*
* Instances of registered commands can be retrieved from {@link module:core/editor/editor~Editor#commands `editor.commands`}.
* The easiest way to execute a command is through {@link module:core/editor/editor~Editor#execute `editor.execute()`}.
*
* By default, commands are disabled when the editor is in the {@link module:core/editor/editor~Editor#isReadOnly read-only} mode
* but commands with the {@link module:core/command~Command#affectsData `affectsData`} flag set to `false` will not be disabled.
*/ class Command extends /* #__PURE__ */ ObservableMixin() {
/**
* The editor on which this command will be used.
*/ editor;
/**
* A flag indicating whether a command's `isEnabled` state should be changed depending on where the document
* selection is placed.
*
* By default, it is set to `true`. If the document selection is placed in a
* {@link module:engine/model/model~Model#canEditAt non-editable} place (such as non-editable root), the command becomes disabled.
*
* The flag should be changed to `false` in a concrete command's constructor if the command should not change its `isEnabled`
* accordingly to the document selection.
*/ _isEnabledBasedOnSelection;
/**
* A flag indicating whether a command execution changes the editor data or not.
*
* @see #affectsData
*/ _affectsData;
/**
* Holds identifiers for {@link #forceDisabled} mechanism.
*/ _disableStack;
/**
* Creates a new `Command` instance.
*
* @param editor The editor on which this command will be used.
*/ constructor(editor){
super();
this.editor = editor;
this.set('value', undefined);
this.set('isEnabled', false);
this._affectsData = true;
this._isEnabledBasedOnSelection = true;
this._disableStack = new Set();
this.decorate('execute');
// By default, every command is refreshed when changes are applied to the model.
this.listenTo(this.editor.model.document, 'change', ()=>{
this.refresh();
});
this.listenTo(editor, 'change:isReadOnly', ()=>{
this.refresh();
});
// By default, commands are disabled if the selection is in non-editable place or editor is in read-only mode.
this.on('set:isEnabled', (evt)=>{
if (!this.affectsData) {
return;
}
const selection = editor.model.document.selection;
const selectionInGraveyard = selection.getFirstPosition().root.rootName == '$graveyard';
const canEditAtSelection = !selectionInGraveyard && editor.model.canEditAt(selection);
// Disable if editor is read only, or when selection is in a place which cannot be edited.
//
// Checking `editor.isReadOnly` is needed for all commands that have `_isEnabledBasedOnSelection == false`.
// E.g. undo does not base on selection, but affects data and should be disabled when the editor is in read-only mode.
if (editor.isReadOnly || this._isEnabledBasedOnSelection && !canEditAtSelection) {
evt.return = false;
evt.stop();
}
}, {
priority: 'highest'
});
this.on('execute', (evt)=>{
if (!this.isEnabled) {
evt.stop();
}
}, {
priority: 'high'
});
}
/**
* A flag indicating whether a command execution changes the editor data or not.
*
* Commands with `affectsData` set to `false` will not be automatically disabled in
* the {@link module:core/editor/editor~Editor#isReadOnly read-only mode} and
* {@glink features/read-only#related-features other editor modes} with restricted user write permissions.
*
* **Note:** You do not have to set it for your every command. It is `true` by default.
*
* @default true
*/ get affectsData() {
return this._affectsData;
}
set affectsData(affectsData) {
this._affectsData = affectsData;
}
/**
* Refreshes the command. The command should update its {@link #isEnabled} and {@link #value} properties
* in this method.
*
* This method is automatically called when
* {@link module:engine/model/document~Document#event:change any changes are applied to the document}.
*/ refresh() {
this.isEnabled = true;
}
/**
* Disables the command.
*
* Command may be disabled by multiple features or algorithms (at once). When disabling a command, unique id should be passed
* (e.g. the feature name). The same identifier should be used when {@link #clearForceDisabled enabling back} the command.
* The command becomes enabled only after all features {@link #clearForceDisabled enabled it back}.
*
* Disabling and enabling a command:
*
* ```ts
* command.isEnabled; // -> true
* command.forceDisabled( 'MyFeature' );
* command.isEnabled; // -> false
* command.clearForceDisabled( 'MyFeature' );
* command.isEnabled; // -> true
* ```
*
* Command disabled by multiple features:
*
* ```ts
* command.forceDisabled( 'MyFeature' );
* command.forceDisabled( 'OtherFeature' );
* command.clearForceDisabled( 'MyFeature' );
* command.isEnabled; // -> false
* command.clearForceDisabled( 'OtherFeature' );
* command.isEnabled; // -> true
* ```
*
* Multiple disabling with the same identifier is redundant:
*
* ```ts
* command.forceDisabled( 'MyFeature' );
* command.forceDisabled( 'MyFeature' );
* command.clearForceDisabled( 'MyFeature' );
* command.isEnabled; // -> true
* ```
*
* **Note:** some commands or algorithms may have more complex logic when it comes to enabling or disabling certain commands,
* so the command might be still disabled after {@link #clearForceDisabled} was used.
*
* @param id Unique identifier for disabling. Use the same id when {@link #clearForceDisabled enabling back} the command.
*/ forceDisabled(id) {
this._disableStack.add(id);
if (this._disableStack.size == 1) {
this.on('set:isEnabled', forceDisable, {
priority: 'highest'
});
this.isEnabled = false;
}
}
/**
* Clears forced disable previously set through {@link #forceDisabled}. See {@link #forceDisabled}.
*
* @param id Unique identifier, equal to the one passed in {@link #forceDisabled} call.
*/ clearForceDisabled(id) {
this._disableStack.delete(id);
if (this._disableStack.size == 0) {
this.off('set:isEnabled', forceDisable);
this.refresh();
}
}
/**
* Executes the command.
*
* A command may accept parameters. They will be passed from {@link module:core/editor/editor~Editor#execute `editor.execute()`}
* to the command.
*
* The `execute()` method will automatically abort when the command is disabled ({@link #isEnabled} is `false`).
* This behavior is implemented by a high priority listener to the {@link #event:execute} event.
*
* In order to see how to disable a command from "outside" see the {@link #isEnabled} documentation.
*
* This method may return a value, which would be forwarded all the way down to the
* {@link module:core/editor/editor~Editor#execute `editor.execute()`}.
*
* @fires execute
*/ execute(...args) {
return undefined;
}
/**
* Destroys the command.
*/ destroy() {
this.stopListening();
}
}
/**
* Helper function that forces command to be disabled.
*/ function forceDisable(evt) {
evt.return = false;
evt.stop();
}
/**
* A CKEditor command that aggregates other commands.
*
* This command is used to proxy multiple commands. The multi-command is enabled when
* at least one of its registered child commands is enabled.
* When executing a multi-command, the first enabled command with highest priority will be executed.
*
* ```ts
* const multiCommand = new MultiCommand( editor );
*
* const commandFoo = new Command( editor );
* const commandBar = new Command( editor );
*
* // Register a child command.
* multiCommand.registerChildCommand( commandFoo );
* // Register a child command with a low priority.
* multiCommand.registerChildCommand( commandBar, { priority: 'low' } );
*
* // Enable one of the commands.
* commandBar.isEnabled = true;
*
* multiCommand.execute(); // Will execute commandBar.
* ```
*/ class MultiCommand extends Command {
/**
* Registered child commands definitions.
*/ _childCommandsDefinitions = [];
/**
* @inheritDoc
*/ refresh() {
// Override base command refresh(): the command's state is changed when one of child commands changes states.
}
/**
* Executes the first enabled command which has the highest priority of all registered child commands.
*
* @returns The value returned by the {@link module:core/command~Command#execute `command.execute()`}.
*/ execute(...args) {
const command = this._getFirstEnabledCommand();
return !!command && command.execute(args);
}
/**
* Registers a child command.
*
* @param options An object with configuration options.
* @param options.priority Priority of a command to register.
*/ registerChildCommand(command, options = {}) {
insertToPriorityArray(this._childCommandsDefinitions, {
command,
priority: options.priority || 'normal'
});
// Change multi-command enabled state when one of registered commands changes state.
command.on('change:isEnabled', ()=>this._checkEnabled());
this._checkEnabled();
}
/**
* Checks if any of child commands is enabled.
*/ _checkEnabled() {
this.isEnabled = !!this._getFirstEnabledCommand();
}
/**
* Returns a first enabled command with the highest priority or `undefined` if none of them is enabled.
*/ _getFirstEnabledCommand() {
const commandDefinition = this._childCommandsDefinitions.find(({ command })=>command.isEnabled);
return commandDefinition && commandDefinition.command;
}
}
/**
* Manages a list of CKEditor plugins, including loading, resolving dependencies and initialization.
*/ class PluginCollection extends /* #__PURE__ */ EmitterMixin() {
_context;
_plugins = new Map();
/**
* A map of plugin constructors that can be retrieved by their names.
*/ _availablePlugins;
/**
* Map of {@link module:core/contextplugin~ContextPlugin context plugins} which can be retrieved by their constructors or instances.
*/ _contextPlugins;
/**
* Creates an instance of the plugin collection class.
* Allows loading and initializing plugins and their dependencies.
* Allows providing a list of already loaded plugins. These plugins will not be destroyed along with this collection.
*
* @param availablePlugins Plugins (constructors) which the collection will be able to use
* when {@link module:core/plugincollection~PluginCollection#init} is used with the 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 contextPlugins A list of already initialized plugins represented by a `[ PluginConstructor, pluginInstance ]` pair.
*/ constructor(context, availablePlugins = [], contextPlugins = []){
super();
this._context = context;
this._availablePlugins = new Map();
for (const PluginConstructor of availablePlugins){
if (PluginConstructor.pluginName) {
this._availablePlugins.set(PluginConstructor.pluginName, PluginConstructor);
}
}
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.
*/ *[Symbol.iterator]() {
for (const entry of this._plugins){
if (typeof entry[0] == 'function') {
yield entry;
}
}
}
/**
* Gets the plugin instance by its constructor or name.
*
* ```ts
* // Check if 'Clipboard' plugin was loaded.
* if ( editor.plugins.has( 'ClipboardPipeline' ) ) {
* // Get clipboard plugin instance
* const clipboard = editor.plugins.get( 'ClipboardPipeline' );
*
* this.listenTo( clipboard, 'inputTransformation', ( evt, data ) => {
* // Do something on clipboard input.
* } );
* }
* ```
*
* **Note**: This method will throw an error if a plugin is not loaded. Use `{@link #has editor.plugins.has()}`
* to check if a plugin is available.
*
* @param key The plugin constructor or {@link module:core/plugin~PluginStaticMembers#pluginName name}.
*/ get(key) {
const plugin = this._plugins.get(key);
if (!plugin) {
let pluginName = key;
if (typeof key == 'function') {
pluginName = key.pluginName || key.name;
}
/**
* 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.
*
* **Note**: You can use `{@link module:core/plugincollection~PluginCollection#has editor.plugins.has()}`
* to check if a plugin was loaded.
*
* @error plugincollection-plugin-not-loaded
* @param {string} plugin The name of the plugin which is not loaded.
*/ throw new CKEditorError('plugincollection-plugin-not-loaded', this._context, {
plugin: pluginName
});
}
return plugin;
}
/**
* Checks if a plugin is loaded.
*
* ```ts
* // Check if the 'Clipboard' plugin was loaded.
* if ( editor.plugins.has( 'ClipboardPipeline' ) ) {
* // Now use the clipboard plugin instance:
* const clipboard = editor.plugins.get( 'ClipboardPipeline' );
*
* // ...
* }
* ```
*
* @param key The plugin constructor or {@link module:core/plugin~PluginStaticMembers#pluginName name}.
*/ has(key) {
return this._plugins.has(key);
}
/**
* Initializes a set of plugins and adds them to the collection.
*
* @param plugins An array of {@link module:core/plugin~PluginInterface plugin constructors}
* or {@link module:core/plugin~PluginStaticMembers#pluginName plugin names}.
* @param pluginsToRemove Names of the plugins or plugin constructors
* that should not be loaded (despite being specified in the `plugins` array).
* @param pluginsSubstitutions An array of {@link module:core/plugin~PluginInterface plugin constructors}
* that will be used to replace plugins of the same names that were passed in `plugins` or that are in their dependency tree.
* A useful option for replacing built-in plugins while creating tests (for mocking their APIs). Plugins that will be replaced
* must follow these rules:
* * The new plugin must be a class.
* * The new plugin must be named.
* * Both plugins must not depend on other plugins.
* @returns A promise which gets resolved once all plugins are loaded and available in the collection.
*/ init(plugins, pluginsToRemove = [], pluginsSubstitutions = []) {
// Plugin initialization procedure consists of 2 main steps:
// 1) collecting all available plugin constructors,
// 2) verification whether all required plugins can be instantiated.
//
// In the first step, all plugin constructors, available in the provided `plugins` array and inside
// plugin's dependencies (from the `Plugin.requires` array), are recursively collected and added to the existing
// `this._availablePlugins` map, but without any verification at the given moment. Performing the verification
// at this point (during the plugin constructor searching) would cause false errors to occur, that some plugin
// is missing but in fact it may be defined further in the array as the dependency of other plugin. After
// traversing the entire dependency tree, it will be checked if all required "top level" plugins are available.
//
// In the second step, the list of plugins that have not been explicitly removed is traversed to get all the
// plugin constructors to be instantiated in the correct order and to validate against some rules. Finally, if
// no plugin is missing and no other error has been found, they all will be instantiated.
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
const context = this._context;
findAvailablePluginConstructors(plugins);
validatePlugins(plugins);
const pluginsToLoad = plugins.filter((plugin)=>!isPluginRemoved(plugin, pluginsToRemove));
const pluginConstructors = [
...getPluginConstructors(pluginsToLoad)
];
substitutePlugins(pluginConstructors, pluginsSubstitutions);
const pluginInstances = loadPlugins(pluginConstructors);
return initPlugins(pluginInstances, 'init').then(()=>initPlugins(pluginInstances, 'afterInit')).then(()=>pluginInstances);
function isPluginConstructor(plugin) {
return typeof plugin === 'function';
}
function isContextPlugin(plugin) {
return isPluginConstructor(plugin) && !!plugin.isContextPlugin;
}
function isPluginRemoved(plugin, pluginsToRemove) {
return pluginsToRemove.some((removedPlugin)=>{
if (removedPlugin === plugin) {
return true;
}
if (getPluginName(plugin) === removedPlugin) {
return true;
}
if (getPluginName(removedPlugin) === plugin) {
return true;
}
return false;
});
}
function getPluginName(plugin) {
return isPluginConstructor(plugin) ? plugin.pluginName || plugin.name : plugin;
}
function findAvailablePluginConstructors(plugins, processed = new Set()) {
plugins.forEach((plugin)=>{
if (!isPluginConstructor(plugin)) {
return;
}
if (processed.has(plugin)) {
return;
}
processed.add(plugin);
if (plugin.pluginName && !that._availablePlugins.has(plugin.pluginName)) {
that._availablePlugins.set(plugin.pluginName, plugin);
}
if (plugin.requires) {
findAvailablePluginConstructors(plugin.requires, processed);
}
});
}
function getPluginConstructors(plugins, processed = new Set()) {
return plugins.map((plugin)=>{
return isPluginConstructor(plugin) ? plugin : that._availablePlugins.get(plugin);
}).reduce((result, plugin)=>{
if (processed.has(plugin)) {
return result;
}
processed.add(plugin);
if (plugin.requires) {
validatePlugins(plugin.requires, plugin);
getPluginConstructors(plugin.requires, processed).forEach((plugin)=>result.add(plugin));
}
return result.add(plugin);
}, new Set());
}
function validatePlugins(plugins, parentPluginConstructor = null) {
plugins.map((plugin)=>{
return isPluginConstructor(plugin) ? plugin : that._availablePlugins.get(plugin) || plugin;
}).forEach((plugin)=>{
checkMissingPlugin(plugin, parentPluginConstructor);
checkContextPlugin(plugin, parentPluginConstructor);
checkRemovedPlugin(plugin, parentPluginConstructor);
});
}
function checkMissingPlugin(plugin, parentPluginConstructor) {
if (isPluginConstructor(plugin)) {
return;
}
if (parentPluginConstructor) {
/**
* A required "soft" dependency was not found on the plugin list.
*
* When configuring the editor, either prior to building (via
* {@link module:core/editor/editor~Editor.builtinPlugins `Editor.builtinPlugins`}) or when
* creating a new instance of the editor (e.g. via
* {@link module:core/editor/editorconfig~EditorConfig#plugins `config.plugins`}), you need to provide
* some of the dependencies for other plugins that you used.
*
* This error is thrown when one of these dependencies was not provided. The name of the missing plugin
* can be found in `missingPlugin` and the plugin that required it in `requiredBy`.
*
* In order to resolve it, you need to import the missing plugin and add it to the
* current list of plugins (`Editor.builtinPlugins` or `config.plugins`/`config.extraPlugins`).
*
* Soft requirements were introduced in version 26.0.0. If you happen to stumble upon this error
* when upgrading to version 26.0.0, read also the
* {@glink updating/guides/update-to-26 Migration to 26.0.0} guide.
*
* @error plugincollection-soft-required
* @param {string} missingPlugin The name of the required plugin.
* @param {string} requiredBy The name of the plugin that requires the other plugin.
*/ throw new CKEditorError('plugincollection-soft-required', context, {
missingPlugin: plugin,
requiredBy: getPluginName(parentPluginConstructor)
});
}
/**
* A plugin is 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 the now deprecated CKEditor 5 builds by setting
* the {@link module:core/editor/editor~Editor.builtinPlugins} property.
*
* **If you see this warning when using one of the deprecated CKEditor 5 Builds**,
* it means that you tried to enable a plugin that was not included in that build. This may be due to a typo
* in the plugin name or simply because that plugin was not a part of this build.
*
* **Predefined builds are no longer supported and you need to
* {@glink updating/nim-migration/migration-to-new-installation-methods migrate to new installation methods}**.
*
* **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 a reference (as a constructor function). Check out the examples in the
* {@glink getting-started/installation/cloud/quick-start Quick start} guide.
*
* @error plugincollection-plugin-not-found
* @param {string} plugin The name of the plugin which could not be loaded.
*/ throw new CKEditorError('plugincollection-plugin-not-found', context, {
plugin
});
}
function checkContextPlugin(plugin, parentPluginConstructor) {
if (!isContextPlugin(parentPluginConstructor)) {
return;
}
if (isContextPlugin(plugin)) {
return;
}
/**
* 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: getPluginName(plugin),
requiredBy: getPluginName(parentPluginConstructor)
});
}
function checkRemovedPlugin(plugin, parentPluginConstructor) {
if (!parentPluginConstructor) {
return;
}
if (!isPluginRemoved(plugin, pluginsToRemove)) {
return;
}
/**
* 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', context, {
plugin: getPluginName(plugin),
requiredBy: getPluginName(parentPluginConstructor)
});
}
function loadPlugins(pluginConstructors) {
return pluginConstructors.map((PluginConstructor)=>{
let pluginInstance = that._contextPlugins.get(PluginConstructor);
pluginInstance = pluginInstance || new PluginConstructor(context);
that._add(PluginConstructor, pluginInstance);
return pluginInstance;
});
}
function initPlugins(pluginInstances, method) {
return pluginInstances.reduce((promise, plugin)=>{
if (!plugin[method]) {
return promise;
}
if (that._contextPlugins.has(plugin)) {
return promise;
}
return promise.then(plugin[method].bind(plugin));
}, Promise.resolve());
}
/**
* Replaces plugin constructors with the specified set of plugins.
*/ function substitutePlugins(pluginConstructors, pluginsSubstitutions) {
for (const pluginItem of pluginsSubstitutions){
if (typeof pluginItem != 'function') {
/**
* The plugin replacing an existing plugin must be a function.
*
* @error plugincollection-replace-plugin-invalid-type
* @param {never} pluginItem The plugin item.
*/ throw new CKEditorError('plugincollection-replace-plugin-invalid-type', null, {
pluginItem
});
}
const pluginName = pluginItem.pluginName;
if (!pluginName) {
/**
* The plugin replacing an existing plugin must have a name.
*
* @error plugincollection-replace-plugin-missing-name
* @param {module:core/plugin~PluginConstructor} pluginItem The plugin item.
*/ throw new CKEditorError('plugincollection-replace-plugin-missing-name', null, {
pluginItem
});
}
if (pluginItem.requires && pluginItem.requires.length) {
/**
* The plugin replacing an existing plugin cannot depend on other plugins.
*
* @error plugincollection-plugin-for-replacing-cannot-have-dependencies
* @param {string} pluginName The name of the plugin.
*/ throw new CKEditorError('plugincollection-plugin-for-replacing-cannot-have-dependencies', null, {
pluginName
});
}
const pluginToReplace = that._availablePlugins.get(pluginName);
if (!pluginToReplace) {
/**
* The replaced plugin does not exist in the
* {@link module:core/plugincollection~PluginCollection available plugins} collection.
*
* @error plugincollection-plugin-for-replacing-not-exist
* @param {string} pluginName The name of the plugin.
*/ throw new CKEditorError('plugincollection-plugin-for-replacing-not-exist', null, {
pluginName
});
}
const indexInPluginConstructors = pluginConstructors.indexOf(pluginToReplace);
if (indexInPluginConstructors === -1) {
// The Context feature can substitute plugins as well.
// It may happen that the editor will be created with the given context, where the plugin for substitute
// was already replaced. In such a case, we don't want to do it again.
if (that._contextPlugins.has(pluginToReplace)) {
return;
}
/**
* The replaced plugin will not be loaded so it cannot be replaced.
*
* @error plugincollection-plugin-for-replacing-not-loaded
* @param {string} pluginName The name of the plugin.
*/ throw new CKEditorError('plugincollection-plugin-for-replacing-not-loaded', null, {
pluginName
});
}
if (pluginToReplace.requires && pluginToReplace.requires.length) {
/**
* The replaced plugin cannot depend on other plugins.
*
* @error plugincollection-replaced-plugin-cannot-have-dependencies
* @param {string} pluginName The name of the plugin.
*/ throw new CKEditorError('plugincollection-replaced-plugin-cannot-have-dependencies', null, {
pluginName
});
}
pluginConstructors.splice(indexInPluginConstructors, 1, pluginItem);
that._availablePlugins.set(pluginName, pluginItem);
}
}
}
/**
* Destroys all loaded plugins.
*/ 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.
*
* @param PluginConstructor The plugin constructor.
* @param 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~PluginStaticMembers#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`}.
*
* Predefined builds are a deprecated solution and we strongly advise
* {@glink updating/nim-migration/migration-to-new-installation-methods migrating to new installation methods}.
*
* 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 some 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
* the 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.
*
* @error plugincollection-plugin-name-conflict
* @param {string} pluginName The duplicated plugin name.
* @param {module:core/plugin~PluginConstructor} plugin1 The first plugin constructor.
* @param {module:core/plugin~PluginConstructor} plugin2 The second plugin constructor.
*/ throw new CKEditorError('plugincollection-plugin-name-conflict', null, {
pluginName,
plugin1: this._plugins.get(pluginName).constructor,
plugin2: PluginConstructor
});
}
this._plugins.set(pluginName, plugin);
}
}
/**
* Provides a common, higher-level environment for solutions that use multiple {@link module:core/editor/editor~Editor editors}
* or plugins that work outside the 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 the editor instances initialized in that context.
*
* {@link module:core/contextplugin~ContextPlugin Context plugins} passed to a context instance will be shared among all
* editor instances initialized in this context. These will be the same plugin instances for all the editors.
*
* **Note:** The context can only be initialized with {@link module:core/contextplugin~ContextPlugin context plugins}
* (e.g. [comments](https://ckeditor.com/collaboration/comments/)). Regular {@link module:core/plugin~Plugin plugins} require an
* editor instance to work and cannot be added to a context.
*
* **Note:** You can add a context plugin to an editor instance, though.
*
* If you are using multiple editor instances on one page and use any context plugins, create a context to share the configuration and
* plugins among these 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 the 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 a case, a context instance will be created by the editor instance in a transparent way.
*
* See {@link ~Context.create `Context.create()`} for usage examples.
*/ class Context {
/**
* Stores all the configurations specific to this context instance.
*/ config;
/**
* The plugins loaded and in use by this context instance.
*/ plugins;
locale;
/**
* Shorthand for {@link module:utils/locale~Locale#t}.
*/ t;
/**
* A list of editors that this context instance is injected to.
*/ editors;
/**
* The default configuration which is built into the `Context` class.
*
* It was used in the now deprecated CKEditor 5 builds featuring `Context` to provide the default configuration options
* which are later used during the context initialization.
*
* ```ts
* Context.defaultConfig = {
* foo: 1,
* bar: 2
* };
*
* Context
* .create()
* .then( context => {
* context.config.get( 'foo' ); // -> 1
* context.config.get( 'bar' ); // -> 2
* } );
*
* // The default options can be overridden by the configuration passed to create().
* Context
* .create( { bar: 3 } )
* .then( context => {
* context.config.get( 'foo' ); // -> 1
* context.config.get( 'bar' ); // -> 3
* } );
* ```
*
* See also {@link module:core/context~Context.builtinPlugins `Context.builtinPlugins`}
* and {@link module:core/editor/editor~Editor.defaultConfig `Editor.defaultConfig`}.
*/ static defaultConfig;
/**
* An array of plugins built into the `Context` class.
*
* It was used in the now deprecated CKEditor 5 builds featuring `Context` to provide the default configuration options
* which are later used during the context initialization.
*
* They will be automatically initialized by `Context` unless `config.plugins` is passed.
*
* ```ts
* // Build some context plugins into the Context class first.
* Context.builtinPlugins = [ FooPlugin, BarPlugin ];
*
* // Normally, you need to define config.plugins, but since Context.builtinPlugins was
* // defined, now you can call create() without any configuration.
* Context
* .create()
* .then( context => {
* context.plugins.get( FooPlugin ); // -> An instance of the Foo plugin.
* context.plugins.get( BarPlugin ); // -> An instance of the Bar plugin.
* } );
* ```
*
* See also {@link module:core/context~Context.defaultConfig `Context.defaultConfig`}
* and {@link module:core/editor/editor~Editor.builtinPlugins `Editor.builtinPlugins`}.
*/ static builtinPlugins;
/**
* 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.
*/ _contextOwner = null;
/**
* 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 config The context configuration.
*/ constructor(config){
// We don't pass translations to the config, because its behavior of splitting keys
// with dots (e.g. `resize.width` => `resize: { width }`) breaks the translations.
const { translations, ...rest } = config || {};
this.config = new Config(rest, this.constructor.defaultConfig);
const availablePlugins = this.constructor.builtinPlugins;
this.config.define('plugins', availablePlugins);
this.plugins = new PluginCollection(this, availablePlugins);
const languageConfig = this.config.get('language') || {};
this.locale = new Locale({
uiLanguage: typeof languageConfig === 'string' ? languageConfig : languageConfig.ui,
contentLanguage: this.config.get('language.content'),
translations
});
this.t = this.locale.t;
this.editors = new Collection();
}
/**
* Loads and initializes plugins specified in the configuration.
*
* @returns A promise which resolves once the initialization is completed, providing an array of loaded plugins.
*/ initPlugins() {
const plugins = this.config.get('plugins') || [];
const substitutePlugins = this.config.get('substitutePlugins') || [];
// Plugins for substitution should be checked as well.
for (const Plugin of plugins.concat(substitutePlugins)){
if (typeof Plugin != 'function') {
/**
* Only a constructor function is allowed as a {@link module:core/contextplugin~ContextPlugin context plugin}.
*
* @error context-initplugins-constructor-only
*/ throw new CKEditorError('context-initplugins-constructor-only', null, {
Plugin
});
}
if (Plugin.isContextPlugin !== true) {
/**
* Only a plugin marked as a {@link module:core/contextplugin~ContextPlugin.isContextPlugin context plugin}
* is allowed to be used with a context.
*
* @error context-initplugins-invalid-plugin
*/ throw new CKEditorError('context-initplugins-invalid-plugin', null, {
Plugin
});
}
}
return this.plugins.init(plugins, [], substitutePlugins);
}
/**
* Destroys the context instance and all editors used with the context,
* releasing all resources used by the context.
*
* @returns 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, the reference to this editor will be stored
* as a {@link ~Context#_contextOwner}.
*
* This method should only be used by the editor.
*
* @internal
* @param 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');
}
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, the context will be destroyed.
*
* This method should only be used by the editor.
*
* @internal
* @return A promise that resolves once the editor is removed from the context or when the context was destroyed.
*/ _removeEditor(editor) {
if (this.editors.has(editor)) {
this.editors.remove(editor);
}
if (this._contextOwner === editor) {
return this.destroy();
}
return Promise.resolve();
}
/**
* Returns the context configuration which will be copied to the editors created using this context.
*
* The configuration returned by this method has the plugins configuration removed – plugins are shared with all editors
* through another mechanism.
*
* This method should only be used by the editor.
*
* @internal
* @returns 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.
*
* ```ts
* const commonConfig = { ... }; // Configuration for all the plugins and editors.
* const editorPlugins = [ ... ]; // Regular plugins here.
*
* Context
* .create( {
* // Only context plugins here.
* plugins: [ ... ],
*
* // Configure the 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 the configuration of the context.
* }
* ) );
*
* return Promise.all( promises );
* } );
* ```
*
* @param config The context configuration.
* @returns 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));
});
}
}
/**
* 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.
*
* There are a few rules for interaction between the 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}.
*/ class ContextPl