diff --git a/CHANGELOG.md b/CHANGELOG.md
index af498be9..7b21948d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Release Notes for CKEditor for Craft CMS
+## Unreleased
+
+- CKEditor packages registered by plugins that load after CKEditor, including from `Craft::$app->onInit()` callbacks, are now registered properly. ([#621](https://github.com/craftcms/ckeditor/issues/621))
+- CKEditor fields now only import third-party CKEditor packages, and register their asset bundles, when one of the package’s toolbar items is in the field’s toolbar. Packages without toolbar items are still loaded for every field.
+- Third-party CKEditor plugins are now referenced via namespace imports, so plugins with the same name from different packages no longer conflict. Custom config JS can still refer to them by name (e.g. `extraPlugins: [Tokens]`), as long as the name is unique.
+- Added `craft\ckeditor\Plugin::registerCkeditorPackageBundles()`.
+- Deprecated `craft\ckeditor\helpers\CkeditorConfig::getImportStatements()`.
+- Package asset bundles are no longer registered automatically whenever `CkeditorAsset` is registered.
+- Fixed a bug where package toolbar items were registered again each time a CKEditor field was rendered.
+- Fixed a bug where grouped toolbar items from third-party packages weren’t matched against the field’s toolbar.
+- Fixed a bug where all of a package’s toolbar items were shown as a single group in the toolbar builder, instead of as separately-placeable items and groups.
+
## 5.7.0 - 2026-08-10
- Assets inserted as `
` tags now populate the `alt` attribute with the asset’s Alternative Text value. If left unedited, the `alt` attribute will stay in sync with the asset. ([#585](https://github.com/craftcms/ckeditor/pull/585))
diff --git a/README.md b/README.md
index 38b02c54..75bf3386 100644
--- a/README.md
+++ b/README.md
@@ -406,7 +406,7 @@ class TokensAsset extends BaseCkeditorPackageAsset
}
```
-Finally, ensure your asset bundle is registered whenever the core CKEditor asset bundle is. Add the following code to your plugin’s `init()` method:
+Finally, register the asset bundle from your plugin’s `init()` method:
```php
\craft\ckeditor\Plugin::registerCkeditorPackage(TokensAsset::class, 'tokens.js');
@@ -414,6 +414,32 @@ Finally, ensure your asset bundle is registered whenever the core CKEditor asset
The second parameter should point to the main entry file for your JavaScript. In most cases, it will be the same as the only item in your `$js` array.
+Packages aren’t loaded until they’re needed, so you can also register yours from a `Craft::$app->onInit()` callback, and it doesn’t matter which order plugins are loaded in.
+
+Each CKEditor field only imports your package and registers its asset bundle if one of the package’s `$toolbarItems` is in the field’s toolbar. Packages without toolbar items aren’t tied to the toolbar:
+
+- If `$toolbarItems` is empty, the package’s plugins are loaded for every field.
+- If `$pluginNames` is empty too, the asset bundle is registered for every field, and its JavaScript runs for its side effects.
+
+If some of your plugins go with a toolbar button and others should always be loaded, register the always-on plugins separately:
+
+```php
+public function registerPackage(): void
+{
+ // Plugins in $pluginNames only load when one of $toolbarItems is in the toolbar
+ parent::registerPackage();
+
+ // These load for every field
+ \craft\ckeditor\helpers\CkeditorConfig::registerPackage($this->namespace, [
+ 'plugins' => ['TokensAutocomplete'],
+ ]);
+}
+```
+
+Plugins from your package are referenced through a namespace import (`import * as pkg from '@craftcms/ckeditor5-tokens'`), so their names won’t collide with plugins from other packages. A field’s custom config JS can still refer to your plugin class by name, e.g. `extraPlugins: [Tokens]` to enable it without its toolbar button. The package is imported for that field whenever its config mentions the plugin, as long as no other package provides a plugin with the same name.
+
+If you need your package’s asset bundle on a page that creates its own CKEditor instances, call `\craft\ckeditor\Plugin::registerCkeditorPackageBundles($view)`.
+
## Front-end use
If you wish to use CKEditor on the front end, the safest option is to [bring your own CKE build](https://ckeditor.com/docs/ckeditor5/latest/getting-started/installation/self-hosted/quick-start.html). Craft’s bundle (while technically allowed under the GPL3 license) is not suitable for front-end use, due to its control panel-dependent functionality. Simple [configurations](#configuration) defined via the control panel _can_ be used piecemeal in your initializer:
diff --git a/src/Field.php b/src/Field.php
index 38f52fc0..f69d1ec2 100644
--- a/src/Field.php
+++ b/src/Field.php
@@ -1104,6 +1104,10 @@ private function settingsHtml(bool $readOnly): string
$view = Craft::$app->getView();
$bundle = $view->registerAssetBundle(FieldSettingsAsset::class);
+ // The toolbar builder needs every package
+ [$importStatements, $pluginRefs] = CkeditorConfig::getImports();
+ Plugin::registerCkeditorPackageBundles($view);
+
$userGroupOptions = [
[
'label' => Craft::t('app', 'Admins'),
@@ -1153,12 +1157,12 @@ private function settingsHtml(bool $readOnly): string
return $view->renderTemplate('ckeditor/_field-settings.twig', [
'field' => $this,
- 'importStatements' => CkeditorConfig::getImportStatements(),
+ 'importStatements' => $importStatements,
'toolbarBuilderId' => $view->namespaceInputId('toolbar-builder'),
'configOptionsId' => $view->namespaceInputId('config-options'),
'cssOptionsId' => $view->namespaceInputId('css-options'),
- 'toolbarItems' => CkeditorConfig::normalizeToolbarItems(CkeditorConfig::$toolbarItems),
- 'plugins' => CkeditorConfig::getAllPlugins(),
+ 'toolbarItems' => CkeditorConfig::normalizeToolbarItems(CkeditorConfig::getToolbarItems()),
+ 'plugins' => $pluginRefs,
'jsonSchema' => CkeditorConfigSchema::create(),
'jsonSchemaUri' => $jsonSchemaUri,
'advanceLinkOptions' => CkeditorConfig::advanceLinkOptions(),
@@ -1674,45 +1678,24 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat
$removePlugins->push('ImageTransforms');
}
- // Avoid loading plugins not included in the toolbar
- $unusedPlugins = collect(CkeditorConfig::$pluginButtonMap)
- ->filter(function(array $item) use ($event) {
- $buttons = $item['buttons'] ?? [];
-
- // If there are no buttons defined, always load it
- if (empty($buttons)) {
- return false;
- }
-
- return collect($event->toolbar)
- ->doesntContain(function(string $toolbarItem) use ($buttons) {
- return in_array($toolbarItem, $buttons);
- });
- })
- ->map(fn(array $item) => $item['plugins'] ?? [])
- ->flatten();
-
- $removePlugins->push(...$unusedPlugins->all());
-
- $plugins = CkeditorConfig::getPluginsByPackage();
-
- $plugins = collect($plugins)
- ->mapWithKeys(fn(array $plugins, string $namespace) => [
- $namespace => collect($plugins)
- ->reject(fn($plugin) => in_array($plugin, $removePlugins->toArray())),
- ]);
+ $configJs = $this->configJs();
- $configPlugins = '[' . $plugins->flatten()->join(',') . ']';
+ // Only import (and register the asset bundles for) packages that have plugins in use, or that custom
+ // config JS refers to by name (e.g. `extraPlugins: [Tokens]`). JSON config can't refer to plugin classes.
+ [$imports, $pluginRefs, $namespaces] = CkeditorConfig::getImports(
+ $event->toolbar,
+ $removePlugins->all(),
+ isset($this->options) ? null : $configJs,
+ );
+ Plugin::registerCkeditorPackageBundles($view, $namespaces);
- $imports = CkeditorConfig::getImportStatements();
+ $configPlugins = '[' . implode(',', $pluginRefs) . ']';
// Add the translation import
$uiLanguage = BaseCkeditorPackageAsset::uiLanguage();
$importCompliantUiLanguage = BaseCkeditorPackageAsset::getImportCompliantLanguage(BaseCkeditorPackageAsset::uiLanguage());
$uiTranslationImport = "import coreTranslations from 'ckeditor5/translations/$importCompliantUiLanguage.js';";
- $configJs = $this->configJs();
-
$view->registerScriptWithVars(fn(
$baseConfigJs,
$toolbarJs,
diff --git a/src/Plugin.php b/src/Plugin.php
index 72b4363a..833f43ec 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -10,11 +10,11 @@
use Craft;
use craft\base\Element;
use craft\ckeditor\deletionblockers\ReferenceDeletionBlocker;
+use craft\ckeditor\helpers\CkeditorConfig;
use craft\ckeditor\web\assets\BaseCkeditorPackageAsset;
use craft\ckeditor\web\assets\ckeditor\CkeditorAsset;
use craft\ckeditor\web\assets\fieldsettings\FieldSettingsAsset;
use craft\elements\NestedElementManager;
-use craft\events\AssetBundleEvent;
use craft\events\DefineElementDeletionBlockersEvent;
use craft\events\ModelEvent;
use craft\events\RegisterComponentTypesEvent;
@@ -36,18 +36,102 @@ class Plugin extends \craft\base\Plugin
/**
* Registers an asset bundle for a CKEditor package.
*
+ * Packages aren’t loaded until they’re needed, so this can be called from a plugin or module’s `init()`
+ * method or a `Craft::$app->onInit()` callback, regardless of load order.
+ *
* @param string $name The asset bundle class name. The asset bundle should extend
* [[\craft\ckeditor\web\assets\BaseCkeditorPackageAsset]].
+ * @param string $entry The package’s JavaScript entry file, relative to the asset bundle’s source path.
* @since 3.5.0
*/
public static function registerCkeditorPackage(string $name, string $entry = 'index.js'): void
{
- self::$ckeditorPackages[$name] = true;
- self::$ckeditorImports[$name] = $entry;
+ self::$ckeditorPackages[$name] = $entry;
+
+ // If packages have already been loaded, load this one right away
+ if (self::$packagesLoaded) {
+ self::loadCkeditorPackage($name, $entry);
+ }
+ }
+
+ /**
+ * Returns the registered CKEditor packages.
+ *
+ * Packages registered via [[registerCkeditorPackage()]] are loaded the first time this is called. Any registered
+ * after that are loaded immediately.
+ *
+ * @return array Package info, indexed by asset bundle class name
+ * @internal
+ */
+ public static function getCkeditorPackages(): array
+ {
+ if (!self::$packagesLoaded) {
+ self::$packagesLoaded = true;
+ foreach (self::$ckeditorPackages as $name => $entry) {
+ self::loadCkeditorPackage($name, $entry);
+ }
+ }
+
+ return self::$loadedPackages;
+ }
+
+ /**
+ * Registers the asset bundles for CKEditor packages.
+ *
+ * Packages that don’t provide any CKEditor plugins (e.g. global functionality) are always registered.
+ *
+ * @param View $view
+ * @param string[]|null $namespaces The package namespaces whose bundles should be registered, or `null` for all packages
+ * @since 5.8.0
+ */
+ public static function registerCkeditorPackageBundles(View $view, ?array $namespaces = null): void
+ {
+ foreach (self::getCkeditorPackages() as $name => $package) {
+ if (
+ $namespaces === null ||
+ in_array($package['namespace'], $namespaces, true) ||
+ empty(CkeditorConfig::getPluginsByPackage($package['namespace']))
+ ) {
+ $view->registerAssetBundle($name);
+ }
+ }
+ }
+
+ private static function loadCkeditorPackage(string $name, string $entry): void
+ {
+ if (isset(self::$loadedPackages[$name])) {
+ self::$loadedPackages[$name]['entry'] = $entry;
+ return;
+ }
+
+ $bundle = Craft::createObject($name);
+ if (!$bundle instanceof BaseCkeditorPackageAsset) {
+ Craft::warning("$name isn’t a CKEditor package asset bundle.", __METHOD__);
+ return;
+ }
+
+ self::$loadedPackages[$name] = [
+ 'namespace' => $bundle->namespace,
+ 'entry' => $entry,
+ ];
+ $bundle->registerPackage();
}
+ /**
+ * @var array Package entry files, indexed by asset bundle class name
+ */
private static array $ckeditorPackages = [];
- private static array $ckeditorImports = [];
+
+ /**
+ * @var array
+ * @see getCkeditorPackages()
+ */
+ private static array $loadedPackages = [];
+
+ /**
+ * @see getCkeditorPackages()
+ */
+ private static bool $packagesLoaded = false;
public string $schemaVersion = '5.6.0.0';
@@ -68,12 +152,14 @@ public function init(): void
$configBundle = $assetManager->getBundle(FieldSettingsAsset::class);
$view->registerJsImport('@craftcms/ckeditor-config', $assetManager->getAssetUrl($configBundle, 'fieldsettings.js'));
- foreach (self::$ckeditorImports as $bundleName => $entry) {
- $bundle = $assetManager->getBundle($bundleName);
- if ($bundle instanceof BaseCkeditorPackageAsset) {
- $view->registerJsImport($bundle->namespace, $assetManager->getAssetUrl($bundle, $entry, false));
+ // Wait until the page is being finalized, so packages registered from other plugins’ init() methods
+ // or onInit() callbacks are included regardless of load order
+ $view->on(View::EVENT_END_PAGE, function() use ($view, $assetManager) {
+ foreach (self::getCkeditorPackages() as $name => $package) {
+ $bundle = $assetManager->getBundle($name);
+ $view->registerJsImport($package['namespace'], $assetManager->getAssetUrl($bundle, $package['entry'], false));
}
- }
+ });
}
Event::on(Fields::class, Fields::EVENT_REGISTER_FIELD_TYPES, function(RegisterComponentTypesEvent $event) {
@@ -84,19 +170,6 @@ public function init(): void
$event->types[] = Field::class;
});
- Event::on(View::class, View::EVENT_AFTER_REGISTER_ASSET_BUNDLE, function(AssetBundleEvent $event) {
- if ($event->bundle instanceof CkeditorAsset) {
- /** @var View $view */
- $view = $event->sender;
- foreach (array_keys(self::$ckeditorPackages) as $name) {
- $bundle = $view->registerAssetBundle($name);
- if ($bundle instanceof BaseCkeditorPackageAsset) {
- $bundle->registerPackage();
- }
- }
- }
- });
-
Event::on(Element::class, Element::EVENT_AFTER_PROPAGATE, function(ModelEvent $event) {
/** @var Element $element */
$element = $event->sender;
diff --git a/src/helpers/CkeditorConfig.php b/src/helpers/CkeditorConfig.php
index cd5e0ccd..3d4519e7 100644
--- a/src/helpers/CkeditorConfig.php
+++ b/src/helpers/CkeditorConfig.php
@@ -8,6 +8,8 @@
namespace craft\ckeditor\helpers;
use Craft;
+use craft\ckeditor\Plugin;
+use craft\helpers\Json;
use Illuminate\Support\Collection;
/**
@@ -91,6 +93,8 @@ final class CkeditorConfig
/**
* Maps toolbar items to plugins so can only load applicable plugins when we render a field.
*
+ * Items registered via [[registerPackage()]] also have a `package` key, limiting them to plugins from that package.
+ *
* @var array
*/
public static array $pluginButtonMap = [
@@ -205,6 +209,13 @@ final class CkeditorConfig
];
+ /**
+ * Package namespaces provided by CKEditor and Craft.
+ *
+ * Their plugins are imported by name, so custom config JS can reference them directly.
+ */
+ private const CORE_PACKAGES = ['ckeditor5', '@craftcms/ckeditor'];
+
/**
* Register a custom CKEditor plugin
*
@@ -220,14 +231,23 @@ public static function registerPackage(string $name, array $config): void
if (!isset(self::$pluginsByPackage[$name])) {
self::$pluginsByPackage[$name] = $plugins;
} else {
- self::$pluginsByPackage[$name] = array_unique(array_merge(self::$pluginsByPackage[$name], $plugins));
+ self::$pluginsByPackage[$name] = array_values(array_unique(array_merge(self::$pluginsByPackage[$name], $plugins)));
}
- self::$toolbarItems[] = $toolbarItems;
- self::$pluginButtonMap[] = [
+ foreach ($toolbarItems as $toolbarItem) {
+ if (!in_array($toolbarItem, self::$toolbarItems, true)) {
+ self::$toolbarItems[] = $toolbarItem;
+ }
+ }
+
+ $mapItem = [
+ 'package' => $name,
'plugins' => $plugins,
- 'buttons' => $toolbarItems,
+ 'buttons' => self::buttonNames($toolbarItems),
];
+ if (!in_array($mapItem, self::$pluginButtonMap, true)) {
+ self::$pluginButtonMap[] = $mapItem;
+ }
}
/**
@@ -249,7 +269,7 @@ public static function registerFirstPartyPackage(array $pluginNames, array $tool
*/
public static function getPluginPackages(): array
{
- return array_keys(self::$pluginsByPackage);
+ return array_keys(self::getPluginsByPackage());
}
/**
@@ -260,15 +280,13 @@ public static function getPluginPackages(): array
*/
public static function getPluginsByPackage(string $name = null): array
{
+ Plugin::getCkeditorPackages();
+
if (!$name) {
return self::$pluginsByPackage;
}
- if (!in_array($name, self::getPluginPackages())) {
- return [];
- }
-
- return self::$pluginsByPackage[$name];
+ return self::$pluginsByPackage[$name] ?? [];
}
/**
@@ -283,15 +301,30 @@ public static function getAllPlugins(): array
->toArray();
}
+ /**
+ * Returns all available toolbar items.
+ *
+ * @return array
+ * @internal
+ */
+ public static function getToolbarItems(): array
+ {
+ Plugin::getCkeditorPackages();
+ return self::$toolbarItems;
+ }
+
/**
* Get the JavaScript import statements for all plugins
*
* @param string|null $name namespace of the package
* @return string
+ * @deprecated in 5.8.0.
*/
public static function getImportStatements(string $name = null): string
{
- return collect(self::getPluginsByPackage($name))
+ $plugins = $name ? [$name => self::getPluginsByPackage($name)] : self::getPluginsByPackage();
+
+ return collect($plugins)
->reduce(function(Collection $carry, array $plugins, string $import) {
$carry->push('import { ' . implode(', ', $plugins) . ' } from "' . $import . '";');
@@ -299,6 +332,157 @@ public static function getImportStatements(string $name = null): string
}, Collection::empty())->join("\n");
}
+ /**
+ * Returns the JavaScript import statements for an editor, along with the JavaScript expressions that
+ * reference each of its plugins.
+ *
+ * Plugins provided by CKEditor and Craft are imported by name. Plugins provided by other packages are
+ * referenced through a namespace import, so plugin names can’t collide across packages, and those packages
+ * aren’t imported at all if none of their plugins are needed.
+ *
+ * Plugins from other packages that the custom config JS refers to by name (e.g. `extraPlugins: [Tokens]`)
+ * are also imported by name, as long as no other package provides a plugin with the same name.
+ *
+ * @param string[]|null $toolbar The editor’s toolbar items, or `null` to import every registered plugin
+ * @param string[] $removePlugins Plugin names that should be left out
+ * @param string|null $configJs The field’s custom config JS
+ * @return array{0:string,1:string[],2:string[]} The import statements, the plugin references, and the
+ * namespaces of the packages that were imported
+ * @internal
+ */
+ public static function getImports(?array $toolbar = null, array $removePlugins = [], ?string $configJs = null): array
+ {
+ $allPluginsByPackage = self::getPluginsByPackage();
+ $pluginsByPackage = $toolbar !== null
+ ? self::pluginsForToolbar($toolbar, $removePlugins)
+ : $allPluginsByPackage;
+ $referencedPlugins = $configJs !== null ? self::referencedPlugins($configJs, $allPluginsByPackage) : [];
+ $namespaces = array_values(array_unique([
+ ...array_keys($pluginsByPackage),
+ ...array_keys($referencedPlugins),
+ ]));
+
+ $statements = [];
+ $references = [];
+ $i = 0;
+
+ // Core packages are always imported
+ foreach (self::CORE_PACKAGES as $namespace) {
+ $pluginsByPackage[$namespace] ??= [];
+ }
+
+ foreach ($pluginsByPackage as $namespace => $plugins) {
+ $isCorePackage = in_array($namespace, self::CORE_PACKAGES, true);
+
+ if (empty($plugins) && !$isCorePackage) {
+ continue;
+ }
+
+ $namespaceJs = Json::encode($namespace, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+ if ($isCorePackage) {
+ // These are loaded regardless, so import everything to keep it available to custom config JS
+ $allPlugins = $allPluginsByPackage[$namespace] ?? $plugins;
+ if (empty($allPlugins)) {
+ continue;
+ }
+ $statements[] = sprintf('import {%s} from %s;', implode(', ', $allPlugins), $namespaceJs);
+ array_push($references, ...array_values($plugins));
+ } else {
+ $alias = '__ckePackage' . $i++;
+ $statements[] = "import * as $alias from $namespaceJs;";
+ foreach ($plugins as $plugin) {
+ $references[] = "$alias.$plugin";
+ }
+ }
+ }
+
+ foreach ($referencedPlugins as $namespace => $plugins) {
+ $namespaceJs = Json::encode($namespace, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ $statements[] = sprintf('import {%s} from %s;', implode(', ', $plugins), $namespaceJs);
+ }
+
+ return [implode("\n", $statements), $references, $namespaces];
+ }
+
+ /**
+ * Returns the plugins from non-core packages that the given JS refers to by name, indexed by package namespace.
+ *
+ * Plugin names provided by more than one package are left out, since they can’t be imported unambiguously.
+ *
+ * @param string $js
+ * @param array $allPluginsByPackage
+ * @return array
+ */
+ private static function referencedPlugins(string $js, array $allPluginsByPackage): array
+ {
+ $counts = array_count_values(array_merge(...array_values($allPluginsByPackage)));
+ $referenced = [];
+
+ foreach ($allPluginsByPackage as $namespace => $plugins) {
+ if (in_array($namespace, self::CORE_PACKAGES, true)) {
+ continue;
+ }
+
+ foreach ($plugins as $plugin) {
+ // Match identifiers, not property names or strings (e.g. `removePlugins: ['Tokens']`)
+ $pattern = sprintf('/(?
+ */
+ private static function pluginsForToolbar(array $toolbar, array $removePlugins): array
+ {
+ Plugin::getCkeditorPackages();
+
+ $unused = collect(self::$pluginButtonMap)
+ ->filter(fn(array $item) =>
+ // If there are no buttons defined, always load it
+ !empty($item['buttons']) && empty(array_intersect($toolbar, $item['buttons'])))
+ ->values();
+
+ return collect(self::getPluginsByPackage())
+ ->map(function(array $plugins, string $namespace) use ($unused, $removePlugins) {
+ // Only remove unused plugins that belong to this package (or aren’t tied to one)
+ $remove = $unused
+ ->filter(fn(array $item) => !isset($item['package']) || $item['package'] === $namespace)
+ ->flatMap(fn(array $item) => $item['plugins'] ?? [])
+ ->merge($removePlugins)
+ ->all();
+ return array_values(array_diff($plugins, $remove));
+ })
+ ->filter()
+ ->all();
+ }
+
+ /**
+ * Returns the button names for the given toolbar items.
+ *
+ * @param array $toolbarItems
+ * @return string[]
+ */
+ private static function buttonNames(array $toolbarItems): array
+ {
+ return collect($toolbarItems)
+ ->flatMap(fn($item) => array_column(self::normalizeToolbarItem($item), 'button'))
+ ->all();
+ }
+
private static function normalizeToolbarItem($item): array
{
if (is_string($item)) {
diff --git a/src/web/assets/BaseCkeditorPackageAsset.php b/src/web/assets/BaseCkeditorPackageAsset.php
index a8c46b46..9c6bd17e 100644
--- a/src/web/assets/BaseCkeditorPackageAsset.php
+++ b/src/web/assets/BaseCkeditorPackageAsset.php
@@ -198,7 +198,7 @@ public function init(): void
*/
public function registerPackage(): void
{
- if (!empty($this->pluginNames || !empty($this->toolbarItems))) {
+ if (!empty($this->pluginNames) || !empty($this->toolbarItems)) {
CkeditorConfig::registerPackage($this->namespace, [
'plugins' => $this->pluginNames,
'toolbarItems' => $this->toolbarItems,