Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<img>` 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))
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,14 +406,40 @@ 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');
```

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:
Expand Down
51 changes: 17 additions & 34 deletions src/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
117 changes: 95 additions & 22 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string,array{namespace:string,entry:string}> 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<string,string> Package entry files, indexed by asset bundle class name
*/
private static array $ckeditorPackages = [];
private static array $ckeditorImports = [];

/**
* @var array<string,array{namespace:string,entry:string}>
* @see getCkeditorPackages()
*/
private static array $loadedPackages = [];

/**
* @see getCkeditorPackages()
*/
private static bool $packagesLoaded = false;

public string $schemaVersion = '5.6.0.0';

Expand All @@ -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) {
Expand All @@ -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;
Expand Down
Loading
Loading