UNPKG

@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
/** * @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