From 479c870e50c9c3756ad54ad0172b5e684898f7cd Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 25 Sep 2026 10:51:26 -0500 Subject: [PATCH 1/3] Refactor package registry --- CHANGELOG.md | 14 ++ README.md | 36 ++++- src/Field.php | 44 ++---- src/Plugin.php | 144 ++++++++++++++--- src/events/RegisterPackagesEvent.php | 31 ++++ src/helpers/CkeditorConfig.php | 162 ++++++++++++++++++-- src/web/assets/BaseCkeditorPackageAsset.php | 2 +- 7 files changed, 363 insertions(+), 70 deletions(-) create mode 100644 src/events/RegisterPackagesEvent.php diff --git a/CHANGELOG.md b/CHANGELOG.md index af498be9..4e13d241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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. +- Added `craft\ckeditor\Plugin::EVENT_REGISTER_CKEDITOR_PACKAGES`. +- Added `craft\ckeditor\Plugin::registerCkeditorPackageBundles()`. +- Added `craft\ckeditor\events\RegisterPackagesEvent`. +- 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..47a74fef 100644 --- a/README.md +++ b/README.md @@ -406,13 +406,43 @@ 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'); +use craft\ckeditor\events\RegisterPackagesEvent; +use craft\ckeditor\Plugin as CkeditorPlugin; +use yii\base\Event; + +Event::on(CkeditorPlugin::class, CkeditorPlugin::EVENT_REGISTER_CKEDITOR_PACKAGES, function(RegisterPackagesEvent $event) { + $event->packages[TokensAsset::class] = 'tokens.js'; +}); +``` + +The value 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 collected until Craft has finished initializing, so you can register yours from `init()` or a `Craft::$app->onInit()` callback, and it doesn’t matter which order plugins are loaded in. (Calling `\craft\ckeditor\Plugin::registerCkeditorPackage(TokensAsset::class, 'tokens.js')` works too.) + +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'], + ]); +} ``` -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. +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. 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 diff --git a/src/Field.php b/src/Field.php index 38f52fc0..444b8c2d 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,37 +1678,11 @@ 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())), - ]); - - $configPlugins = '[' . $plugins->flatten()->join(',') . ']'; + // Only import (and register the asset bundles for) packages that have plugins in use + [$imports, $pluginRefs, $namespaces] = CkeditorConfig::getImports($event->toolbar, $removePlugins->all()); + Plugin::registerCkeditorPackageBundles($view, $namespaces); - $imports = CkeditorConfig::getImportStatements(); + $configPlugins = '[' . implode(',', $pluginRefs) . ']'; // Add the translation import $uiLanguage = BaseCkeditorPackageAsset::uiLanguage(); diff --git a/src/Plugin.php b/src/Plugin.php index 72b4363a..a3dba663 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -10,17 +10,19 @@ use Craft; use craft\base\Element; use craft\ckeditor\deletionblockers\ReferenceDeletionBlocker; +use craft\ckeditor\events\RegisterPackagesEvent; +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; use craft\helpers\UrlHelper; use craft\services\Fields; use craft\web\View; +use yii\base\Application; use yii\base\Event; /** @@ -33,21 +35,130 @@ class Plugin extends \craft\base\Plugin { public const TABLE_REFERENCES = '{{%ckeditor_references}}'; + /** + * @event RegisterPackagesEvent The event that is triggered when registering CKEditor packages. + * + * Packages are collected the first time they’re needed, so handlers can be attached from any plugin or + * module’s `init()` method, regardless of load order. + * + * ```php + * use craft\ckeditor\events\RegisterPackagesEvent; + * use craft\ckeditor\Plugin; + * use yii\base\Event; + * + * Event::on(Plugin::class, Plugin::EVENT_REGISTER_CKEDITOR_PACKAGES, function(RegisterPackagesEvent $event) { + * $event->packages[TokensAsset::class] = 'tokens.js'; + * }); + * ``` + * + * @since 5.8.0 + */ + public const EVENT_REGISTER_CKEDITOR_PACKAGES = 'registerCkeditorPackages'; + /** * Registers an asset bundle for a CKEditor package. * * @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 resolved, load this one right away + if (self::$packagesResolved) { + self::loadCkeditorPackage($name, $entry); + } } + /** + * Returns the registered CKEditor packages. + * + * Packages registered via [[registerCkeditorPackage()]] and [[EVENT_REGISTER_CKEDITOR_PACKAGES]] are loaded + * the first time this is called. Until Craft has finished initializing, the event is triggered again on each + * call, so handlers attached from other plugins’ `Craft::$app->onInit()` callbacks aren’t missed. + * + * @return array Package info, indexed by asset bundle class name + * @internal + */ + public static function getCkeditorPackages(): array + { + if (!self::$packagesResolved) { + $event = new RegisterPackagesEvent([ + 'packages' => self::$ckeditorPackages, + ]); + Event::trigger(self::class, self::EVENT_REGISTER_CKEDITOR_PACKAGES, $event); + + foreach ($event->packages as $name => $entry) { + self::loadCkeditorPackage($name, $entry); + } + + // onInit() callbacks run while the app is still initializing, so only stop collecting once it’s done + self::$packagesResolved = Craft::$app->state >= Application::STATE_BEFORE_REQUEST; + } + + 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 $packagesResolved = false; public string $schemaVersion = '5.6.0.0'; @@ -68,12 +179,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 +197,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/events/RegisterPackagesEvent.php b/src/events/RegisterPackagesEvent.php new file mode 100644 index 00000000..1c9abfca --- /dev/null +++ b/src/events/RegisterPackagesEvent.php @@ -0,0 +1,31 @@ + + * @since 5.8.0 + */ +class RegisterPackagesEvent extends Event +{ + /** + * @var array CKEditor package asset bundles, mapping each bundle class name to its + * JavaScript entry file (relative to the bundle’s source path). + * + * Asset bundles should extend [[\craft\ckeditor\web\assets\BaseCkeditorPackageAsset]]. + * + * ```php + * $event->packages[TokensAsset::class] = 'tokens.js'; + * ``` + */ + public array $packages = []; +} diff --git a/src/helpers/CkeditorConfig.php b/src/helpers/CkeditorConfig.php index cd5e0ccd..0bb9716c 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))); + } + + foreach ($toolbarItems as $toolbarItem) { + if (!in_array($toolbarItem, self::$toolbarItems, true)) { + self::$toolbarItems[] = $toolbarItem; + } } - self::$toolbarItems[] = $toolbarItems; - self::$pluginButtonMap[] = [ + $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,113 @@ 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. + * + * @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 + * @return array{0:string,1:string[],2:string[]} The import statements, the plugin references, and the + * namespaces of the packages that have plugins in use + * @internal + */ + public static function getImports(?array $toolbar = null, array $removePlugins = []): array + { + $allPluginsByPackage = self::getPluginsByPackage(); + $pluginsByPackage = $toolbar !== null + ? self::pluginsForToolbar($toolbar, $removePlugins) + : $allPluginsByPackage; + $namespaces = array_keys($pluginsByPackage); + + $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"; + } + } + } + + return [implode("\n", $statements), $references, $namespaces]; + } + + /** + * Returns the plugins that should be loaded for the given toolbar, indexed by package namespace. + * + * Plugins tied to toolbar buttons are left out if none of their buttons are in the toolbar. Packages + * without any remaining plugins are left out entirely. + * + * @param string[] $toolbar The toolbar items + * @param string[] $removePlugins Additional plugin names that should be left out + * @return array + */ + 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, From d0d778d327f62911418ab209a1bd8b394cb34aad Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 25 Sep 2026 11:40:36 -0500 Subject: [PATCH 2/3] Import third-party plugins referenced by name in config JS Field config JS can refer to a third-party plugin class by name, e.g. `extraPlugins: [Tokens]`, to enable it without its toolbar button. Now that third-party packages are only imported when their buttons are in the toolbar, and via namespace imports, that reference would throw a ReferenceError. When a field's JS config mentions a third-party plugin by name, import its package with a bare named import and register its asset bundle, unless more than one package provides a plugin with that name. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 4 ++- src/Field.php | 13 ++++++--- src/helpers/CkeditorConfig.php | 50 ++++++++++++++++++++++++++++++++-- 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e13d241..b9c4f814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - 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. +- 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::EVENT_REGISTER_CKEDITOR_PACKAGES`. - Added `craft\ckeditor\Plugin::registerCkeditorPackageBundles()`. - Added `craft\ckeditor\events\RegisterPackagesEvent`. diff --git a/README.md b/README.md index 47a74fef..a427b22b 100644 --- a/README.md +++ b/README.md @@ -442,7 +442,9 @@ public function registerPackage(): void } ``` -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. If you need your package’s asset bundle on a page that creates its own CKEditor instances, call `\craft\ckeditor\Plugin::registerCkeditorPackageBundles($view)`. +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 diff --git a/src/Field.php b/src/Field.php index 444b8c2d..f69d1ec2 100644 --- a/src/Field.php +++ b/src/Field.php @@ -1678,8 +1678,15 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat $removePlugins->push('ImageTransforms'); } - // Only import (and register the asset bundles for) packages that have plugins in use - [$imports, $pluginRefs, $namespaces] = CkeditorConfig::getImports($event->toolbar, $removePlugins->all()); + $configJs = $this->configJs(); + + // 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); $configPlugins = '[' . implode(',', $pluginRefs) . ']'; @@ -1689,8 +1696,6 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat $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/helpers/CkeditorConfig.php b/src/helpers/CkeditorConfig.php index 0bb9716c..3d4519e7 100644 --- a/src/helpers/CkeditorConfig.php +++ b/src/helpers/CkeditorConfig.php @@ -340,19 +340,27 @@ public static function getImportStatements(string $name = null): string * 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 have plugins in use + * namespaces of the packages that were imported * @internal */ - public static function getImports(?array $toolbar = null, array $removePlugins = []): array + public static function getImports(?array $toolbar = null, array $removePlugins = [], ?string $configJs = null): array { $allPluginsByPackage = self::getPluginsByPackage(); $pluginsByPackage = $toolbar !== null ? self::pluginsForToolbar($toolbar, $removePlugins) : $allPluginsByPackage; - $namespaces = array_keys($pluginsByPackage); + $referencedPlugins = $configJs !== null ? self::referencedPlugins($configJs, $allPluginsByPackage) : []; + $namespaces = array_values(array_unique([ + ...array_keys($pluginsByPackage), + ...array_keys($referencedPlugins), + ])); $statements = []; $references = []; @@ -389,9 +397,45 @@ public static function getImports(?array $toolbar = null, array $removePlugins = } } + 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('/(? Date: Mon, 28 Sep 2026 09:25:35 -0500 Subject: [PATCH 3/3] Remove event --- CHANGELOG.md | 2 -- README.md | 12 ++----- src/Plugin.php | 49 +++++++--------------------- src/events/RegisterPackagesEvent.php | 31 ------------------ 4 files changed, 14 insertions(+), 80 deletions(-) delete mode 100644 src/events/RegisterPackagesEvent.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c4f814..7b21948d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,7 @@ - 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::EVENT_REGISTER_CKEDITOR_PACKAGES`. - Added `craft\ckeditor\Plugin::registerCkeditorPackageBundles()`. -- Added `craft\ckeditor\events\RegisterPackagesEvent`. - 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. diff --git a/README.md b/README.md index a427b22b..75bf3386 100644 --- a/README.md +++ b/README.md @@ -409,18 +409,12 @@ class TokensAsset extends BaseCkeditorPackageAsset Finally, register the asset bundle from your plugin’s `init()` method: ```php -use craft\ckeditor\events\RegisterPackagesEvent; -use craft\ckeditor\Plugin as CkeditorPlugin; -use yii\base\Event; - -Event::on(CkeditorPlugin::class, CkeditorPlugin::EVENT_REGISTER_CKEDITOR_PACKAGES, function(RegisterPackagesEvent $event) { - $event->packages[TokensAsset::class] = 'tokens.js'; -}); +\craft\ckeditor\Plugin::registerCkeditorPackage(TokensAsset::class, 'tokens.js'); ``` -The value 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. +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 collected until Craft has finished initializing, so you can register yours from `init()` or a `Craft::$app->onInit()` callback, and it doesn’t matter which order plugins are loaded in. (Calling `\craft\ckeditor\Plugin::registerCkeditorPackage(TokensAsset::class, 'tokens.js')` works too.) +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: diff --git a/src/Plugin.php b/src/Plugin.php index a3dba663..833f43ec 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -10,7 +10,6 @@ use Craft; use craft\base\Element; use craft\ckeditor\deletionblockers\ReferenceDeletionBlocker; -use craft\ckeditor\events\RegisterPackagesEvent; use craft\ckeditor\helpers\CkeditorConfig; use craft\ckeditor\web\assets\BaseCkeditorPackageAsset; use craft\ckeditor\web\assets\ckeditor\CkeditorAsset; @@ -22,7 +21,6 @@ use craft\helpers\UrlHelper; use craft\services\Fields; use craft\web\View; -use yii\base\Application; use yii\base\Event; /** @@ -35,29 +33,12 @@ class Plugin extends \craft\base\Plugin { public const TABLE_REFERENCES = '{{%ckeditor_references}}'; - /** - * @event RegisterPackagesEvent The event that is triggered when registering CKEditor packages. - * - * Packages are collected the first time they’re needed, so handlers can be attached from any plugin or - * module’s `init()` method, regardless of load order. - * - * ```php - * use craft\ckeditor\events\RegisterPackagesEvent; - * use craft\ckeditor\Plugin; - * use yii\base\Event; - * - * Event::on(Plugin::class, Plugin::EVENT_REGISTER_CKEDITOR_PACKAGES, function(RegisterPackagesEvent $event) { - * $event->packages[TokensAsset::class] = 'tokens.js'; - * }); - * ``` - * - * @since 5.8.0 - */ - public const EVENT_REGISTER_CKEDITOR_PACKAGES = 'registerCkeditorPackages'; - /** * 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. @@ -67,8 +48,8 @@ public static function registerCkeditorPackage(string $name, string $entry = 'in { self::$ckeditorPackages[$name] = $entry; - // If packages have already been resolved, load this one right away - if (self::$packagesResolved) { + // If packages have already been loaded, load this one right away + if (self::$packagesLoaded) { self::loadCkeditorPackage($name, $entry); } } @@ -76,27 +57,19 @@ public static function registerCkeditorPackage(string $name, string $entry = 'in /** * Returns the registered CKEditor packages. * - * Packages registered via [[registerCkeditorPackage()]] and [[EVENT_REGISTER_CKEDITOR_PACKAGES]] are loaded - * the first time this is called. Until Craft has finished initializing, the event is triggered again on each - * call, so handlers attached from other plugins’ `Craft::$app->onInit()` callbacks aren’t missed. + * 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::$packagesResolved) { - $event = new RegisterPackagesEvent([ - 'packages' => self::$ckeditorPackages, - ]); - Event::trigger(self::class, self::EVENT_REGISTER_CKEDITOR_PACKAGES, $event); - - foreach ($event->packages as $name => $entry) { + if (!self::$packagesLoaded) { + self::$packagesLoaded = true; + foreach (self::$ckeditorPackages as $name => $entry) { self::loadCkeditorPackage($name, $entry); } - - // onInit() callbacks run while the app is still initializing, so only stop collecting once it’s done - self::$packagesResolved = Craft::$app->state >= Application::STATE_BEFORE_REQUEST; } return self::$loadedPackages; @@ -158,7 +131,7 @@ private static function loadCkeditorPackage(string $name, string $entry): void /** * @see getCkeditorPackages() */ - private static bool $packagesResolved = false; + private static bool $packagesLoaded = false; public string $schemaVersion = '5.6.0.0'; diff --git a/src/events/RegisterPackagesEvent.php b/src/events/RegisterPackagesEvent.php deleted file mode 100644 index 1c9abfca..00000000 --- a/src/events/RegisterPackagesEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 5.8.0 - */ -class RegisterPackagesEvent extends Event -{ - /** - * @var array CKEditor package asset bundles, mapping each bundle class name to its - * JavaScript entry file (relative to the bundle’s source path). - * - * Asset bundles should extend [[\craft\ckeditor\web\assets\BaseCkeditorPackageAsset]]. - * - * ```php - * $event->packages[TokensAsset::class] = 'tokens.js'; - * ``` - */ - public array $packages = []; -}