Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines the "CKEditor 5 aspects of a CKEditor5Plugin" annotation object.
*
* Plugin Namespace: Plugin\CKEditor5Plugin.
*
* @see \Drupal\ckeditor5\Plugin\CKEditorPluginInterface
* @see \Drupal\ckeditor5\Plugin\CKEditorPluginBase
* @see \Drupal\ckeditor5\Plugin\CKEditorPluginManager
* @see plugin_api
*
* @Annotation
* @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin
* @see \Drupal\ckeditor5\Annotation\DrupalPartsOfCKEditor5Plugin
*/
class CKEditor5AspectsOfCKEditor5Plugin extends Plugin {
/**
* The CKEditor 5 plugin classes provided.
*
* Found in the CKEditor5 global js object as {package.Class}.
*
* @var string[]
*/
public $plugins;
/**
* A keyed array of additional values for the CKEditor 5 configuration.
*
* This property is optional and it does not need to be declared.
*
* @var array[]
*/
public $config = [];
}

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Annotation;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a CKEditor5Plugin annotation object.
*
* Plugin Namespace: Plugin\CKEditor5Plugin.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
* @see plugin_api
*
* @Annotation
* @see \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin
*/
class CKEditor5Plugin extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The CKEditor 5 aspects of the plugin definition.
*
* @var \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin
*/
public $ckeditor5;
/**
* The Drupal aspects of the plugin definition.
*
* @var \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin
*/
public $drupal;
/**
* {@inheritdoc}
*
* Overridden for compatibility with the AnnotationBridgeDecorator, which
* ensures YAML-defined CKEditor 5 plugin definitions are also processed by
* annotations. Unfortunately it does not (yet) support nested annotations.
* Force YAML-defined plugin definitions to be parsed by the
* annotations, to ensure consistent handling of defaults.
*
* @see \Drupal\Component\Annotation\Plugin\Discovery\AnnotationBridgeDecorator::getDefinitions()
*/
public function __construct($values) {
if (isset($values['ckeditor5']) && is_array($values['ckeditor5'])) {
$values['ckeditor5'] = new CKEditor5AspectsOfCKEditor5Plugin($values['ckeditor5']);
}
if (isset($values['drupal']) && is_array($values['drupal'])) {
$values['drupal'] = new DrupalAspectsOfCKEditor5Plugin($values['drupal']);
}
parent::__construct($values);
}
/**
* {@inheritdoc}
*/
public function getClass() {
return $this->definition['drupal']['class'];
}
/**
* {@inheritdoc}
*/
public function setClass($class) {
$this->definition['drupal']['class'] = $class;
}
/**
* {@inheritdoc}
*/
public function get(): CKEditor5PluginDefinition {
return new CKEditor5PluginDefinition($this->definition);
}
}

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Annotation;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\Component\Annotation\Plugin;
/**
* Defines the "Drupal aspects of a CKEditor5Plugin" annotation object.
*
* Plugin Namespace: Plugin\CKEditor5Plugin.
*
* @see \Drupal\ckeditor5\Plugin\CKEditorPluginInterface
* @see \Drupal\ckeditor5\Plugin\CKEditorPluginBase
* @see \Drupal\ckeditor5\Plugin\CKEditorPluginManager
* @see plugin_api
*
* @Annotation
* @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin
* @see \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin
*/
class DrupalAspectsOfCKEditor5Plugin extends Plugin {
/**
* The human-readable name of the CKEditor plugin.
*
* @var \Drupal\Core\Annotation\Translation
* @ingroup plugin_translatable
*/
public $label;
/**
* The CKEditor 5 plugin class.
*
* If not specified, the CKEditor5PluginDefault class is used.
*
* This property is optional and it does not need to be declared.
*
* @var string
*/
public $class = CKEditor5PluginDefault::class;
/**
* The CKEditor 5 plugin deriver class.
*
* This property is optional and it does not need to be declared.
*
* @var string|null
*/
public $deriver = NULL;
/**
* The library this plugin requires.
*
* This property is optional and it does not need to be declared.
*
* @var string|false
*/
public $library = FALSE;
/**
* The admin library this plugin provides.
*
* This property is optional and it does not need to be declared.
*
* @var string|false
*/
public $admin_library = FALSE;
/**
* List of elements and attributes provided.
*
* An array of strings, or false if no elements are provided.
*
* Syntax for each array value:
* - <element> only allows that HTML element with no attributes
* - <element attrA attrB> only allows that HTML element with attributes attrA
* and attrB, and any value for those attributes.
* - <element attrA="foo bar baz" attrB="qux-*"> only allows that HTML element
* with attributes attrA (if attrA contains one of the three listed values)
* and attrB (if its value has the provided prefix).
* - <element data-*> only allows that HTML element with any attribute that
* has the given prefix.
*
* Note that <element> means such an element (tag) can be created, whereas
* <element attrA attrB> means that `attrA` and `attrB` can be created on the
* tag. If a plugin supports both creating the element as well as setting some
* attributes or attribute values on it, it should have distinct entries in
* the list.
* For example, for a link plugin: `<a>` and `<a href>`. The first indicates
* the plugin can create such tags, the second indicates it can set the `href`
* attribute on it. If the first were omitted, the Drupal CKEditor 5 module
* would interpret that as "this plugin cannot create `<a>`, it can only set
* the `href` attribute on it".
*
* @var string[]|false
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCreatableElements()
*/
public $elements;
/**
* List of toolbar items the plugin provides.
*
* This property is optional and it does not need to be declared.
*
* @var array[]
*/
public $toolbar_items = [];
/**
* List of conditions to enable this plugin.
*
* This property is optional and it does not need to be declared.
*
* @var array|false
*/
public $conditions = FALSE;
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
/**
* Defines the CKEditor5 aspect of CKEditor5 plugin.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class CKEditor5AspectsOfCKEditor5Plugin extends Plugin {
/**
* Constructs a CKEditor5AspectsOfCKEditor5Plugin attribute.
*
* @param class-string[] $plugins
* The CKEditor 5 plugin classes provided. Found in the CKEditor5 global js
* object as {package.Class}.
* @param array $config
* (optional) A keyed array of additional values for the CKEditor 5
* configuration.
*/
public function __construct(
public readonly array $plugins,
public readonly array $config = [],
) {}
/**
* {@inheritdoc}
*/
public function get(): array|object {
return [
'plugins' => $this->plugins,
'config' => $this->config,
];
}
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Attribute;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* The CKEditor5Plugin attribute.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class CKEditor5Plugin extends Plugin {
/**
* The CKEditor 5 aspects of the plugin definition.
*
* @var \Drupal\ckeditor5\Attribute\CKEditor5AspectsOfCKEditor5Plugin|null
*/
public readonly ?CKEditor5AspectsOfCKEditor5Plugin $ckeditor5;
/**
* The Drupal aspects of the plugin definition.
*
* @var \Drupal\ckeditor5\Attribute\DrupalAspectsOfCKEditor5Plugin|null
*/
public readonly ?DrupalAspectsOfCKEditor5Plugin $drupal;
/**
* Constructs a CKEditor5Plugin attribute.
*
* Overridden for compatibility with the AttributeBridgeDecorator, which
* ensures YAML-defined CKEditor 5 plugin definitions are also processed by
* attributes. Unfortunately it does not (yet) support nested attributes.
* Force YAML-defined plugin definitions to be parsed by the attributes, to
* ensure consistent handling of defaults.
*
* @param string $id
* The plugin ID.
* @param array|\Drupal\ckeditor5\Attribute\CKEditor5AspectsOfCKEditor5Plugin|null $ckeditor5
* (optional) The CKEditor 5 aspects of the plugin definition. Required
* unless set by deriver.
* @param array|\Drupal\ckeditor5\Attribute\DrupalAspectsOfCKEditor5Plugin|null $drupal
* (optional) The Drupal aspects of the plugin definition. Required unless
* set by deriver.
* @param class-string|null $deriver
* (optional) The deriver class.
*
* @see \Drupal\Component\Plugin\Discovery\AttributeBridgeDecorator::getDefinitions()
*/
public function __construct(
public readonly string $id,
array|CKEditor5AspectsOfCKEditor5Plugin|null $ckeditor5 = NULL,
array|DrupalAspectsOfCKEditor5Plugin|null $drupal = NULL,
public readonly ?string $deriver = NULL,
) {
// If either of the two aspects of the plugin definition is in array form,
// then this is a YAML-defined CKEditor 5 plugin definition. To avoid errors
// due to violating either Attribute class constructor, verify basic data
// shape requirements here. This provides a better DX for YAML-defined
// plugins, and avoids the need for a PHP IDE or debugger.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::processDefinition()
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateCKEditor5Aspects()
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
if (!$drupal instanceof DrupalAspectsOfCKEditor5Plugin) {
if ($drupal === NULL) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "drupal" key.', $id));
}
// TRICKY: $this->deriver is incorrect due to AttributeBridgeDecorator!
// If there's no deriver, validate here. Otherwise: the base definition is
// allowed to be incomplete; let CKEditor5PluginManager::processDefinition
// perform the validation.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getDeriver()
// @see \Drupal\Component\Plugin\Discovery\AttributeBridgeDecorator::getDefinitions()
if (!isset($drupal['deriver'])) {
if (isset($drupal['label']) && !is_string($drupal['label']) && !$drupal['label'] instanceof TranslatableMarkup) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a "drupal.label" value that is not a string nor a TranslatableMarkup instance.', $id));
}
if (!$ckeditor5 instanceof CKEditor5AspectsOfCKEditor5Plugin) {
if ($ckeditor5 === NULL) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "ckeditor5" key.', $id));
}
if (!isset($ckeditor5['plugins'])) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "ckeditor5.plugins" key.', $id));
}
}
}
}
$this->ckeditor5 = is_array($ckeditor5) ? new CKEditor5AspectsOfCKEditor5Plugin(...$ckeditor5) : $ckeditor5;
$this->drupal = is_array($drupal) ? new DrupalAspectsOfCKEditor5Plugin(...$drupal) : $drupal;
}
/**
* {@inheritdoc}
*/
public function getClass(): string {
return $this->drupal?->getClass() ?? '';
}
/**
* {@inheritdoc}
*/
public function setClass($class): void {
$this->drupal?->setClass($class);
}
/**
* {@inheritdoc}
*/
public function get(): CKEditor5PluginDefinition {
return new CKEditor5PluginDefinition([
'id' => $this->id,
'ckeditor5' => $this->ckeditor5?->get(),
'drupal' => $this->drupal?->get(),
'provider' => $this->getProvider(),
]);
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Attribute;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Define a Drupal aspects of CKEditor5 plugin.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class DrupalAspectsOfCKEditor5Plugin extends Plugin {
/**
* Constructs a DrupalAspectsOfCKEditor5Plugin attribute.
*
* @param string|\Drupal\Core\StringTranslation\TranslatableMarkup|null $label
* (optional) The human-readable name of the CKEditor plugin. Required
* unless set by deriver.
* @param class-string $class
* (optional) The CKEditor 5 plugin class.If not specified, the
* CKEditor5PluginDefault class is used.
* @param string|false $library
* (optional) The library this plugin requires.
* @param string|false $admin_library
* (optional) The admin library this plugin provides.
* @param string[]|false|null $elements
* (optional) List of elements and attributes provided. An array of strings,
* or false if no elements are provided. Required unless set by deriver.
* Syntax for each array value:
* - <element> only allows that HTML element with no attributes
* - <element attrA attrB> only allows that HTML element with attributes
* attrA and attrB, and any value for those attributes.
* - <element attrA="foo bar baz" attrB="qux-*"> only allows that HTML
* element with attributes attrA (if attrA contains one of the three
* listed values) and attrB (if its value has the provided prefix).
* - <element data-*> only allows that HTML element with any attribute that
* has the given prefix.
* Note that <element> means such an element (tag) can be created, whereas
* <element attrA attrB> means that `attrA` and `attrB` can be created on
* the tag. If a plugin supports both creating the element as well as
* setting some attributes or attribute values on it, it should have
* distinct entries in the list.
* For example, for a link plugin: `<a>` and `<a href>`. The first indicates
* the plugin can create such tags, the second indicates it can set the
* `href` attribute on it. If the first were omitted, the Drupal CKEditor 5
* module would interpret that as "this plugin cannot create `<a>`, it can
* only set the `href` attribute on it".
* @param array $toolbar_items
* (optional) List of toolbar items the plugin provides.
* @param array|false $conditions
* (optional) List of conditions to enable this plugin.
* @param class-string|null $deriver
* (optional) The deriver class.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCreatableElements()
*/
public function __construct(
public readonly string|TranslatableMarkup|null $label = NULL,
public string $class = CKEditor5PluginDefault::class,
public readonly string|false $library = FALSE,
public readonly string|false $admin_library = FALSE,
public readonly array|false|null $elements = NULL,
public readonly array $toolbar_items = [],
public readonly array|false $conditions = FALSE,
public readonly ?string $deriver = NULL,
) {}
}

View File

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Controller;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Component\Utility\Bytes;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\Environment;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\editor\Entity\Editor;
use Drupal\file\Upload\FileUploadHandlerInterface;
use Drupal\file\Upload\FormUploadedFile;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Lock\Exception\LockAcquiringException;
use Symfony\Component\Mime\MimeTypes;
/**
* Returns response for CKEditor 5 Simple image upload adapter.
*
* @internal
* Controller classes are internal.
*/
class CKEditor5ImageController extends ControllerBase {
/**
* Constructs a new CKEditor5ImageController.
*
* @param \Drupal\Core\File\FileSystemInterface $fileSystem
* The file system service.
* @param \Drupal\file\Upload\FileUploadHandlerInterface $fileUploadHandler
* The file upload handler.
* @param \Drupal\Core\Lock\LockBackendInterface $lock
* The lock service.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface $pluginManager
* The CKEditor 5 plugin manager.
*/
public function __construct(
protected FileSystemInterface $fileSystem,
protected FileUploadHandlerInterface $fileUploadHandler,
protected LockBackendInterface $lock,
protected CKEditor5PluginManagerInterface $pluginManager,
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('file_system'),
$container->get('file.upload_handler'),
$container->get('lock'),
$container->get('plugin.manager.ckeditor5.plugin')
);
}
/**
* Uploads and saves an image from a CKEditor 5 POST.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON object including the file URL.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
* Thrown when file system errors occur.
* @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
* Thrown when validation errors occur.
*/
public function upload(Request $request): Response {
// Getting the UploadedFile directly from the request.
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile|null $upload */
$upload = $request->files->get('upload');
if ($upload === NULL || !$upload->isValid()) {
throw new HttpException(500, $upload?->getErrorMessage() ?: 'Invalid file upload');
}
$filename = $upload->getClientOriginalName();
/** @var \Drupal\editor\EditorInterface $editor */
$editor = $request->attributes->get('editor');
$settings = $editor->getImageUploadSettings();
$destination = $settings['scheme'] . '://' . $settings['directory'];
// Check the destination file path is writable.
if (!$this->fileSystem->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY)) {
throw new HttpException(500, 'Destination file path is not writable');
}
$validators = $this->getImageUploadValidators($settings);
$file_uri = "{$destination}/{$filename}";
$file_uri = $this->fileSystem->getDestinationFilename($file_uri, FileExists::Rename);
// Lock based on the prepared file URI.
$lock_id = $this->generateLockIdFromFileUri($file_uri);
if (!$this->lock->acquire($lock_id)) {
throw new HttpException(503, sprintf('File "%s" is already locked for writing.', $file_uri), NULL, ['Retry-After' => 1]);
}
try {
$uploadedFile = new FormUploadedFile($upload);
$uploadResult = $this->fileUploadHandler->handleFileUpload($uploadedFile, $validators, $destination, FileExists::Rename);
if ($uploadResult->hasViolations()) {
throw new UnprocessableEntityHttpException((string) $uploadResult->getViolations());
}
}
catch (FileException) {
throw new HttpException(500, 'File could not be saved');
}
catch (LockAcquiringException) {
throw new HttpException(503, sprintf('File "%s" is already locked for writing.', $upload->getClientOriginalName()), NULL, ['Retry-After' => 1]);
}
$this->lock->release($lock_id);
$file = $uploadResult->getFile();
return new JsonResponse([
'url' => $file->createFileUrl(),
'uuid' => $file->uuid(),
'entity_type' => $file->getEntityTypeId(),
], 201);
}
/**
* Gets the image upload validators.
*/
protected function getImageUploadValidators(array $settings): array {
$max_filesize = $settings['max_size']
? Bytes::toNumber($settings['max_size'])
: Environment::getUploadMaxSize();
$max_dimensions = 0;
if (!empty($settings['max_dimensions']['width']) || !empty($settings['max_dimensions']['height'])) {
$max_dimensions = $settings['max_dimensions']['width'] . 'x' . $settings['max_dimensions']['height'];
}
$mimetypes = MimeTypes::getDefault();
$imageUploadPlugin = $this->pluginManager->getDefinition('ckeditor5_imageUpload')->toArray();
$allowed_extensions = [];
foreach ($imageUploadPlugin['ckeditor5']['config']['image']['upload']['types'] as $mime_type) {
$allowed_extensions = array_merge($allowed_extensions, $mimetypes->getExtensions('image/' . $mime_type));
}
return [
'FileExtension' => [
'extensions' => implode(' ', $allowed_extensions),
],
'FileSizeLimit' => [
'fileLimit' => $max_filesize,
],
'FileImageDimensions' => [
'maxDimensions' => $max_dimensions,
],
];
}
/**
* Access check based on whether image upload is enabled or not.
*
* @param \Drupal\editor\Entity\Editor $editor
* The text editor for which an image upload is occurring.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function imageUploadEnabledAccess(Editor $editor) {
if ($editor->getEditor() !== 'ckeditor5') {
return AccessResult::forbidden();
}
if ($editor->getImageUploadSettings()['status'] !== TRUE) {
return AccessResult::forbidden();
}
return AccessResult::allowed();
}
/**
* Generates a lock ID based on the file URI.
*
* @param string $file_uri
* The file URI.
*
* @return string
* The generated lock ID.
*/
protected static function generateLockIdFromFileUri($file_uri) {
return 'file:ckeditor5:' . Crypt::hashBase64($file_uri);
}
}

View File

@ -0,0 +1,183 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Controller;
use Drupal\Component\Uuid\Uuid;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\editor\Entity\Editor;
use Drupal\image\Plugin\Field\FieldType\ImageItem;
use Drupal\media\MediaInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
// cspell:ignore mediaimagetextalternative mediaimagetextalternativeui
/**
* Provides an API for checking if a media entity has image field.
*
* @internal
* Controller classes are internal.
*/
class CKEditor5MediaController extends ControllerBase {
/**
* The currently authenticated user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The entity repository.
*
* @var \Drupal\Core\Entity\EntityRepositoryInterface
*/
protected $entityRepository;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* Constructs a new CKEditor5MediaController.
*
* @param \Drupal\Core\Session\AccountInterface $current_user
* The currently authenticated user.
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
*/
public function __construct(AccountInterface $current_user, EntityRepositoryInterface $entity_repository, RequestStack $request_stack) {
$this->currentUser = $current_user;
$this->entityRepository = $entity_repository;
$this->requestStack = $request_stack;
}
/**
* Returns JSON response containing metadata about media entity.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON object including the response.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Thrown when no media UUID is provided.
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* Thrown when no media with the provided UUID exists.
*/
public function mediaEntityMetadata(Request $request) {
$uuid = $request->query->get('uuid');
if (!$uuid || !Uuid::isValid($uuid)) {
throw new BadRequestHttpException();
}
// Access is enforced on route level.
// @see \Drupal\ckeditor5\Controller\CKEditor5MediaController::access().
if (!$media = $this->entityRepository->loadEntityByUuid('media', $uuid)) {
throw new NotFoundHttpException();
}
$image_field = $this->getMediaImageSourceFieldName($media);
$response = [];
$response['type'] = $media->bundle();
// If this uses the image media source and the "alt" field is enabled,
// expose additional metadata.
// @see \Drupal\media\Plugin\media\Source\Image
// @see core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativeui.js
if ($image_field) {
$settings = $media->{$image_field}->getItemDefinition()->getSettings();
if (!empty($settings['alt_field'])) {
$response['imageSourceMetadata'] = [
'alt' => $this->entityRepository->getTranslationFromContext($media)->{$image_field}->alt,
];
}
}
// Note that we intentionally do not use:
// - \Drupal\Core\Cache\CacheableResponse because caching it on the server
// side is wasteful, hence there is no need for cacheability metadata.
// - \Drupal\Core\Render\HtmlResponse because there is no need for
// attachments nor cacheability metadata.
return (new JsonResponse($response, 200))
// Do not allow any intermediary to cache the response, only the end user.
->setPrivate()
// Allow the end user to cache it for up to 5 minutes.
->setMaxAge(300);
}
/**
* Additional access check for ::isMediaImage().
*
* This grants access if media embed filter is enabled on the filter format
* and user has access to view the media entity.
*
* Note that access to the filter format is not checked here because the route
* is configured to check entity access to the filter format.
*
* @param \Drupal\editor\Entity\Editor $editor
* The text editor.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Thrown when no media UUID is provided.
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* Thrown when no media with the provided UUID exists.
*/
public function access(Editor $editor): AccessResultInterface {
if ($editor->getEditor() !== 'ckeditor5') {
return AccessResult::forbidden();
}
// @todo add current request as an argument after
// https://www.drupal.org/project/drupal/issues/2786941 has been resolved.
$request = $this->requestStack->getCurrentRequest();
$uuid = $request->query->get('uuid');
if (!$uuid || !Uuid::isValid($uuid)) {
throw new BadRequestHttpException();
}
$media = $this->entityRepository->loadEntityByUuid('media', $uuid);
if (!$media) {
throw new NotFoundHttpException();
}
$filters = $editor->getFilterFormat()->filters();
return AccessResult::allowedIf($filters->has('media_embed') && $filters->get('media_embed')->status)
->andIf($media->access('view', $this->currentUser, TRUE))
->addCacheableDependency($editor->getFilterFormat());
}
/**
* Gets the name of an image media item's source field.
*
* @param \Drupal\media\MediaInterface $media
* The media item being embedded.
*
* @return string|null
* The name of the image source field configured for the media item, or
* NULL if the source field is not an image field.
*/
protected function getMediaImageSourceFieldName(MediaInterface $media) {
$field_definition = $media->getSource()
->getSourceFieldDefinition($media->bundle->entity);
$item_class = $field_definition->getItemDefinition()->getClass();
if (is_a($item_class, ImageItem::class, TRUE)) {
return $field_definition->getName();
}
return NULL;
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\EventSubscriber;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* A subscriber invalidating cache tags when the default theme changes.
*
* @internal
* This class may change at any time. It is not for use outside this module.
*/
class CKEditor5CacheTag implements EventSubscriberInterface {
/**
* The cache tags invalidator.
*
* @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
*/
protected $cacheTagsInvalidator;
/**
* Constructs a CKEditor5CacheTag object.
*
* @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tags_invalidator
* The cache tags invalidator.
*/
public function __construct(CacheTagsInvalidatorInterface $cache_tags_invalidator) {
$this->cacheTagsInvalidator = $cache_tags_invalidator;
}
/**
* Invalidates cache tags when particular system config objects are saved.
*
* @param \Drupal\Core\Config\ConfigCrudEvent $event
* The Event to process.
*/
public function onSave(ConfigCrudEvent $event) {
$config_name = $event->getConfig()->getName();
// Ckeditor5-stylesheets settings may change when the default theme changes.
if ($config_name === 'system.theme' && $event->isChanged('default')) {
// @see ckeditor5_library_info_alter()
$this->cacheTagsInvalidator->invalidateTags(['library_info']);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events[ConfigEvents::SAVE][] = ['onSave'];
return $events;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,380 @@
<?php
namespace Drupal\ckeditor5\Hook;
use Drupal\Core\Hook\Order\OrderAfter;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Asset\AttachedAssetsInterface;
use Drupal\Core\Render\Element;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for ckeditor5.
*/
class Ckeditor5Hooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.ckeditor5':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The CKEditor 5 module provides a highly-accessible, highly-usable visual text editor and adds a toolbar to text fields. Users can use buttons to format content and to create semantically correct and valid HTML. The CKEditor module uses the framework provided by the <a href=":text_editor">Text Editor module</a>. It requires JavaScript to be enabled in the browser. For more information, see the <a href=":doc_url">online documentation for the CKEditor 5 module</a> and the <a href=":cke5_url">CKEditor 5 website</a>.', [
':doc_url' => 'https://www.drupal.org/docs/contributed-modules/ckeditor-5',
':cke5_url' => 'https://ckeditor.com/ckeditor-5/',
':text_editor' => Url::fromRoute('help.page', [
'name' => 'editor',
])->toString(),
]) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Enabling CKEditor 5 for individual text formats') . '</dt>';
$output .= '<dd>' . $this->t('CKEditor 5 has to be installed and configured separately for individual text formats from the <a href=":formats">Text formats and editors page</a> because the filter settings for each text format can be different. For more information, see the <a href=":text_editor">Text Editor help page</a> and <a href=":filter">Filter help page</a>.', [
':formats' => Url::fromRoute('filter.admin_overview')->toString(),
':text_editor' => Url::fromRoute('help.page', [
'name' => 'editor',
])->toString(),
':filter' => Url::fromRoute('help.page', [
'name' => 'filter',
])->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Configuring the toolbar') . '</dt>';
$output .= '<dd>' . $this->t('When CKEditor 5 is chosen from the <em>Text editor</em> drop-down menu, its toolbar configuration is displayed. You can add and remove buttons from the <em>Active toolbar</em> by dragging and dropping them. Separators and rows can be added to organize the buttons.') . '</dd>';
$output .= '<dt>' . $this->t('Filtering HTML content') . '</dt>';
$output .= '<dd>' . $this->t("Unlike other text editors, plugin configuration determines the tags and attributes allowed in text formats using CKEditor 5. If using the <em>Limit allowed HTML tags and correct faulty HTML</em> filter, this filter's values will be automatically set based on enabled plugins and toolbar items.");
$output .= '<dt>' . $this->t('Toggling between formatted text and HTML source') . '</dt>';
$output .= '<dd>' . $this->t('If the <em>Source</em> button is available in the toolbar, users can click this button to disable the visual editor and edit the HTML source directly. After toggling back, the visual editor uses the HTML tags allowed via plugin configuration (and not explicity disallowed by filters) to format the text. Tags not enabled via plugin configuration will be stripped out of the HTML source when the user toggles back to the text editor.') . '</dd>';
$output .= '<dt>' . $this->t('Developing CKEditor 5 plugins in Drupal') . '</dt>';
$output .= '<dd>' . $this->t('See the <a href=":dev_docs_url">online documentation</a> for detailed information on developing CKEditor 5 plugins for use in Drupal.', [
':dev_docs_url' => 'https://www.drupal.org/docs/contributed-modules/ckeditor-5/plugin-and-contrib-module-development',
]) . '</dd>';
$output .= '</dd>';
$output .= '<dt>' . $this->t('Accessibility features') . '</dt>';
$output .= '<dd>' . $this->t('The built in WYSIWYG editor (CKEditor 5) comes with a number of accessibility features. CKEditor 5 comes with built in <a href=":shortcuts">keyboard shortcuts</a>, which can be beneficial for both power users and keyboard only users.', [
':shortcuts' => 'https://ckeditor.com/docs/ckeditor5/latest/features/keyboard-support.html',
]) . '</dd>';
$output .= '<dt>' . $this->t('Generating accessible content') . '</dt>';
$output .= '<dd>';
$output .= '<ul>';
$output .= '<li>' . $this->t('HTML tables can be created with table headers and caption/summary elements.') . '</li>';
$output .= '<li>' . $this->t('Alt text is required by default on images added through CKEditor (note that this can be overridden).') . '</li>';
$output .= '<li>' . $this->t('Semantic HTML5 figure/figcaption are available to add captions to images.') . '</li>';
$output .= '<li>' . $this->t('To support multilingual page content, CKEditor 5 can be configured to include a language button in the toolbar.') . '</li>';
$output .= '</ul>';
$output .= '</dd>';
$output .= '</dl>';
$output .= '<h3 id="migration-settings">' . $this->t('Migrating an Existing Text Format to CKEditor 5') . '</h2>';
$output .= '<p>' . $this->t('When switching an existing text format to use CKEditor 5, an automatic process is initiated that helps text formats switching to CKEditor 5 from CKEditor 4 (or no text editor) to do so with minimal effort and zero data loss.') . '</p>';
$output .= '<p>' . $this->t("This process is designed for there to be no data loss risk in switching to CKEditor 5. However some of your editor's functionality may not be 100% equivalent to what was available previously. In most cases, these changes are minimal. After the process completes, status and/or warning messages will summarize any changes that occurred, and more detailed information will be available in the site's logs.") . '</p>';
$output .= '<p>' . $this->t('CKEditor 5 will attempt to enable plugins that provide equivalent toolbar items to those used prior to switching to CKEditor 5. All core CKEditor 4 plugins and many popular contrib plugins already have CKEditor 5 equivalents. In some cases, functionality that required contrib modules is now built into CKEditor 5. In instances where a plugin does not have an equivalent, no data loss will occur but elements previously provided via the plugin may need to be added manually as HTML via source editing.') . '</p>';
$output .= '<h4>' . $this->t('Additional migration considerations for text formats with restricted HTML') . '</h4>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('The “Allowed HTML tags" field in the “Limit allowed HTML tags and correct Faulty HTML" filter is now read-only') . '</dt>';
$output .= '<dd>' . $this->t('This field accurately represents the tags/attributes allowed by a text format, but the allowed tags are based on which plugins are enabled and how they are configured. For example, enabling the Underline plugin adds the &lt;u&gt; tag to “Allowed HTML tags".') . '</dd>';
$output .= '<dt id="required-tags">' . $this->t('The &lt;p&gt; and &lt;br &gt; tags will be automatically added to your text format.') . '</dt>';
$output .= '<dd>' . $this->t('CKEditor 5 requires the &lt;p&gt; and &lt;br &gt; tags to achieve basic functionality. They will be automatically added to “Allowed HTML tags" on formats that previously did not allow them.') . '</dd>';
$output .= '<dt id="source-editing">' . $this->t('Tags/attributes that are not explicitly supported by any plugin are supported by Source Editing') . '</dt>';
$output .= '<dd>' . $this->t('When a necessary tag/attribute is not directly supported by an available plugin, the "Source Editing" plugin is enabled. This plugin is typically used for by passing the CKEditor 5 UI and editing contents as HTML source. In the settings for Source Editing, tags/attributes that aren\'t available via other plugins are added to Source Editing\'s "Manually editable HTML tags" setting so they are supported by the text format.') . '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_theme().
*/
#[Hook('theme')]
public function theme() : array {
return ['ckeditor5_settings_toolbar' => ['render element' => 'form']];
}
/**
* Implements hook_form_FORM_ID_alter().
*
* This module's implementation of form_filter_format_form_alter() must
* happen after the editor module's implementation, as that implementation
* adds the active editor to $form_state. It must also happen after the media
* module's implementation so media_filter_format_edit_form_validate can be
* removed from the validation chain, as that validator is not needed with
* CKEditor 5 and will trigger a false error.
*/
#[Hook('form_filter_format_form_alter',
order: new OrderAfter(
modules: ['editor', 'media'],
)
)]
public function formFilterFormatFormAlter(array &$form, FormStateInterface $form_state, $form_id) : void {
$editor = $form_state->get('editor');
// CKEditor 5 plugin config determines the available HTML tags. If an HTML
// restricting filter is enabled and the editor is CKEditor 5, the 'Allowed
// HTML tags' field is made read only and automatically populated with the
// values needed by CKEditor 5 plugins.
// @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::buildConfigurationForm()
if ($editor && $editor->getEditor() === 'ckeditor5') {
if (isset($form['filters']['settings']['filter_html']['allowed_html'])) {
$filter_allowed_html =& $form['filters']['settings']['filter_html']['allowed_html'];
$filter_allowed_html['#value_callback'] = [CKEditor5::class, 'getGeneratedAllowedHtmlValue'];
// Set readonly and add the form-disabled wrapper class as using
// #disabled or the disabled attribute will prevent the new values from
// being validated.
$filter_allowed_html['#attributes']['readonly'] = TRUE;
$filter_allowed_html['#wrapper_attributes']['class'][] = 'form-disabled';
$filter_allowed_html['#description'] = $this->t('With CKEditor 5 this is a
read-only field. The allowed HTML tags and attributes are determined
by the CKEditor 5 configuration. Manually removing tags would break
enabled functionality, and any manually added tags would be removed by
CKEditor 5 on render.');
// The media_filter_format_edit_form_validate validator is not needed
// with CKEditor 5 as it exists to enforce the inclusion of specific
// allowed tags that are added automatically by CKEditor 5. The
// validator is removed so it does not conflict with the automatic
// addition of those allowed tags.
$key = array_search('media_filter_format_edit_form_validate', $form['#validate']);
if ($key !== FALSE) {
unset($form['#validate'][$key]);
}
}
}
// Override the AJAX callbacks for changing editors, so multiple areas of
// the form can be updated on change.
$form['editor']['editor']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => [
'name' => 'editor_configure',
],
];
$form['editor']['configure']['#ajax'] = ['callback' => '_update_ckeditor5_html_filter'];
$form['editor']['settings']['subform']['toolbar']['items']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => [
'name' => 'editor_configure',
],
'event' => 'change',
'ckeditor5_only' => 'true',
];
foreach (Element::children($form['filters']['status']) as $filter_type) {
$form['filters']['status'][$filter_type]['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => [
'name' => 'editor_configure',
],
'event' => 'change',
'ckeditor5_only' => 'true',
];
}
/*
* Recursively adds AJAX listeners to plugin settings elements.
*
* These are added so allowed tags and other fields that have values
* dependent on plugin settings can be updated via AJAX when these settings
* are changed in the editor form.
*
* @param array $plugins_config_form
* The plugins config subform render array.
*/
$add_listener = function (array &$plugins_config_form) use (&$add_listener) : void {
$field_types = ['checkbox', 'select', 'radios', 'textarea'];
if (isset($plugins_config_form['#type']) && in_array($plugins_config_form['#type'], $field_types) && !isset($plugins_config_form['#ajax'])) {
$plugins_config_form['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => [
'name' => 'editor_configure',
],
'event' => 'change',
'ckeditor5_only' => 'true',
];
}
foreach ($plugins_config_form as $key => &$value) {
if (is_array($value) && !str_contains((string) $key, '#')) {
$add_listener($value);
}
}
};
if (isset($form['editor']['settings']['subform']['plugins'])) {
$add_listener($form['editor']['settings']['subform']['plugins']);
}
// Add an ID to the filter settings vertical tabs wrapper to facilitate AJAX
// updates.
$form['filter_settings']['#wrapper_attributes']['id'] = 'filter-settings-wrapper';
$form['#after_build'][] = [
CKEditor5::class,
'assessActiveTextEditorAfterBuild',
];
$form['#validate'][] = [CKEditor5::class, 'validateSwitchingToCKEditor5'];
array_unshift($form['actions']['submit']['#submit'], 'ckeditor5_filter_format_edit_form_submit');
}
/**
* Implements hook_library_info_alter().
*/
#[Hook('library_info_alter')]
public function libraryInfoAlter(&$libraries, $extension): void {
if ($extension === 'filter') {
$libraries['drupal.filter.admin']['dependencies'][] = 'ckeditor5/internal.drupal.ckeditor5.filter.admin';
}
$moduleHandler = \Drupal::moduleHandler();
if ($extension === 'ckeditor5') {
// Add paths to stylesheets specified by a theme's ckeditor5-stylesheets
// config property.
$css = _ckeditor5_theme_css();
$libraries['internal.drupal.ckeditor5.stylesheets'] = ['css' => ['theme' => array_fill_keys(array_values($css), [])]];
}
if ($extension === 'core') {
// CSS rule to resolve the conflict with z-index between CKEditor 5 and
// jQuery UI.
$libraries['drupal.dialog']['css']['component']['modules/ckeditor5/css/ckeditor5.dialog.fix.css'] = [];
// Fix the CKEditor 5 focus management in dialogs. Modify the library
// declaration to ensure this file is always loaded after
// drupal.dialog.jquery-ui.js.
$libraries['drupal.dialog']['js']['modules/ckeditor5/js/ckeditor5.dialog.fix.js'] = [];
}
// Only add translation processing if the locale module is enabled.
if (!$moduleHandler->moduleExists('locale')) {
return;
}
// All possibles CKEditor 5 languages that can be used by Drupal.
$ckeditor_langcodes = array_values(_ckeditor5_get_langcode_mapping());
if ($extension === 'core') {
// Generate libraries for each of the CKEditor 5 translation files so that
// the correct translation file can be attached depending on the current
// language. This makes sure that caching caches the appropriate language.
// Only create libraries for languages that have a mapping to Drupal.
foreach ($ckeditor_langcodes as $langcode) {
$libraries['ckeditor5.translations.' . $langcode] = [
'remote' => $libraries['ckeditor5']['remote'],
'version' => $libraries['ckeditor5']['version'],
'license' => $libraries['ckeditor5']['license'],
'dependencies' => [
'core/ckeditor5',
'core/ckeditor5.translations',
],
];
}
}
// Copied from
// \Drupal\Core\Asset\LibraryDiscoveryParser::buildByExtension().
if ($extension === 'core') {
$path = 'core';
}
else {
if ($moduleHandler->moduleExists($extension)) {
$extension_type = 'module';
}
else {
$extension_type = 'theme';
}
$path = \Drupal::getContainer()->get('extension.path.resolver')->getPath($extension_type, $extension);
}
foreach ($libraries as &$library) {
// The way to know if a library has a translation is to depend on the
// special "core/ckeditor5.translations" library.
if (empty($library['js']) || empty($library['dependencies']) || !in_array('core/ckeditor5.translations', $library['dependencies'])) {
continue;
}
foreach ($library['js'] as $file => $options) {
// Only look for translations on libraries defined with a relative path.
if (!empty($options['type']) && $options['type'] === 'external') {
continue;
}
// Path relative to the current extension folder.
$dirname = dirname($file);
// Path of the folder in the filesystem relative to the Drupal root.
$dir = $path . '/' . $dirname;
// Exclude protocol-free URI.
if (str_starts_with($dirname, '//')) {
continue;
}
// CKEditor 5 plugins are most likely added through composer and
// installed in the module exposing it. Suppose the file path is
// relative to the module and not in the /libraries/ folder.
// Collect translations based on filename, and add all existing
// translations files to the plugin library. Unnecessary translations
// will be filtered in ckeditor5_js_alter() hook.
$files = scandir("{$dir}/translations");
foreach ($files as $file) {
if (str_ends_with($file, '.js')) {
$langcode = basename($file, '.js');
// Only add languages that Drupal can understands.
if (in_array($langcode, $ckeditor_langcodes)) {
$library['js']["{$dirname}/translations/{$langcode}.js"] = ['ckeditor5_langcode' => $langcode, 'minified' => TRUE, 'preprocess' => TRUE];
}
}
}
}
}
}
/**
* Implements hook_js_alter().
*/
#[Hook('js_alter')]
public function jsAlter(&$javascript, AttachedAssetsInterface $assets, LanguageInterface $language): void {
// This file means CKEditor 5 translations are in use on the page.
// @see locale_js_alter()
$placeholder_file = 'core/assets/vendor/ckeditor5/translation.js';
// This file is used to get a weight that will make it possible to aggregate
// all translation files in a single aggregate.
$ckeditor_dll_file = 'core/assets/vendor/ckeditor5/ckeditor5-dll/ckeditor5-dll.js';
if (isset($javascript[$placeholder_file])) {
// Use the placeholder file weight to set all the translations files
// weights so they can be aggregated together as expected.
$default_weight = $javascript[$placeholder_file]['weight'];
if (isset($javascript[$ckeditor_dll_file])) {
$default_weight = $javascript[$ckeditor_dll_file]['weight'];
}
// The placeholder file is not a real file, remove it from the list.
unset($javascript[$placeholder_file]);
// When the locale module isn't installed there are no translations.
if (!\Drupal::moduleHandler()->moduleExists('locale')) {
return;
}
$ckeditor5_language = _ckeditor5_get_langcode_mapping($language->getId());
// Remove all CKEditor 5 translations files that are not in the current
// language.
foreach ($javascript as $index => &$item) {
// This is not a CKEditor 5 translation file, skip it.
if (empty($item['ckeditor5_langcode'])) {
continue;
}
// This file is the correct translation for this page.
if ($item['ckeditor5_langcode'] === $ckeditor5_language) {
// Set the weight for the translation file to be able to have the
// translation files aggregated.
$item['weight'] = $default_weight;
}
else {
// Remove files that don't match the language requested.
unset($javascript[$index]);
}
}
}
}
/**
* Implements hook_config_schema_info_alter().
*/
#[Hook('config_schema_info_alter')]
public function configSchemaInfoAlter(&$definitions): void {
// In \Drupal\Tests\config\Functional\ConfigImportAllTest, this hook may be
// called without ckeditor5.pair.schema.yml being active.
if (!isset($definitions['ckeditor5_valid_pair__format_and_editor'])) {
return;
}
// @see filter.format.*.filters
$definitions['ckeditor5_valid_pair__format_and_editor']['mapping']['filters'] = $definitions['filter.format.*']['mapping']['filters'];
// @see @see editor.editor.*.image_upload
$definitions['ckeditor5_valid_pair__format_and_editor']['mapping']['image_upload'] = $definitions['editor.editor.*']['mapping']['image_upload'];
}
}

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\EditorInterface;
use Drupal\ckeditor5\HTMLRestrictions;
/**
* CKEditor 5 Alignment plugin.
*
* @internal
* Plugin classes are internal.
*/
class Alignment extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface, CKEditor5PluginElementsSubsetInterface {
use CKEditor5PluginConfigurableTrait;
/**
* The default configuration for this plugin.
*
* @var string[][]
*/
const DEFAULT_CONFIGURATION = [
'enabled_alignments' => [
'left',
'center',
'right',
'justify',
],
];
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return static::DEFAULT_CONFIGURATION;
}
/**
* {@inheritdoc}
*
* Form for choosing which alignment types are available.
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['enabled_alignments'] = [
'#type' => 'fieldset',
'#title' => $this->t('Enabled Alignments'),
'#description' => $this->t('These are the alignment types that will appear in the alignment dropdown.'),
];
foreach ($this->getPluginDefinition()->getCKEditor5Config()['alignment']['options'] as $alignment_option) {
$name = $alignment_option['name'];
$form['enabled_alignments'][$name] = [
'#type' => 'checkbox',
// phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString
'#title' => $this->t($name),
'#return_value' => $name,
'#default_value' => in_array($name, $this->configuration['enabled_alignments'], TRUE) ? $name : NULL,
];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
// Match the config schema structure at
// ckeditor5.plugin.ckeditor5_alignment.
$form_value = $form_state->getValue('enabled_alignments');
$config_value = array_values(array_filter($form_value));
$form_state->setValue('enabled_alignments', $config_value);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['enabled_alignments'] = $form_state->getValue('enabled_alignments');
}
/**
* {@inheritdoc}
*
* Filters the alignment options to those chosen in editor config.
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$enabled_alignments = $this->configuration['enabled_alignments'];
$all_alignment_options = $static_plugin_config['alignment']['options'];
$configured_alignment_options = array_filter($all_alignment_options, function ($option) use ($enabled_alignments) {
return in_array($option['name'], $enabled_alignments, TRUE);
});
return [
'alignment' => [
'options' => array_values($configured_alignment_options),
],
];
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
$enabled_alignments = $this->configuration['enabled_alignments'];
$plugin_definition = $this->getPluginDefinition();
$all_elements = $plugin_definition->getElements();
$subset = HTMLRestrictions::fromString(implode($all_elements));
foreach ($plugin_definition->getCKEditor5Config()['alignment']['options'] as $configured_alignment) {
if (!in_array($configured_alignment['name'], $enabled_alignments, TRUE)) {
$element_string = '<$text-container class="' . $configured_alignment["className"] . '">';
$subset = $subset->diff(HTMLRestrictions::fromString($element_string));
}
}
return $subset->toCKEditor5ElementsArray();
}
}

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\EditorInterface;
/**
* CKEditor 5 Code Block plugin configuration.
*
* @internal
* Plugin classes are internal.
*/
class CodeBlock extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface {
use CKEditor5PluginConfigurableTrait;
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['languages'] = [
'#title' => $this->t('Programming languages'),
'#type' => 'textarea',
'#description' => $this->t('A list of programming languages that will be provided in the "Code Block" dropdown. Enter one value per line, in the format key|label. Example: php|PHP.'),
];
if (!empty($this->configuration['languages'])) {
$as_selectors = '';
foreach ($this->configuration['languages'] as $language) {
$as_selectors .= sprintf("%s|%s\n", $language['language'], $language['label']);
}
$form['languages']['#default_value'] = $as_selectors;
}
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$form_value = $form_state->getValue('languages');
[$styles, $not_parseable_lines] = self::parseLanguagesFromValue($form_value);
if (!empty($not_parseable_lines)) {
$line_numbers = array_keys($not_parseable_lines);
$form_state->setError($form['languages'], $this->formatPlural(
count($not_parseable_lines),
'Line @line-number does not contain a valid value. Enter a valid language key followed by a pipe symbol and a label.',
'Lines @line-numbers do not contain a valid value. Enter a valid language key followed by a pipe symbol and a label.',
[
'@line-number' => reset($line_numbers),
'@line-numbers' => implode(', ', $line_numbers),
]
));
}
$form_state->setValue('languages', $styles);
}
/**
* Parses the line-based (for form) Code Block configuration.
*
* @param string $form_value
* A string containing >=1 lines with on each line a language key and label.
*
* @return array
* The parsed equivalent: a list of arrays with each containing:
* - label: the label after the pipe symbol, with whitespace trimmed
* - language: the key for the language
*/
protected static function parseLanguagesFromValue(string $form_value): array {
$not_parseable_lines = [];
$lines = explode("\n", $form_value);
$languages = [];
foreach ($lines as $line) {
if (empty(trim($line))) {
continue;
}
// Parse the line.
[$language, $label] = array_map('trim', explode('|', $line));
$languages[] = [
'label' => $label,
'language' => $language,
];
}
return [$languages, $not_parseable_lines];
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['languages'] = $form_state->getValue('languages');
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'languages' => [
['language' => 'plaintext', 'label' => 'Plain text'],
['language' => 'c', 'label' => 'C'],
['language' => 'cs', 'label' => 'C#'],
['language' => 'cpp', 'label' => 'C++'],
['language' => 'css', 'label' => 'CSS'],
['language' => 'diff', 'label' => 'Diff'],
['language' => 'html', 'label' => 'HTML'],
['language' => 'java', 'label' => 'Java'],
['language' => 'javascript', 'label' => 'JavaScript'],
['language' => 'php', 'label' => 'PHP'],
['language' => 'python', 'label' => 'Python'],
['language' => 'ruby', 'label' => 'Ruby'],
['language' => 'typescript', 'label' => 'TypeScript'],
['language' => 'xml', 'label' => 'XML'],
],
];
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
return [
'codeBlock' => [
'languages' => $this->configuration['languages'],
],
];
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\Core\Url;
/**
* Provides a trait for CKEditor 5 with dynamically generated CSRF token URLs.
*
* The Text Editor module's APIs predate the concept of bubbleable metadata. To
* prevent URLs with CSRF tokens from breaking cacheability, placeholders are
* used for those CSRF tokens since https://drupal.org/i/2512132. Placeholders
* are designed to be attached to the data in which they exist, so they can be
* replaced at the last possible moment, without interfering with cacheability.
* Unfortunately, because it is not possible to associate bubbleable metadata
* with a Text Editor's JS settings, we have to manually process these. This is
* acceptable only because a text editor's JS settings are not cacheable anyway
* (just like forms are not cacheable).
*
* @see \Drupal\Core\Access\CsrfAccessCheck
* @see \Drupal\Core\Access\RouteProcessorCsrf::processOutbound()
* @see \Drupal\Core\Render\BubbleableMetadata
* @see \Drupal\editor\Plugin\EditorPluginInterface::getJSSettings()
* @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image::getDynamicPluginConfig()
* @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media::getDynamicPluginConfig()
* @see https://www.drupal.org/project/drupal/issues/2512132
*
* @internal
*/
trait DynamicPluginConfigWithCsrfTokenUrlTrait {
/**
* Gets the given URL with all placeholders replaced.
*
* @param \Drupal\Core\Url $url
* A URL which generates CSRF token placeholders.
*
* @return string
* The URL string, with all placeholders replaced.
*/
private static function getUrlWithReplacedCsrfTokenPlaceholder(Url $url): string {
$generated_url = $url->toString(TRUE);
$url_with_csrf_token_placeholder = [
'#plain_text' => $generated_url->getGeneratedUrl(),
];
$generated_url->applyTo($url_with_csrf_token_placeholder);
return (string) \Drupal::service('renderer')->renderInIsolation($url_with_csrf_token_placeholder);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\editor\EditorInterface;
/**
* CKEditor 5 Global Attribute for filter_html.
*
* Can be used for adding support for any "global attribute". For example:
* `<* lang>` to allow the `lang` attribute on all supported tags.
*
* @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
*
* @internal
* Plugin classes are internal.
*/
class GlobalAttribute extends CKEditor5PluginDefault {
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
// This plugin is only loaded when filter_html is enabled.
assert($editor->getFilterFormat()->filters()->has('filter_html'));
$filter_html = $editor->getFilterFormat()->filters('filter_html');
$restrictions = HTMLRestrictions::fromFilterPluginInstance($filter_html);
// Determine which tags are allowed by filter_html, excluding the global
// attribute `*` HTML tag, because that's what we're expanding this to right
// now.
$allowed_elements = $restrictions->getAllowedElements();
unset($allowed_elements['*']);
$allowed_tags = array_keys($allowed_elements);
// Update the static plugin configuration: generate a `name` regular
// expression to match any of the HTML tags supported by filter_html.
// @see https://ckeditor.com/docs/ckeditor5/latest/features/general-html-support.html#configuration
$dynamic_plugin_config = $static_plugin_config;
$dynamic_plugin_config['htmlSupport']['allow'][0]['name']['regexp']['pattern'] = '/^(' . implode('|', $allowed_tags) . ')$/';
return $dynamic_plugin_config;
}
}

View File

@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\EditorInterface;
/**
* CKEditor 5 Heading plugin.
*
* @internal
* Plugin classes are internal.
*/
class Heading extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface, CKEditor5PluginElementsSubsetInterface {
use CKEditor5PluginConfigurableTrait;
/**
* The headings that cannot be disabled.
*
* @var string[]
*/
const ALWAYS_ENABLED_HEADINGS = [
'paragraph',
];
/**
* The default configuration for this plugin.
*
* @var string[][]
*/
const DEFAULT_CONFIGURATION = [
'enabled_headings' => [
'heading2',
'heading3',
'heading4',
'heading5',
'heading6',
],
];
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return static::DEFAULT_CONFIGURATION;
}
/**
* Computes all valid choices for the "enabled_headings" setting.
*
* @see ckeditor5.schema.yml
*
* @return string[]
* All valid choices.
*/
public static function validChoices(): array {
$cke5_plugin_manager = \Drupal::service('plugin.manager.ckeditor5.plugin');
assert($cke5_plugin_manager instanceof CKEditor5PluginManagerInterface);
$plugin_definition = $cke5_plugin_manager->getDefinition('ckeditor5_heading');
assert($plugin_definition->getClass() === static::class);
return array_diff(
array_column($plugin_definition->getCKEditor5Config()['heading']['options'], 'model'),
static::ALWAYS_ENABLED_HEADINGS
);
}
/**
* Gets all enabled headings.
*
* @return string[]
* The values in the plugins.ckeditor5_heading.enabled_headings
* configuration plus the headings that are always enabled.
*/
private function getEnabledHeadings(): array {
return array_merge(
self::ALWAYS_ENABLED_HEADINGS,
$this->configuration['enabled_headings']
);
}
/**
* {@inheritdoc}
*
* Form for choosing which heading tags are available.
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['enabled_headings'] = [
'#type' => 'fieldset',
'#title' => $this->t('Enabled Headings'),
'#description' => $this->t('These are the headings that will appear in the headings dropdown. If a heading is not chosen here, it does not necessarily mean the corresponding tag is disallowed in the text format.'),
];
foreach ($this->getPluginDefinition()->getCKEditor5Config()['heading']['options'] as $heading_option) {
$model = $heading_option['model'];
if (in_array($model, self::ALWAYS_ENABLED_HEADINGS, TRUE)) {
continue;
}
// It's safe to use $model as a key: listing the same model twice with
// different properties triggers a schema error in CKEditor 5.
// @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/error-codes.html#error-schema-cannot-register-item-twice
// @see https://ckeditor.com/docs/ckeditor5/latest/features/headings.html#configuring-custom-heading-elements
$form['enabled_headings'][$model] = self::generateCheckboxForHeadingOption($heading_option);
$form['enabled_headings'][$model]['#default_value'] = in_array($model, $this->configuration['enabled_headings'], TRUE) ? $model : NULL;
}
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
// Match the config schema structure at ckeditor5.plugin.ckeditor5_heading.
$form_value = $form_state->getValue('enabled_headings');
$config_value = array_values(array_filter($form_value));
$form_state->setValue('enabled_headings', $config_value);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['enabled_headings'] = $form_state->getValue('enabled_headings');
}
/**
* Generates checkbox for a CKEditor 5 heading plugin config option.
*
* @param array $heading_option
* A heading option configuration as the CKEditor 5 Heading plugin expects
* in its configuration.
*
* @return array
* The checkbox render array.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/api/module_heading_heading-HeadingConfig.html#member-options
*/
private static function generateCheckboxForHeadingOption(array $heading_option): array {
// This requires the `title` and `model` properties. The `class` property is
// optional. The `view` property is not used.
assert(array_key_exists('title', $heading_option));
assert(array_key_exists('model', $heading_option));
$checkbox = [
'#type' => 'checkbox',
'#title' => $heading_option['title'],
'#return_value' => $heading_option['model'],
];
if (isset($heading_option['class'])) {
$checkbox['#label_attributes']['class'][] = $heading_option['class'];
$checkbox['#label_attributes']['class'][] = 'ck';
}
return $checkbox;
}
/**
* {@inheritdoc}
*
* Filters the header options to those chosen in editor config.
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$enabled_headings = $this->getEnabledHeadings();
$all_heading_options = $static_plugin_config['heading']['options'];
$configured_heading_options = array_filter($all_heading_options, function ($option) use ($enabled_headings) {
return in_array($option['model'], $enabled_headings, TRUE);
});
return [
'heading' => [
'options' => array_values($configured_heading_options),
],
];
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
return $this->enabledHeadingsToTags($this->configuration['enabled_headings']);
}
/**
* Returns an array of enabled tags based on the enabled headings.
*
* @param string[] $enabled_headings
* Array of the enabled headings.
*
* @return string[]
* List of tags provided by the enabled headings.
*/
private function enabledHeadingsToTags(array $enabled_headings): array {
$plugin_definition = $this->getPluginDefinition();
$elements = $plugin_definition->getElements();
$heading_keyed_by_model = [];
foreach ($plugin_definition->getCKEditor5Config()['heading']['options'] as $configured_heading) {
if (isset($configured_heading['model'])) {
$heading_keyed_by_model[$configured_heading['model']] = $configured_heading;
}
}
$tags_to_return = [];
foreach ($enabled_headings as $model) {
if (isset($heading_keyed_by_model[$model]) && isset($heading_keyed_by_model[$model]['view'])) {
$element_as_tag = "<{$heading_keyed_by_model[$model]['view']}>";
if (in_array($element_as_tag, $elements, TRUE)) {
$tags_to_return[] = "<{$heading_keyed_by_model[$model]['view']}>";
}
}
}
return $tags_to_return;
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\editor\EditorInterface;
/**
* CKEditor 5 Image plugin.
*
* @internal
* Plugin classes are internal.
*/
class Image extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface {
use CKEditor5PluginConfigurableTrait;
use DynamicPluginConfigWithCsrfTokenUrlTrait;
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$config = $static_plugin_config;
if ($editor->getImageUploadSettings()['status'] === TRUE) {
$config += [
'drupalImageUpload' => [
'uploadUrl' => self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('ckeditor5.upload_image')
->setRouteParameter('editor', $editor->getFilterFormat()->id())
),
'withCredentials' => TRUE,
'headers' => ['Accept' => 'application/json', 'text/javascript'],
],
];
$config['image']['insert']['integrations'][] = 'upload';
}
else {
$config['image']['insert']['integrations'][] = 'url';
}
return $config;
}
/**
* {@inheritdoc}
*
* @see editor_image_upload_settings_form()
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form_state->loadInclude('editor', 'admin.inc');
return editor_image_upload_settings_form($form_state->get('editor'));
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$form_state->setValue('status', (bool) $form_state->getValue('status'));
$directory = $form_state->getValue(['directory']);
$form_state->setValue(['directory'], trim($directory) === '' ? NULL : $directory);
$max_size = $form_state->getValue(['max_size']);
$form_state->setValue(['max_size'], trim($max_size) === '' ? NULL : $max_size);
$max_width = $form_state->getValue(['max_dimensions', 'width']);
$form_state->setValue(['max_dimensions', 'width'], trim($max_width) === '' ? NULL : (int) $max_width);
$max_height = $form_state->getValue(['max_dimensions', 'height']);
$form_state->setValue(['max_dimensions', 'height'], trim($max_height) === '' ? NULL : (int) $max_height);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$settings = $form_state->getValues();
if (!$settings['status']) {
// Remove all other settings to comply with config schema.
$settings = ['status' => FALSE];
}
// Store this configuration in its out-of-band location.
$form_state->get('editor')->setImageUploadSettings($settings);
}
/**
* {@inheritdoc}
*
* This returns an empty array as image upload config is stored out of band.
*/
public function defaultConfiguration() {
return [];
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* CKEditor 5 ImageResize plugin.
*
* @internal
* Plugin classes are internal.
*/
class ImageResize extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface {
use CKEditor5PluginConfigurableTrait;
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return ['allow_resize' => TRUE];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['allow_resize'] = [
'#type' => 'checkbox',
'#title' => $this->t('Allow the user to resize images'),
'#default_value' => $this->configuration['allow_resize'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
// Match the config schema structure at
// ckeditor5.plugin.ckeditor5_imageResize.
$form_value = $form_state->getValue('allow_resize');
$form_state->setValue('allow_resize', (bool) $form_value);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['allow_resize'] = $form_state->getValue('allow_resize');
}
}

View File

@ -0,0 +1,171 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManager;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\Url;
use Drupal\editor\EditorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* CKEditor 5 Language plugin.
*
* @internal
* Plugin classes are internal.
*/
class Language extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface, ContainerFactoryPluginInterface {
use CKEditor5PluginConfigurableTrait;
/**
* Language constructor.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Language\LanguageManagerInterface $languageManager
* The language manager.
* @param \Drupal\Core\Routing\RouteProviderInterface $routeProvider
* The route provider.
*/
public function __construct(array $configuration, string $plugin_id, CKEditor5PluginDefinition $plugin_definition, protected LanguageManagerInterface $languageManager, protected RouteProviderInterface $routeProvider) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('language_manager'),
$container->get('router.route_provider'),
);
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$languages = NULL;
switch ($this->configuration['language_list']) {
case 'site_configured':
$configured_languages = $this->languageManager->getLanguages();
$languages = [];
foreach ($configured_languages as $language) {
$languages[$language->getId()] = [
$language->getName(),
'',
$language->getDirection(),
];
}
break;
case 'all':
$languages = LanguageManager::getStandardLanguageList();
break;
case 'un':
$languages = LanguageManager::getUnitedNationsLanguageList();
}
// Generate the language_list setting as expected by the CKEditor Language
// plugin, but key the values by the full language name so that we can sort
// them later on.
$language_list = [];
foreach ($languages as $langcode => $language) {
$english_name = $language[0];
$direction = empty($language[2]) ? NULL : $language[2];
$language_list[$english_name] = [
'title' => $english_name,
'languageCode' => $langcode,
];
if ($direction === LanguageInterface::DIRECTION_RTL) {
$language_list[$english_name]['textDirection'] = 'rtl';
}
}
// Sort on full language name.
ksort($language_list);
$dynamic_plugin_config = $static_plugin_config;
$dynamic_plugin_config['language']['textPartLanguage'] = array_values($language_list);
return $dynamic_plugin_config;
}
/**
* {@inheritdoc}
*
* @see editor_image_upload_settings_form()
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$configured = count($this->languageManager->getLanguages());
$predefined = count(LanguageManager::getStandardLanguageList());
$united_nations = count(LanguageManager::getUnitedNationsLanguageList());
$language_list_description_args = [
':united-nations-official' => 'https://www.un.org/en/sections/about-un/official-languages',
'@count_predefined' => $predefined,
'@count_united_nations' => $united_nations,
'@count_configured' => $configured,
];
// If Language is enabled, link to the configuration route.
if ($this->routeProvider->getRoutesByNames(['entity.configurable_language.collection'])) {
$language_list_description = $this->t('The list of languages in the CKEditor "Language" dropdown can present the <a href=":united-nations-official">@count_united_nations official languages of the UN</a>, all @count_predefined languages predefined in Drupal, or the <a href=":admin-configure-languages">@count_configured languages configured for this site</a>.', $language_list_description_args + [':admin-configure-languages' => Url::fromRoute('entity.configurable_language.collection')->toString()]);
}
else {
$language_list_description = $this->t('The list of languages in the CKEditor "Language" dropdown can present the <a href=":united-nations-official">@count_united_nations official languages of the UN</a>, all @count_predefined languages predefined in Drupal, or the languages configured for this site.', $language_list_description_args);
}
$form['language_list'] = [
'#title' => $this->t('Language list'),
'#title_display' => 'invisible',
'#type' => 'select',
'#options' => [
'un' => $this->t("United Nations' official languages (@count)", ['@count' => $united_nations]),
'all' => $this->t('Drupal predefined languages (@count)', ['@count' => $predefined]),
'site_configured' => $this->t("Site-configured languages (@count)", ['@count' => $configured]),
],
'#default_value' => $this->configuration['language_list'],
'#description' => $language_list_description,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['language_list'] = $form_state->getValue('language_list');
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return ['language_list' => 'un'];
}
}

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\EditorInterface;
/**
* CKEditor 5 List plugin.
*
* @internal
* Plugin classes are internal.
*/
class ListPlugin extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface, CKEditor5PluginElementsSubsetInterface {
use CKEditor5PluginConfigurableTrait;
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'properties' => ['reversed' => TRUE, 'startIndex' => TRUE],
'multiBlock' => TRUE,
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['reversed'] = [
'#type' => 'checkbox',
'#title' => $this->t('Allow the user to reverse an ordered list'),
'#default_value' => $this->configuration['properties']['reversed'],
];
$form['startIndex'] = [
'#type' => 'checkbox',
'#title' => $this->t('Allow the user to specify the start index of an ordered list'),
'#default_value' => $this->configuration['properties']['startIndex'],
];
$form['multiBlock'] = [
'#type' => 'checkbox',
'#title' => $this->t('Allow the user to create paragraphs in list items (or other block elements)'),
'#default_value' => $this->configuration['multiBlock'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$form_value = $form_state->getValue('reversed');
$form_state->setValue('reversed', (bool) $form_value);
$form_value = $form_state->getValue('startIndex');
$form_state->setValue('startIndex', (bool) $form_value);
$form_value = $form_state->getValue('multiBlock');
$form_state->setValue('multiBlock', (bool) $form_value);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['properties']['reversed'] = $form_state->getValue('reversed');
$this->configuration['properties']['startIndex'] = $form_state->getValue('startIndex');
$this->configuration['multiBlock'] = $form_state->getValue('multiBlock');
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$static_plugin_config['list']['properties'] = $this->getConfiguration()['properties'] + $static_plugin_config['list']['properties'];
$static_plugin_config['list']['multiBlock'] = $this->getConfiguration()['multiBlock'];
return $static_plugin_config;
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
$subset = $this->getPluginDefinition()->getElements();
$subset = array_diff($subset, ['<ol reversed start>']);
$reversed_enabled = $this->getConfiguration()['properties']['reversed'];
$start_index_enabled = $this->getConfiguration()['properties']['startIndex'];
$subset[] = "<ol" . ($reversed_enabled ? ' reversed' : '') . ($start_index_enabled ? ' start' : '') . '>';
return $subset;
}
}

View File

@ -0,0 +1,238 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Url;
use Drupal\editor\EditorInterface;
use Drupal\media\Entity\MediaType;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
/**
* CKEditor 5 Media plugin.
*
* Provides drupal-media element and options provided by the CKEditor 5 build.
*
* @internal
* Plugin classes are internal.
*/
class Media extends CKEditor5PluginDefault implements ContainerFactoryPluginInterface, CKEditor5PluginConfigurableInterface, CKEditor5PluginElementsSubsetInterface {
use DynamicPluginConfigWithCsrfTokenUrlTrait;
use CKEditor5PluginConfigurableTrait;
/**
* The entity display repository.
*
* @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface
*/
protected $entityDisplayRepository;
/**
* Media constructor.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityDisplayRepositoryInterface $entity_display_repository
* The entity display repository.
*/
public function __construct(array $configuration, string $plugin_id, CKEditor5PluginDefinition $plugin_definition, EntityDisplayRepositoryInterface $entity_display_repository) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityDisplayRepository = $entity_display_repository;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_display.repository'));
}
/**
* Configures allowed view modes.
*
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*
* @return array
* An array containing view modes, style configuration,
* and toolbar configuration.
*/
private function configureViewModes(EditorInterface $editor) {
$element_style_configuration = [];
$toolbar_configuration = [];
$media_embed_filter = $editor->getFilterFormat()->filters('media_embed');
$media_bundles = MediaType::loadMultiple();
$bundles_per_view_mode = [];
$all_view_modes = $this->entityDisplayRepository->getViewModeOptions('media');
$allowed_view_modes = $media_embed_filter->settings['allowed_view_modes'];
$default_view_mode = $media_embed_filter->settings['default_view_mode'];
// @todo Remove in https://www.drupal.org/project/drupal/issues/3277049.
// This is a workaround until the above issue is fixed to prevent the
// editor from crashing because the frontend expects the default view mode
// to exist in drupalElementStyles.
if (!array_key_exists($default_view_mode, $allowed_view_modes)) {
$allowed_view_modes[$default_view_mode] = $default_view_mode;
}
// Return early since there is no need to configure if there
// are less than 2 view modes.
if ($allowed_view_modes < 2) {
return [];
}
// Configure view modes.
foreach (array_keys($media_bundles) as $bundle) {
$allowed_view_modes_by_bundle = $this->entityDisplayRepository->getViewModeOptionsByBundle('media', $bundle);
foreach (array_keys($allowed_view_modes_by_bundle) as $view_mode) {
// Get the bundles that have this view mode enabled.
$bundles_per_view_mode[$view_mode][] = $bundle;
}
}
// Limit to view modes allowed by filter.
$bundles_per_view_mode = array_intersect_key($bundles_per_view_mode, $allowed_view_modes);
// Configure view mode element styles.
foreach (array_keys($all_view_modes) as $view_mode) {
if (array_key_exists($view_mode, $bundles_per_view_mode)) {
$specific_bundles = $bundles_per_view_mode[$view_mode];
if ($view_mode == $default_view_mode) {
$element_style_configuration[] = [
'isDefault' => TRUE,
'name' => $default_view_mode,
'title' => $all_view_modes[$view_mode],
'attributeName' => 'data-view-mode',
'attributeValue' => $view_mode,
'modelElements' => ['drupalMedia'],
'modelAttributes' => [
'drupalMediaType' => array_keys($media_bundles),
],
];
}
else {
$element_style_configuration[] = [
'name' => $view_mode,
'title' => $all_view_modes[$view_mode],
'attributeName' => 'data-view-mode',
'attributeValue' => $view_mode,
'modelElements' => ['drupalMedia'],
'modelAttributes' => [
'drupalMediaType' => $specific_bundles,
],
];
}
}
}
$items = [];
foreach (array_keys($allowed_view_modes) as $view_mode) {
$items[] = "drupalElementStyle:viewMode:$view_mode";
}
$default_item = 'drupalElementStyle:viewMode:' . $default_view_mode;
if (!empty($allowed_view_modes)) {
// Configure toolbar dropdown menu.
$toolbar_configuration = [
'name' => 'drupalMedia:viewMode',
'display' => 'listDropdown',
'defaultItem' => $default_item,
'defaultText' => 'View mode',
'items' => $items,
];
}
return [
$element_style_configuration,
$toolbar_configuration,
];
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$dynamic_plugin_config = $static_plugin_config;
$dynamic_plugin_config['drupalMedia']['previewURL'] = Url::fromRoute('media.filter.preview')
->setRouteParameter('filter_format', $editor->getFilterFormat()->id())
->toString(TRUE)
->getGeneratedUrl();
[$element_style_configuration, $toolbar_configuration,
] = self::configureViewModes($editor);
$dynamic_plugin_config['drupalElementStyles']['viewMode'] = $element_style_configuration;
if ($this->getConfiguration()['allow_view_mode_override']) {
$dynamic_plugin_config['drupalMedia']['toolbar'][] = $toolbar_configuration;
}
$dynamic_plugin_config['drupalMedia']['metadataUrl'] = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('ckeditor5.media_entity_metadata')
->setRouteParameter('editor', $editor->id())
);
$dynamic_plugin_config['drupalMedia']['previewCsrfToken'] = \Drupal::csrfToken()->get('X-Drupal-MediaPreview-CSRF-Token');
return $dynamic_plugin_config;
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
$subset = $this->getPluginDefinition()->getElements();
$view_mode_override_enabled = $this->getConfiguration()['allow_view_mode_override'];
if (!$view_mode_override_enabled) {
$subset = array_diff($subset, ['<drupal-media data-view-mode>']);
}
return $subset;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return ['allow_view_mode_override' => FALSE];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['allow_view_mode_override'] = [
'#type' => 'checkbox',
'#title' => $this->t('Allow the user to override the default view mode'),
'#default_value' => $this->configuration['allow_view_mode_override'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$form_value = $form_state->getValue('allow_view_mode_override');
$form_state->setValue('allow_view_mode_override', (bool) $form_value);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['allow_view_mode_override'] = $form_state->getValue('allow_view_mode_override');
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\editor\EditorInterface;
use Drupal\media_library\MediaLibraryState;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* CKEditor 5 Media Library plugin.
*
* Provides media library support and options for the CKEditor 5 build.
*
* @internal
* Plugin classes are internal.
*/
class MediaLibrary extends CKEditor5PluginDefault implements ContainerFactoryPluginInterface {
use StringTranslationTrait;
/**
* The media type entity storage.
*
* @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface
*/
protected $mediaTypeStorage;
/**
* MediaLibrary constructor.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(array $configuration, string $plugin_id, CKEditor5PluginDefinition $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->mediaTypeStorage = $entity_type_manager->getStorage('media_type');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
);
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$media_type_ids = $this->mediaTypeStorage->getQuery()->execute();
// Making the title for editor drupal media embed translatable.
$static_plugin_config['drupalMedia']['dialogSettings']['title'] = $this->t('Add or select media');
if ($editor->hasAssociatedFilterFormat()) {
$media_embed_filter = $editor->getFilterFormat()->filters()->get('media_embed');
// Optionally limit the allowed media types based on the MediaEmbed
// setting. If the setting is empty, do not limit the options.
if (!empty($media_embed_filter->settings['allowed_media_types'])) {
$media_type_ids = array_intersect_key($media_type_ids, $media_embed_filter->settings['allowed_media_types']);
}
}
if (in_array('image', $media_type_ids, TRUE)) {
// Move image to first position.
// This workaround can be removed once this issue is fixed:
// @see https://www.drupal.org/project/drupal/issues/3073799
array_unshift($media_type_ids, 'image');
$media_type_ids = array_unique($media_type_ids);
}
$state = MediaLibraryState::create(
'media_library.opener.editor',
$media_type_ids,
reset($media_type_ids),
1,
['filter_format_id' => $editor->getFilterFormat()->id()],
);
$library_url = Url::fromRoute('media_library.ui')
->setOption('query', $state->all())
->toString(TRUE)
->getGeneratedUrl();
$dynamic_plugin_config = $static_plugin_config;
$dynamic_plugin_config['drupalMedia']['libraryURL'] = $library_url;
return $dynamic_plugin_config;
}
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\EditorInterface;
/**
* CKEditor 5 Source Editing plugin configuration.
*
* @internal
* Plugin classes are internal.
*/
class SourceEditing extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface, CKEditor5PluginElementsSubsetInterface {
use CKEditor5PluginConfigurableTrait;
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['allowed_tags'] = [
'#type' => 'textarea',
'#title' => $this->t('Manually editable HTML tags'),
'#default_value' => implode(' ', $this->configuration['allowed_tags']),
'#description' => $this->t('A list of HTML tags that can be used while editing source. It is only necessary to add tags that are not already supported by other enabled plugins. For example, if "Bold" is enabled, it is not necessary to add the <code>&lt;strong&gt;</code> tag, but it may be necessary to add <code>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</code> in a format that does not have a definition list plugin, but requires definition list markup.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
// Match the config schema structure at
// ckeditor5.plugin.ckeditor5_sourceEditing.
$form_value = $form_state->getValue('allowed_tags');
assert(is_string($form_value));
$config_value = HTMLRestrictions::fromString($form_value)->toCKEditor5ElementsArray();
$form_state->setValue('allowed_tags', $config_value);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['allowed_tags'] = $form_state->getValue('allowed_tags');
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'allowed_tags' => [],
];
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
// Drupal needs to know which plugin can create a particular <tag>, and not
// just a particular attribute on a tag: <tag attr>.
// SourceEditing enables every tag a plugin lists, even if it's only there
// to add support for an attribute. So, compute a list of only the tags.
// F.e.: <foo attr>, <bar>, <baz bar> would result in <foo>, <bar>, <baz>.
$r = HTMLRestrictions::fromString(implode(' ', $this->configuration['allowed_tags']));
$plain_tags = $r->extractPlainTagsSubset()->toCKEditor5ElementsArray();
// Return the union of the "tags only" list and the original configuration,
// but omit duplicates (the entries that were already "tags only").
// F.e.: merging the tags only list of <foo>, <bar>, <baz> with the original
// list of <foo attr>, <bar>, <baz bar> would result in <bar> having a
// duplicate.
$subset = array_unique(array_merge(
$plain_tags,
$this->configuration['allowed_tags']
));
return $subset;
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$restrictions = HTMLRestrictions::fromString(implode(' ', $this->configuration['allowed_tags']));
// Only handle concrete HTML elements to allow the Wildcard HTML support
// plugin to handle wildcards.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig()
$concrete_restrictions = $restrictions->getConcreteSubset();
return [
'htmlSupport' => [
'allow' => $concrete_restrictions->toGeneralHtmlSupportConfig(),
// Any manually created elements are explicitly allowed to be empty.
'allowEmpty' => array_keys($concrete_restrictions->getAllowedElements()),
],
];
}
}

View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\EditorInterface;
/**
* CKEditor 5 Style plugin configuration.
*
* @internal
* Plugin classes are internal.
*/
class Style extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface, CKEditor5PluginElementsSubsetInterface {
use CKEditor5PluginConfigurableTrait;
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['styles'] = [
'#title' => $this->t('Styles'),
'#type' => 'textarea',
'#description' => $this->t('A list of classes that will be provided in the "Style" dropdown. Enter one or more classes on each line in the format: element.classA.classB|Label. Example: h1.title|Title. Advanced example: h1.fancy.title|Fancy title.<br />These styles should be available in your theme\'s CSS file.'),
];
if (!empty($this->configuration['styles'])) {
$as_selectors = '';
foreach ($this->configuration['styles'] as $style) {
[$tag, $classes] = self::getTagAndClasses(HTMLRestrictions::fromString($style['element']));
$as_selectors .= sprintf("%s.%s|%s\n", $tag, implode('.', $classes), $style['label']);
}
$form['styles']['#default_value'] = $as_selectors;
}
return $form;
}
/**
* Gets the tag and classes for a parsed style element.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $style_element
* A parsed style element.
*
* @return array
* An array containing two values:
* - a HTML tag name
* - a list of classes
*
* @internal
*/
public static function getTagAndClasses(HTMLRestrictions $style_element): array {
$tag = array_keys($style_element->getAllowedElements())[0];
$classes = array_keys($style_element->getAllowedElements()[$tag]['class']);
return [$tag, $classes];
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
// Match the config schema structure at ckeditor5.plugin.ckeditor5_style.
$form_value = $form_state->getValue('styles');
[$styles, $invalid_lines] = self::parseStylesFormValue($form_value);
if (!empty($invalid_lines)) {
$line_numbers = array_keys($invalid_lines);
$form_state->setError($form['styles'], $this->formatPlural(
count($invalid_lines),
'Line @line-number does not contain a valid value. Enter a valid CSS selector containing one or more classes, followed by a pipe symbol and a label.',
'Lines @line-numbers do not contain a valid value. Enter a valid CSS selector containing one or more classes, followed by a pipe symbol and a label.',
[
'@line-number' => reset($line_numbers),
'@line-numbers' => implode(', ', $line_numbers),
]
));
}
$form_state->setValue('styles', $styles);
}
/**
* Parses the line-based (for form) style configuration.
*
* @param string $form_value
* A string containing >=1 lines with on each line a CSS selector targeting
* 1 tag with >=1 classes, a pipe symbol and a label. An example of a single
* line: `p.foo.bar|Foo bar paragraph`.
*
* @return array
* The parsed equivalent: a list of arrays with each containing:
* - label: the label after the pipe symbol, with whitespace trimmed
* - element: the CKEditor 5 element equivalent of the tag + classes
*
* @internal
*/
private static function parseStylesFormValue(string $form_value): array {
$invalid_lines = [];
$lines = explode("\n", $form_value);
$styles = [];
foreach ($lines as $index => $line) {
if (empty(trim($line))) {
continue;
}
// Parse the line.
[$selector, $label] = array_map('trim', explode('|', $line));
// Validate the selector.
$selector_matches = [];
// @see https://www.w3.org/TR/CSS2/syndata.html#:~:text=In%20CSS%2C%20identifiers%20(including%20element,hyphen%20followed%20by%20a%20digit
if (!preg_match('/^([a-z][0-9a-zA-Z\-]*)((\.[a-zA-Z0-9\x{00A0}-\x{FFFF}\-_]+)+)$/u', $selector, $selector_matches)) {
$invalid_lines[$index + 1] = $line;
continue;
}
// Parse selector into tag + classes and normalize.
$tag = $selector_matches[1];
$classes = array_filter(explode('.', $selector_matches[2]));
$normalized = HTMLRestrictions::fromString(sprintf('<%s class="%s">', $tag, implode(' ', $classes)));
$styles[] = [
'label' => $label,
'element' => $normalized->toCKEditor5ElementsArray()[0],
];
}
return [$styles, $invalid_lines];
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['styles'] = $form_state->getValue('styles');
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'styles' => [],
];
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
return array_column($this->configuration['styles'], 'element');
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
$definitions = [];
foreach ($this->configuration['styles'] as $style) {
[$tag, $classes] = self::getTagAndClasses(HTMLRestrictions::fromString($style['element']));
// Transform configured styles to the configuration structure expected by
// the CKEditor 5 Style plugin.
$definitions[] = [
'name' => $style['label'],
'element' => $tag,
'classes' => $classes,
];
}
return [
'style' => [
'definitions' => $definitions,
],
];
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin;
use Drupal\Component\Plugin\ConfigurableInterface;
use Drupal\Core\Plugin\PluginFormInterface;
/**
* Defines an interface for configurable CKEditor 5 plugins.
*
* This allows a CKEditor 5 plugin to define a settings form. These settings can
* then be automatically passed on to the corresponding CKEditor 5 instance via
* CKEditor5PluginInterface::getDynamicPluginConfig().
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait
* @see \Drupal\ckeditor5\CKEditor5PluginInterface
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginBase
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
* @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin
* @see plugin_api
*/
interface CKEditor5PluginConfigurableInterface extends CKEditor5PluginInterface, ConfigurableInterface, PluginFormInterface {
}

View File

@ -0,0 +1,26 @@
<?php
namespace Drupal\ckeditor5\Plugin;
/**
* Provides a trait for configurable CKEditor 5 plugins.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface
*/
trait CKEditor5PluginConfigurableTrait {
/**
* {@inheritdoc}
*/
public function getConfiguration() {
return $this->configuration;
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
$this->configuration = $configuration + $this->defaultConfiguration();
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin;
use Drupal\Core\Plugin\PluginBase;
use Drupal\editor\EditorInterface;
/**
* Defines the default CKEditor 5 plugin implementation.
*
* When a CKEditor 5 plugin is not configurable nor has dynamic plugin
* configuration, no custom code needs to be written: this default
* implementation will be used under the hood.
*
* @see @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$class
*/
class CKEditor5PluginDefault extends PluginBase implements CKEditor5PluginInterface {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
// Ensure the configuration is set as expected for configurable plugins.
if ($this instanceof CKEditor5PluginConfigurableInterface) {
$this->setConfiguration($configuration);
}
}
/**
* {@inheritdoc}
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
return $static_plugin_config;
}
}

View File

@ -0,0 +1,623 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Plugin\Definition\DerivablePluginDefinitionInterface;
use Drupal\Component\Plugin\Definition\PluginDefinition;
use Drupal\Component\Plugin\Definition\PluginDefinitionInterface;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Core\Config\Schema\SchemaCheckTrait;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Provides an implementation of a CKEditor 5 plugin definition.
*/
final class CKEditor5PluginDefinition extends PluginDefinition implements PluginDefinitionInterface, DerivablePluginDefinitionInterface {
use SchemaCheckTrait;
/**
* The CKEditor 5 aspects of the plugin definition.
*
* @var array
*/
private $ckeditor5;
/**
* The Drupal aspects of the plugin definition.
*
* @var array
*/
private $drupal;
/**
* CKEditor5PluginDefinition constructor.
*
* @param array $definition
* An array of values from the annotation/YAML.
*
* @throws \InvalidArgumentException
*/
public function __construct(array $definition) {
foreach ($definition as $property => $value) {
if (property_exists($this, $property)) {
$this->{$property} = $value;
}
else {
throw new \InvalidArgumentException(sprintf('Property %s with value %s does not exist on %s.', $property, $value, __CLASS__));
}
}
// In version CKEditor5 45.0.0, the icons were renamed, so if any
// drupalElementStyles are specifying icons, deprecate use of the old names
// and provide a mapping for backwards compatibility.
// @see https://ckeditor.com/docs/ckeditor5/latest/updating/guides/changelog.html#new-installation-methods-improvements-icons-replacement
// @see https://github.com/ckeditor/ckeditor5/blob/v44.3.0/packages/ckeditor5-core/src/index.ts
// @see https://github.com/ckeditor/ckeditor5/blob/v45.0.0/packages/ckeditor5-icons/src/index.ts
if (!isset($this->ckeditor5) || !isset($this->ckeditor5['config']['drupalElementStyles']) || !is_array($this->ckeditor5['config']['drupalElementStyles'])) {
return;
}
foreach ($this->ckeditor5['config']['drupalElementStyles'] as $group_id => &$groups) {
if (!is_array($groups)) {
continue;
}
foreach ($groups as &$style) {
if (is_array($style) && isset($style['icon']) && is_string($style['icon']) && !preg_match('/^(<svg)|(Icon)/', $style['icon'])) {
$deprecated_icon = $style['icon'];
$style['icon'] = match ($deprecated_icon) {
'objectLeft' => 'IconObjectInlineLeft',
'objectRight' => 'IconObjectInlineRight',
'objectBlockLeft' => 'IconObjectLeft',
'objectBlockRight' => 'IconObjectRight',
default => 'Icon' . ucfirst($style['icon'])
};
@trigger_error(sprintf('The icon configuration value "%s" in drupalElementStyles group %s for CKEditor5 plugin %s is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Try using "%s" instead. See https://www.drupal.org/node/3528806', $deprecated_icon, $group_id, $this->id(), $style['icon']), E_USER_DEPRECATED);
}
}
}
}
/**
* Gets an array representation of this CKEditor 5 plugin definition.
*
* @return array
* The array representation of this CKEditor 5 plugin definition.
*/
public function toArray(): array {
return [
'id' => $this->id(),
'provider' => $this->provider,
'ckeditor5' => $this->ckeditor5,
'drupal' => $this->drupal,
];
}
/**
* Validates the CKEditor 5 aspects of the CKEditor 5 plugin definition.
*
* @param string $id
* The plugin ID, for use in exception messages.
* @param array $definition
* The plugin definition to validate.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
*
* @internal
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::processDefinition()
*/
public static function validateCKEditor5Aspects(string $id, array $definition): void {
if (!isset($definition['ckeditor5'])) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "ckeditor5" key.', $id));
}
if (!isset($definition['ckeditor5']['plugins'])) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "ckeditor5.plugins" key.', $id));
}
// Automatic link decorators make sense in CKEditor 5, where the generated
// HTML must be assumed to be served as-is. But it does not make sense in
// in Drupal, where we prefer not storing (hardcoding) such decisions in the
// database. Drupal instead filters it on output, using the filter system.
if (isset($definition['ckeditor5']['config']['link'])) {
// @see https://ckeditor.com/docs/ckeditor5/latest/api/module_link_link-LinkDecoratorAutomaticDefinition.html
if (isset($definition['ckeditor5']['config']['link']['decorators']) && is_array($definition['ckeditor5']['config']['link']['decorators'])) {
foreach ($definition['ckeditor5']['config']['link']['decorators'] as $decorator) {
if ($decorator['mode'] === 'automatic') {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition specifies an automatic decorator, this is not supported. Use the Drupal filter system instead.', $id));
}
}
}
// CKEditor 5 offers one preconfigured automatic link decorator under a
// special config flag.
// @see https://ckeditor.com/docs/ckeditor5/latest/api/module_link_link-LinkConfig.html#member-addTargetToExternalLinks
if (isset($definition['ckeditor5']['config']['link']['addTargetToExternalLinks']) && $definition['ckeditor5']['config']['link']['addTargetToExternalLinks']) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition specifies an automatic decorator, this is not supported. Use the Drupal filter system instead.', $id));
}
}
}
/**
* Validates the Drupal aspects of the CKEditor 5 plugin definition.
*
* @param string $id
* The plugin ID, for use in exception messages.
* @param array $definition
* The plugin definition to validate.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
*
* @internal
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::processDefinition()
*/
public function validateDrupalAspects(string $id, array $definition): void {
if (!isset($definition['drupal'])) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "drupal" key.', $id));
}
// Without a label, the CKEditor 5 UI, validation constraints et cetera
// cannot be as informative in guiding the end user.
if (!isset($definition['drupal']['label'])) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "drupal.label" key.', $id));
}
elseif (!is_string($definition['drupal']['label']) && !$definition['drupal']['label'] instanceof TranslatableMarkup) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a "drupal.label" value that is not a string nor a TranslatableMarkup instance.', $id));
}
// Without accurate and complete metadata about what HTML elements a
// CKEditor 5 plugin supports, Drupal cannot ensure a complete and accurate
// upgrade path.
if (!isset($definition['drupal']['elements'])) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "drupal.elements" key.', $id));
}
// ckeditor5_sourceEditing is the edge case here: it is the only plugin that
// is allowed to return a superset. It's a special case because it is
// through configuring this particular plugin that additional HTML tags can
// be allowed.
// The list of tags it supports is generated dynamically. In its default
// configuration it does support any HTML tags.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getProvidedElements()
elseif ($definition['id'] === 'ckeditor5_sourceEditing') {
assert($definition['drupal']['elements'] === []);
}
elseif ($definition['drupal']['elements'] !== FALSE && !(is_array($definition['drupal']['elements']) && !empty($definition['drupal']['elements']) && Inspector::assertAllStrings($definition['drupal']['elements']))) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a "drupal.elements" value that is neither a list of HTML tags/attributes nor false.', $id));
}
elseif (is_array($definition['drupal']['elements'])) {
foreach ($definition['drupal']['elements'] as $index => $element) {
$parsed = HTMLRestrictions::fromString($element);
if ($parsed->allowsNothing()) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a value at "drupal.elements.%d" that is not an HTML tag with optional attributes: "%s". Expected structure: "<tag allowedAttribute="allowedValue1 allowedValue2">".', $id, $index, $element));
}
if (count($parsed->getAllowedElements()) > 1) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a value at "drupal.elements.%d": multiple tags listed, should be one: "%s".', $id, $index, $element));
}
}
}
if (isset($definition['drupal']['class']) && !class_exists($definition['drupal']['class'])) {
throw new InvalidPluginDefinitionException($id, sprintf('The CKEditor 5 "%s" provides a plugin class: "%s", but it does not exist.', $id, $definition['drupal']['class']));
}
elseif (isset($definition['drupal']['class']) && !in_array(CKEditor5PluginInterface::class, class_implements($definition['drupal']['class']))) {
throw new InvalidPluginDefinitionException($id, sprintf('CKEditor 5 plugins must implement \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface. "%s" does not.', $id));
}
elseif (in_array(CKEditor5PluginConfigurableInterface::class, class_implements($definition['drupal']['class'], TRUE))) {
$default_configuration = (new \ReflectionClass($definition['drupal']['class']))
->newInstanceWithoutConstructor()
->defaultConfiguration();
if (!empty($default_configuration)) {
$configuration_name = sprintf("ckeditor5.plugin.%s", $definition['id']);
if (!$this->getTypedConfig()->hasConfigSchema($configuration_name)) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition is configurable, has non-empty default configuration but has no config schema. Config schema is required for validation.', $id));
}
$error_message = $this->validateConfiguration($default_configuration);
if ($error_message) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition is configurable, but its default configuration does not match its config schema. %s', $id, $error_message));
}
}
}
if ($definition['drupal']['conditions'] !== FALSE) {
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::isPluginDisabled()
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\ToolbarItemConditionsMetConstraintValidator::validate()
$supported_condition_types = [
'toolbarItem' => function ($value): ?string {
return is_string($value) ? NULL : 'A string corresponding to a CKEditor 5 toolbar item must be specified.';
},
'imageUploadStatus' => function ($value): ?string {
return is_bool($value) ? NULL : 'A boolean indicating whether image uploads must be enabled (true) or not (false) must be specified.';
},
'filter' => function ($value): ?string {
return is_string($value) ? NULL : 'A string corresponding to a filter plugin ID must be specified.';
},
'requiresConfiguration' => function ($required_configuration, array $definition): ?string {
if (!is_array($required_configuration)) {
return 'An array structure matching the required configuration for this plugin must be specified.';
}
if (!in_array(CKEditor5PluginConfigurableInterface::class, class_implements($definition['drupal']['class'], TRUE))) {
return 'This condition type is only available for CKEditor 5 plugins implementing CKEditor5PluginConfigurableInterface.';
}
$error_message = $this->validateConfiguration($required_configuration);
return is_string($error_message) ? sprintf('The required configuration does not match its config schema. %s', $error_message) : NULL;
},
'plugins' => function ($value): ?string {
return is_array($value) && Inspector::assertAllStrings($value) ? NULL : 'A list of strings, each corresponding to a CKEditor 5 plugin ID must be specified.';
},
];
$unsupported_condition_types = array_keys(array_diff_key($definition['drupal']['conditions'], $supported_condition_types));
if (!empty($unsupported_condition_types)) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a "drupal.conditions" value that contains some unsupported condition types: "%s". Only the following conditions types are supported: "%s".', $id, implode(', ', $unsupported_condition_types), implode('", "', array_keys($supported_condition_types))));
}
foreach ($definition['drupal']['conditions'] as $condition_type => $value) {
$assessment = $supported_condition_types[$condition_type]($value, $definition);
if (is_string($assessment)) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has an invalid "drupal.conditions" item. "%s" is set to an invalid value. %s', $id, $condition_type, $assessment));
}
}
}
if ($definition['drupal']['admin_library'] !== FALSE) {
[$extension, $library] = explode('/', $definition['drupal']['admin_library'], 2);
if (\Drupal::service('library.discovery')->getLibraryByName($extension, $library) === FALSE) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a "drupal.admin_library" key whose asset library "%s" does not exist.', $id, $definition['drupal']['admin_library']));
}
}
}
/**
* Returns the typed configuration service.
*
* @return \Drupal\Core\Config\TypedConfigManagerInterface
* The typed configuration service.
*/
private function getTypedConfig(): TypedConfigManagerInterface {
return \Drupal::service('config.typed');
}
/**
* Validates the given configuration array.
*
* @param array $configuration
* The configuration to validate.
*
* @return string|null
* NULL if there are no validation errors, a string containing the schema
* violation error messages otherwise.
*/
private function validateConfiguration(array $configuration): ?string {
if (!isset($this->schema)) {
$configuration_name = sprintf("ckeditor5.plugin.%s", $this->id);
// TRICKY: SchemaCheckTrait::checkConfigSchema() dynamically adds a
// 'langcode' key-value pair that is irrelevant here. Also,
// ::checkValue() may (counter to its docs) trigger an exception.
$this->configName = 'STRIP';
$this->schema = $this->getTypedConfig()->createFromNameAndData($configuration_name, $configuration);
}
$schema_errors = [];
foreach ($configuration as $key => $value) {
try {
$schema_error = $this->checkValue($key, $value);
}
catch (\InvalidArgumentException $e) {
$schema_error = [$key => $e->getMessage()];
}
$schema_errors = array_merge($schema_errors, $schema_error);
}
$formatted_schema_errors = [];
foreach ($schema_errors as $key => $value) {
$formatted_schema_errors[] = sprintf("[%s] %s", str_replace('STRIP:', '', $key), trim($value, '.'));
}
if (!empty($formatted_schema_errors)) {
return sprintf('The following errors were found: %s.', implode(', ', $formatted_schema_errors));
}
return NULL;
}
/**
* {@inheritdoc}
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$class
*/
public function getClass() {
return $this->drupal['class'];
}
/**
* {@inheritdoc}
*/
public function setClass($class) {
$this->drupal['class'] = $class;
return $this;
}
/**
* {@inheritdoc}
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$deriver
*/
public function getDeriver() {
// TRICKY: this is the only key that is allowed to not be set, because it is
// possible that this plugin definition is a partial/incomplete one, and the
// default from the annotation is only applied automatically for class
// annotation CKEditor 5 plugin definitions (because they create an instance
// of the DrupalAspectsOfCKEditor5Plugin annotation level), not for CKEditor
// 5 plugin definitions in YAML.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::processDefinition()
// @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin::__construct()
return $this->drupal['deriver'] ?? NULL;
}
/**
* {@inheritdoc}
*/
public function setDeriver($deriver) {
$this->drupal['deriver'] = $deriver;
return $this;
}
/**
* Whether this plugin is configurable by the user.
*
* @return bool
* TRUE if it is configurable, FALSE otherwise.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface
*/
public function isConfigurable(): bool {
return is_subclass_of($this->getClass(), CKEditor5PluginConfigurableInterface::class);
}
/**
* Gets the human-readable name of the CKEditor plugin.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The human-readable name of the CKEditor plugin.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$label
*/
public function label(): TranslatableMarkup {
$label = $this->drupal['label'];
if (!$label instanceof TranslatableMarkup) {
// phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString
$label = new TranslatableMarkup($label);
}
return $label;
}
/**
* Gets the list of conditions to enable this plugin.
*
* @return array
* An array of conditions.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$conditions
*
* @throws \LogicException
* When called on a plugin definition that has no conditions.
*/
public function getConditions(): array {
if (!$this->hasConditions()) {
throw new \LogicException('::getConditions() should only be called if ::hasConditions() returns TRUE.');
}
return $this->drupal['conditions'];
}
/**
* Whether this plugin has conditions.
*
* @return bool
* TRUE if the plugin has conditions, FALSE otherwise.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$conditions
*/
public function hasConditions(): bool {
return $this->drupal['conditions'] !== FALSE;
}
/**
* Gets the list of toolbar items this plugin provides.
*
* @return array[]
* An array of toolbar items.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$toolbar_items
*/
public function getToolbarItems(): array {
return $this->drupal['toolbar_items'];
}
/**
* Whether this plugin has toolbar items.
*
* @return bool
* TRUE if the plugin has toolbar items, FALSE otherwise.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$toolbar_items
*/
public function hasToolbarItems(): bool {
return $this->getToolbarItems() !== [];
}
/**
* Gets the asset library this plugin needs to be loaded.
*
* @return string
* An asset library ID.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$library
*
* @throws \LogicException
* When called on a plugin definition that has no library.
*/
public function getLibrary(): string {
if (!$this->hasLibrary()) {
throw new \LogicException('::getLibrary() should only be called if ::hasLibrary() returns TRUE.');
}
return $this->drupal['library'];
}
/**
* Whether this plugin has an asset library to load.
*
* @return bool
* TRUE if the plugin has an asset library to load, FALSE otherwise.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$library
*/
public function hasLibrary(): bool {
return $this->drupal['library'] !== FALSE;
}
/**
* Gets the asset library this plugin needs to be loaded on the admin UI.
*
* @return string
* An asset library ID.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$admin_library
*
* @throws \LogicException
* When called on a plugin definition that has no admin library.
*/
public function getAdminLibrary(): string {
if (!$this->hasAdminLibrary()) {
throw new \LogicException('::getAdminLibrary() should only be called if ::hasAdminLibrary() returns TRUE.');
}
return $this->drupal['admin_library'];
}
/**
* Whether this plugin has an asset library to load on the admin UI.
*
* @return bool
* TRUE if the plugin has an asset library to load on the admin UI, FALSE
* otherwise.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$admin_library
*/
public function hasAdminLibrary(): bool {
return $this->drupal['admin_library'] !== FALSE;
}
/**
* Gets the list of elements and attributes this plugin allows to create/edit.
*
* @return string[]
* A list of elements and attributes.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$elements
*
* @throws \LogicException
* When called on a plugin definition that has no elements.
*/
public function getElements(): array {
if (!$this->hasElements()) {
throw new \LogicException('::getElements() should only be called if ::hasElements() returns TRUE.');
}
return $this->drupal['elements'];
}
/**
* Gets the elements this plugin allows to create.
*
* @return string[]
* A list of plain tags (without attributes) that this plugin can create.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$elements
*
* @throws \LogicException
* When called on a plugin definition that has no elements.
*/
public function getCreatableElements(): array {
if (!$this->hasElements()) {
throw new \LogicException('::getCreatableElements() should only be called if ::hasElements() returns TRUE.');
}
return array_filter($this->getElements(), [__CLASS__, 'isCreatableElement']);
}
/**
* Checks if the element is a plain tag, meaning the plugin can create it.
*
* @param string $element
* A single element, for example `<foo>`, `<foo bar>` or `<foo bar="baz'>`.
*
* @return bool
* If it is a plain tag and hence a creatable element.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$elements
*/
public static function isCreatableElement(string $element): bool {
return !HTMLRestrictions::fromString($element)
->getPlainTagsSubset()
->allowsNothing();
}
/**
* Whether this plugin allows creating/editing elements and attributes.
*
* @return bool
* TRUE if the plugin has elements, FALSE otherwise.
*
* @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$elements
*/
public function hasElements(): bool {
return $this->drupal['elements'] !== FALSE;
}
/**
* Gets the list of CKEditor 5 plugin classes this plugin needs to load.
*
* @return string[]
* CKEditor 5 plugin classes.
*
* @see \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin::$plugins
*/
public function getCKEditor5Plugins(): array {
return $this->ckeditor5['plugins'];
}
/**
* Whether this plugin loads CKEditor 5 plugin classes.
*
* @return bool
* TRUE if the plugin loads CKEditor 5 plugin classes, FALSE otherwise.
*
* @see \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin::$plugins
*/
public function hasCKEditor5Plugins(): bool {
return $this->getCKEditor5Plugins() !== [];
}
/**
* Gets keyed array of additional values for the CKEditor 5 configuration.
*
* @return array
* The CKEditor 5 constructor config.
*
* @see \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin::$config
*/
public function getCKEditor5Config(): array {
return $this->ckeditor5['config'];
}
/**
* Whether this plugin has additional values for the CKEditor 5 configuration.
*
* @return bool
* TRUE if there are additional configuration values, FALSE otherwise.
*
* @see \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin::$config
*/
public function hasCKEditor5Config(): bool {
return $this->getCKEditor5Config() !== [];
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin;
/**
* Defines an interface for plugins that can support an elements subset.
*
* Plugins can support multiple elements in the `elements` property of their
* definition. A text format may want to use a given plugin without supporting
* every supported element. Plugins that implement this interface return a
* subset based on the configuration in the Text Editor's settings.
*/
interface CKEditor5PluginElementsSubsetInterface extends CKEditor5PluginConfigurableInterface {
/**
* Returns a configured subset of the elements supported by this plugin.
*
* @return string[]
* An array of supported elements.
*/
public function getElementsSubset(): array;
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\editor\EditorInterface;
/**
* Defines an interface for CKEditor 5 plugins.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginBase
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
* @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin
* @see plugin_api
*/
interface CKEditor5PluginInterface extends PluginInspectionInterface {
/**
* Allows a plugin to modify its static configuration.
*
* @param array $static_plugin_config
* The ckeditor5.config entry from the YAML or annotation, if any. If none
* is specified in the YAML or annotation, then the empty array.
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*
* @return array
* Returns the received $static_plugin_config plus dynamic additions or
* alterations.
*
* @see \Drupal\ckeditor5\Annotation\CKEditor5AspectsOfCKEditor5Plugin::$config
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCKEditor5Config()
*/
public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array;
}

View File

@ -0,0 +1,512 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin;
use Drupal\ckeditor5\Attribute\CKEditor5Plugin;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Plugin\Discovery\AttributeBridgeDecorator;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Plugin\Discovery\AttributeDiscoveryWithAnnotations;
use Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator;
use Drupal\Core\Plugin\Discovery\YamlDiscoveryDecorator;
use Drupal\editor\EditorInterface;
use Drupal\filter\FilterPluginCollection;
/**
* Provides a CKEditor 5 plugin manager.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginBase
* @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin
* @see plugin_api
*
* @internal
* CKEditor 5 is currently experimental and should only be leveraged by
* experimental modules and development releases of contributed modules.
* See https://www.drupal.org/core/experimental for more information.
*/
class CKEditor5PluginManager extends DefaultPluginManager implements CKEditor5PluginManagerInterface {
/**
* Constructs a CKEditor5PluginManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct(
'Plugin/CKEditor5Plugin',
$namespaces,
$module_handler,
CKEditor5PluginInterface::class,
CKEditor5Plugin::class,
'\Drupal\ckeditor5\Annotation\CKEditor5Plugin',
);
$this->alterInfo('ckeditor5_plugin_info');
$this->setCacheBackend($cache_backend, 'ckeditor5_plugins');
}
/**
* {@inheritdoc}
*/
protected function getDiscovery() {
if (!$this->discovery) {
$discovery = new AttributeDiscoveryWithAnnotations($this->subdir, $this->namespaces, $this->pluginDefinitionAttributeName, $this->pluginDefinitionAnnotationName, $this->additionalAnnotationNamespaces);
$discovery = new YamlDiscoveryDecorator($discovery, 'ckeditor5', $this->moduleHandler->getModuleDirectories());
// Note: adding translatable properties here is impossible because it only
// supports top-level properties.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::label()
$discovery = new AttributeBridgeDecorator($discovery, $this->pluginDefinitionAttributeName);
$discovery = new ContainerDerivativeDiscoveryDecorator($discovery);
$this->discovery = $discovery;
}
return $this->discovery;
}
/**
* {@inheritdoc}
*/
public function processDefinition(&$definition, $plugin_id) {
if (!$definition instanceof CKEditor5PluginDefinition) {
throw new InvalidPluginDefinitionException($plugin_id, sprintf('The "%s" CKEditor 5 plugin definition must extend %s', $plugin_id, CKEditor5PluginDefinition::class));
}
// A derived plugin will still have the ID of the derivative, rather than
// that of the derived plugin ID (`<base plugin ID>:<derivative ID>`).
// Generate an updated CKEditor5PluginDefinition.
// @see \Drupal\Component\Plugin\Discovery\DerivativeDiscoveryDecorator::encodePluginId()
// @todo Remove this in https://www.drupal.org/project/drupal/issues/2458769.
$is_derived = $definition->id() !== $plugin_id;
if ($is_derived) {
$definition = new CKEditor5PluginDefinition(['id' => $plugin_id] + $definition->toArray());
}
$expected_prefix = sprintf("%s_", $definition->getProvider());
$id = $definition->id();
if (!str_starts_with($id, $expected_prefix)) {
throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must have a plugin ID that starts with "%s".', $id, $expected_prefix));
}
try {
$definition->validateCKEditor5Aspects($id, $definition->toArray());
$definition->validateDrupalAspects($id, $definition->toArray());
}
catch (InvalidPluginDefinitionException $e) {
// If this exception is thrown for a derived CKEditor 5 plugin definition,
// it means the deriver did not generate a valid plugin definition.
// Re-throw the exception, but tweak the language for DX: clarify it is
// for a derived plugin definition.
if ($is_derived) {
throw new InvalidPluginDefinitionException($e->getPluginId(), str_replace('plugin definition', 'derived plugin definition', $e->getMessage()));
}
// Otherwise, the exception was appropriate: re-throw it.
throw $e;
}
parent::processDefinition($definition, $plugin_id);
}
/**
* {@inheritdoc}
*/
public function getPlugin(string $plugin_id, ?EditorInterface $editor): CKEditor5PluginInterface {
$configuration = $editor
? self::getPluginConfiguration($editor, $plugin_id)
: [];
return $this->createInstance($plugin_id, $configuration);
}
/**
* Gets the plugin configuration (if any) from a text editor config entity.
*
* @param \Drupal\editor\EditorInterface $editor
* A text editor config entity that is using CKEditor 5.
* @param string $plugin_id
* A CKEditor 5 plugin ID.
*
* @return array
* The CKEditor 5 plugin configuration, if any.
*
* @throws \InvalidArgumentException
* Thrown when the editor is not CKEditor 5.
*/
protected static function getPluginConfiguration(EditorInterface $editor, string $plugin_id): array {
if ($editor->getEditor() !== 'ckeditor5') {
throw new \InvalidArgumentException('This method should only be called on text editor config entities using CKEditor 5.');
}
return $editor->getSettings()['plugins'][$plugin_id] ?? [];
}
/**
* {@inheritdoc}
*/
public function getToolbarItems(): array {
return $this->mergeDefinitionValues('getToolbarItems', $this->getDefinitions());
}
/**
* {@inheritdoc}
*/
public function getAdminLibraries(): array {
$list = $this->mergeDefinitionValues('getAdminLibrary', $this->getDefinitions());
// Include main admin library.
array_unshift($list, 'ckeditor5/internal.admin');
return $list;
}
/**
* {@inheritdoc}
*/
public function getEnabledLibraries(EditorInterface $editor): array {
$list = $this->mergeDefinitionValues('getLibrary', $this->getEnabledDefinitions($editor));
$list = array_unique($list);
// Include main library.
array_unshift($list, 'ckeditor5/internal.drupal.ckeditor5');
sort($list);
return $list;
}
/**
* {@inheritdoc}
*/
public function getEnabledDefinitions(EditorInterface $editor): array {
$definitions = $this->getDefinitions();
ksort($definitions);
$definitions_with_plugins_condition = [];
foreach ($definitions as $plugin_id => $definition) {
// Remove definition when plugin has conditions and they are not met.
if ($definition->hasConditions()) {
$plugin = $this->getPlugin($plugin_id, $editor);
if ($this->isPluginDisabled($plugin, $editor)) {
unset($definitions[$plugin_id]);
}
else {
// The `plugins` condition can only be evaluated at the end of
// gathering enabled definitions. ::isPluginDisabled() did not yet
// evaluate that condition.
if (array_key_exists('plugins', $definition->getConditions())) {
$definitions_with_plugins_condition[$plugin_id] = $definition;
}
}
}
// Otherwise, only remove the definition if the plugin has buttons and
// none of its buttons are active.
elseif ($definition->hasToolbarItems()) {
if (empty(array_intersect($editor->getSettings()['toolbar']['items'], array_keys($definition->getToolbarItems())))) {
unset($definitions[$plugin_id]);
}
}
}
// Only enable the arbitrary HTML Support plugin on text formats with no
// HTML restrictions.
// @see https://ckeditor.com/docs/ckeditor5/latest/api/html-support.html
// @see https://github.com/ckeditor/ckeditor5/issues/9856
if ($editor->getFilterFormat()->getHtmlRestrictions() !== FALSE) {
unset($definitions['ckeditor5_arbitraryHtmlSupport']);
}
// Evaluate `plugins` condition.
foreach ($definitions_with_plugins_condition as $plugin_id => $definition) {
if (!empty(array_diff($definition->getConditions()['plugins'], array_keys($definitions)))) {
unset($definitions[$plugin_id]);
}
}
if (!isset($definitions['ckeditor5_arbitraryHtmlSupport'])) {
$restrictions = new HTMLRestrictions($this->getProvidedElements(array_keys($definitions), $editor, FALSE));
if ($restrictions->getWildcardSubset()->allowsNothing()) {
// This is only reached if arbitrary HTML is not enabled. If wildcard
// tags (such as $text-container) are present, they need to
// be resolved via the wildcardHtmlSupport plugin.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig()
unset($definitions['ckeditor5_wildcardHtmlSupport']);
}
}
// When arbitrary HTML is already supported, there is no need to support
// wildcard tags.
else {
unset($definitions['ckeditor5_wildcardHtmlSupport']);
}
return $definitions;
}
/**
* {@inheritdoc}
*/
public function findPluginSupportingElement(string $tag): ?string {
// This will contain the element config for a plugin found to support $tag,
// so it can be compared to additional plugins that support $tag so the
// plugin with the most permissive config can be the id returned.
$selected_provided_elements = [];
$plugin_id = NULL;
foreach ($this->getDefinitions() as $id => $definition) {
$provided_elements = $this->getProvidedElements([$id]);
// Multiple plugins may support the $tag being searched for.
if (array_key_exists($tag, $provided_elements)) {
// Skip plugins with conditions as those plugins can't be guaranteed to
// provide a given tag without additional criteria being met. In the
// future we could possibly add support for automatically enabling
// filters or other similar requirements a plugin might need in order to
// be enabled and provide the tag it supports. For now, we assume such
// configuration cannot be modified programmatically.
if ($definition->hasConditions()) {
continue;
}
// True if a plugin has already been selected. If another plugin
// supports $tag, it will be compared against this one. Whichever
// provides broader support for $tag will be the plugin id returned by
// this method.
$selected_plugin = isset($selected_provided_elements[$tag]);
$selected_config = $selected_provided_elements[$tag] ?? FALSE;
// True if a plugin supporting $tag has been selected but does not allow
// any attributes while the plugin currently being checked does support
// attributes.
$adds_attribute_config = is_array($provided_elements[$tag]) && $selected_plugin && !is_array($selected_config);
$broader_attribute_config = FALSE;
// If the selected plugin and the plugin being checked both have arrays
// for $tag configuration, they both have attribute configuration. Check
// which attribute configuration is more permissive.
if ($selected_plugin && is_array($selected_config) && is_array($provided_elements[$tag])) {
$selected_plugin_full_attributes = array_filter($selected_config, function ($attribute_config) {
return !is_array($attribute_config);
});
$being_checked_plugin_full_attributes = array_filter($provided_elements[$tag], function ($attribute_config) {
return !is_array($attribute_config);
});
if (count($being_checked_plugin_full_attributes) > count($selected_plugin_full_attributes)) {
$broader_attribute_config = TRUE;
}
}
if (empty($selected_provided_elements) || $broader_attribute_config || $adds_attribute_config) {
$selected_provided_elements = $provided_elements;
$plugin_id = $id;
}
}
}
return $plugin_id;
}
/**
* {@inheritdoc}
*/
public function getCKEditor5PluginConfig(EditorInterface $editor): array {
$definitions = $this->getEnabledDefinitions($editor);
// Allow plugin to modify config, such as loading dynamic values.
$config = [];
foreach ($definitions as $plugin_id => $definition) {
$plugin = $this->getPlugin($plugin_id, $editor);
$config[$plugin_id] = $plugin->getDynamicPluginConfig($definition->getCKEditor5Config(), $editor);
}
// CKEditor 5 interprets wildcards from a "CKEditor 5 model element"
// perspective, Drupal interprets wildcards from a "HTML element"
// perspective. GHS is used to reconcile those two perspectives, to ensure
// all expected HTML elements truly are supported.
// The `ckeditor5_wildcardHtmlSupport` is automatically enabled when
// necessary, and only when necessary.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getEnabledDefinitions()
if (isset($definitions['ckeditor5_wildcardHtmlSupport'])) {
$allowed_elements = new HTMLRestrictions($this->getProvidedElements(array_keys($definitions), $editor, FALSE));
// Compute the net new elements that the wildcard tags resolve into.
$concrete_allowed_elements = $allowed_elements->getConcreteSubset();
$net_new_elements = $allowed_elements->diff($concrete_allowed_elements);
$config['ckeditor5_wildcardHtmlSupport'] = [
'htmlSupport' => [
'allow' => $net_new_elements->toGeneralHtmlSupportConfig(),
],
];
}
return [
'plugins' => $this->mergeDefinitionValues('getCKEditor5Plugins', $definitions),
'config' => NestedArray::mergeDeepArray($config),
];
}
/**
* {@inheritdoc}
*/
public function getProvidedElements(array $plugin_ids = [], ?EditorInterface $editor = NULL, bool $resolve_wildcards = TRUE, bool $creatable_elements_only = FALSE): array {
$plugins = $this->getDefinitions();
if (!empty($plugin_ids)) {
$plugins = array_intersect_key($plugins, array_flip($plugin_ids));
}
$elements = HTMLRestrictions::emptySet();
foreach ($plugins as $id => $definition) {
// Some CKEditor 5 plugins only provide functionality, not additional
// elements.
if (!$definition->hasElements()) {
continue;
}
$defined_elements = $definition->getElements();
if (is_a($definition->getClass(), CKEditor5PluginElementsSubsetInterface::class, TRUE)) {
// ckeditor5_sourceEditing is the edge case here: it is the only plugin
// that is allowed to return a superset. It's a special case because it
// is through configuring this particular plugin that additional HTML
// tags can be allowed.
// The list of tags it supports is generated dynamically. In its default
// configuration it does support any HTML tags.
if ($id === 'ckeditor5_sourceEditing') {
$defined_elements = !isset($editor) ? [] : $this->getPlugin($id, $editor)->getElementsSubset();
}
// The default case: all other plugins that implement this interface are
// explicitly checked for compliance: only subsets are allowed. This is
// essential for \Drupal\ckeditor5\SmartDefaultSettings to be able to
// work: otherwise it would not be able to know which plugins to enable.
elseif (isset($editor)) {
$subset = $this->getPlugin($id, $editor)->getElementsSubset();
$subset_restrictions = HTMLRestrictions::fromString(implode($subset));
$defined_restrictions = HTMLRestrictions::fromString(implode($defined_elements));
// Determine max supported elements by resolving wildcards in the
// restrictions defined by the plugin.
$max_supported = $defined_restrictions;
if (!$defined_restrictions->getWildcardSubset()->allowsNothing()) {
$concrete_tags_to_use_to_resolve_wildcards = $subset_restrictions->extractPlainTagsSubset();
$max_supported = $max_supported->merge($concrete_tags_to_use_to_resolve_wildcards)
->diff($concrete_tags_to_use_to_resolve_wildcards);
}
$not_in_max_supported = $subset_restrictions->diff($max_supported);
if (!$not_in_max_supported->allowsNothing()) {
// If the editor is still being configured, the configuration may
// not yet be valid.
if ($editor->isNew()) {
$subset = [];
}
else {
throw new \LogicException(sprintf('The "%s" CKEditor 5 plugin implements ::getElementsSubset() and did not return a subset, the following tags are absent from the plugin definition: "%s".', $id, implode(' ', $not_in_max_supported->toCKEditor5ElementsArray())));
}
}
// Also detect what is technically a valid subset, but has lost the
// ability to create tags that are still in the subset. This points to
// a bug in the plugin's ::getElementsSubset() logic.
$defined_creatable = HTMLRestrictions::fromString(implode($definition->getCreatableElements()));
$subset_creatable_actual = HTMLRestrictions::fromString(implode(array_filter($subset, [CKEditor5PluginDefinition::class, 'isCreatableElement'])));
$subset_creatable_needed = $subset_restrictions->extractPlainTagsSubset()
->intersect($defined_creatable);
$missing_creatable_for_subset = $subset_creatable_needed->diff($subset_creatable_actual);
if (!$missing_creatable_for_subset->allowsNothing()) {
throw new \LogicException(sprintf('The "%s" CKEditor 5 plugin implements ::getElementsSubset() and did return a subset ("%s") but the following tags can no longer be created: "%s".', $id, implode($subset_restrictions->toCKEditor5ElementsArray()), implode($missing_creatable_for_subset->toCKEditor5ElementsArray())));
}
$defined_elements = $subset;
}
}
assert(Inspector::assertAllStrings($defined_elements));
if ($creatable_elements_only) {
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCreatableElements()
$defined_elements = array_filter($defined_elements, [CKEditor5PluginDefinition::class, 'isCreatableElement']);
}
foreach ($defined_elements as $element) {
$additional_elements = HTMLRestrictions::fromString($element);
$elements = $elements->merge($additional_elements);
}
}
return $elements->getAllowedElements($resolve_wildcards);
}
/**
* Returns array of merged values for the given plugin definitions.
*
* @param string $get_method
* Which CKEditor5PluginDefinition getter to call to get values to merge.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[] $definitions
* The plugin definitions whose values to merge.
*
* @return array
* List of merged values for the given plugin definition method.
*/
protected function mergeDefinitionValues(string $get_method, array $definitions): array {
assert(method_exists(CKEditor5PluginDefinition::class, $get_method));
$has_method = 'has' . substr($get_method, 3);
assert(method_exists(CKEditor5PluginDefinition::class, $has_method));
$per_plugin = array_filter(array_map(function (CKEditor5PluginDefinition $definition) use ($get_method, $has_method) {
if ($definition->$has_method()) {
return $definition->$get_method();
}
}, $definitions));
return array_reduce($per_plugin, function (array $result, $current): array {
return is_array($current) && is_array(reset($current))
// Merge nested arrays using their keys.
? $result + $current
// Merge everything else by appending.
: array_merge($result, (array) $current);
}, []);
}
/**
* Checks whether a plugin must be disabled due to unmet conditions.
*
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface $plugin
* A CKEditor 5 plugin instance.
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*
* @return bool
* Whether the plugin is disabled due to unmet conditions.
*/
protected function isPluginDisabled(CKEditor5PluginInterface $plugin, EditorInterface $editor): bool {
assert($plugin->getPluginDefinition()->hasConditions());
foreach ($plugin->getPluginDefinition()->getConditions() as $condition_type => $required_value) {
switch ($condition_type) {
case 'toolbarItem':
if (!in_array($required_value, $editor->getSettings()['toolbar']['items'])) {
return TRUE;
}
break;
case 'imageUploadStatus':
$image_upload_status = $editor->getImageUploadSettings()['status'] ?? FALSE;
return $image_upload_status !== $required_value;
case 'filter':
$filters = $editor->getFilterFormat()->filters();
assert($filters instanceof FilterPluginCollection);
if (!$filters->has($required_value) || !$filters->get($required_value)->status) {
return TRUE;
}
break;
case 'requiresConfiguration':
$intersection = array_intersect($plugin->getConfiguration(), $required_value);
return $intersection !== $required_value;
case 'plugins':
// Tricky: this cannot yet be evaluated here. It will evaluated later.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getEnabledDefinitions()
return FALSE;
}
}
return FALSE;
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin;
use Drupal\Component\Plugin\Discovery\DiscoveryInterface;
use Drupal\editor\EditorInterface;
/**
* Provides the interface for a plugin manager of CKEditor 5 plugins.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface
* @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin
* @see plugin_api
*/
interface CKEditor5PluginManagerInterface extends DiscoveryInterface {
/**
* Returns a CKEditor 5 plugin with configuration from the editor.
*
* @param string $plugin_id
* The plugin ID.
* @param \Drupal\editor\EditorInterface|null $editor
* The editor to load configuration from.
*
* @return \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface
* The CKEditor 5 plugin instance.
*/
public function getPlugin(string $plugin_id, ?EditorInterface $editor): CKEditor5PluginInterface;
/**
* Gets a list of all toolbar items.
*
* @return string[]
* List of all toolbar items provided by plugins.
*/
public function getToolbarItems(): array;
/**
* Gets a list of all admin library names.
*
* @return string[]
* List of all admin libraries provided by plugins.
*/
public function getAdminLibraries(): array;
/**
* Gets a list of libraries required for the editor.
*
* This list is filtered by enabled plugins because it is needed at runtime.
*
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*
* @return string[]
* The list of enabled libraries.
*/
public function getEnabledLibraries(EditorInterface $editor): array;
/**
* Filter list of definitions by enabled plugins only.
*
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*
* @return array
* Enabled plugin definitions.
*/
public function getEnabledDefinitions(EditorInterface $editor): array;
/**
* Searches for CKEditor 5 plugin that supports a given tag.
*
* @param string $tag
* The HTML tag to be searched for within plugin definitions.
*
* @return string|null
* The ID of the plugin that supports the given tag.
*/
public function findPluginSupportingElement(string $tag): ?string;
/**
* Gets the configuration for the CKEditor 5 plugins enabled in this editor.
*
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*
* @return array[]
* An array with two key-value pairs:
* 1. 'plugins' lists all plugins to load
* 2. 'config' lists the configuration for all these plugins.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/api/module_editor-classic_classiceditor-ClassicEditor.html
*
* @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getJSSettings()
*/
public function getCKEditor5PluginConfig(EditorInterface $editor): array;
/**
* Gets all supported elements for the given plugins and text editor.
*
* @param string[] $plugin_ids
* (optional) An array of CKEditor 5 plugin IDs. When not set, gets elements
* for all plugins.
* @param \Drupal\editor\EditorInterface|null $editor
* (optional) A configured text editor object using CKEditor 5. When not
* set, plugins depending on the text editor cannot provide elements.
* @param bool $resolve_wildcards
* (optional) Whether to resolve wildcards. Defaults to TRUE. When set to
* FALSE, the raw allowed elements will be returned (with no processing
* applied hence no resolved wildcards).
* @param bool $creatable_elements_only
* (optional) Whether to retrieve only the creatable elements. Defaults to
* FALSE.
*
* @return array
* A nested array with a structure as described in
* \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions().
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCreatableElements()
*
* @throws \LogicException
* Thrown when an invalid CKEditor5PluginElementsSubsetInterface
* implementation is encountered.
*
* @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
*/
public function getProvidedElements(array $plugin_ids = [], ?EditorInterface $editor = NULL, bool $resolve_wildcards = TRUE, bool $creatable_elements_only = FALSE): array;
}

View File

@ -0,0 +1,118 @@
<?php
namespace Drupal\ckeditor5\Plugin\ConfigAction;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Config\Action\ConfigActionPluginInterface;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\editor\EditorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Config action plugin to add an item to the toolbar.
*
* @internal
* This API is experimental.
*/
#[ConfigAction(
id: 'editor:addItemToToolbar',
admin_label: new TranslatableMarkup('Add an item to a CKEditor 5 toolbar'),
entity_types: ['editor'],
)]
final class AddItemToToolbar implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
public function __construct(
private readonly ConfigManagerInterface $configManager,
private readonly CKEditor5PluginManagerInterface $pluginManager,
private readonly string $pluginId,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$container->get(ConfigManagerInterface::class),
$container->get(CKEditor5PluginManagerInterface::class),
$plugin_id,
);
}
/**
* {@inheritdoc}
*/
public function apply(string $configName, mixed $value): void {
$editor = $this->configManager->loadConfigEntityByName($configName);
assert($editor instanceof EditorInterface);
if ($editor->getEditor() !== 'ckeditor5') {
throw new ConfigActionException(sprintf('The %s config action only works with editors that use CKEditor 5.', $this->pluginId));
}
if (is_string($value)) {
$value = ['item_name' => $value];
}
assert(is_array($value));
$item_name = $value['item_name'];
assert(is_string($item_name));
$replace = $value['replace'] ?? FALSE;
assert(is_bool($replace));
$position = $value['position'] ?? NULL;
$allow_duplicate = $value['allow_duplicate'] ?? FALSE;
assert(is_bool($allow_duplicate));
$editor_settings = $editor->getSettings();
// If the item is already in the toolbar and we're not allowing duplicate
// items, we're done.
if (in_array($item_name, $editor_settings['toolbar']['items'], TRUE) && $allow_duplicate === FALSE && $item_name !== '|') {
return;
}
if (is_int($position)) {
// If we want to replace the item at this position, then `replace`
// should be true. This would be useful if, for example, we wanted to
// replace the Image button with the Media Library.
array_splice($editor_settings['toolbar']['items'], $position, $replace ? 1 : 0, $item_name);
}
else {
$editor_settings['toolbar']['items'][] = $item_name;
}
// If we're just adding a vertical separator, there's nothing else we need
// to do at this point.
if ($item_name === '|') {
return;
}
// If this item is associated with a plugin, ensure that it's configured
// at the editor level, if necessary.
/** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $definition */
foreach ($this->pluginManager->getDefinitions() as $id => $definition) {
if (array_key_exists($item_name, $definition->getToolbarItems())) {
// If plugin settings already exist, don't change them.
if (array_key_exists($id, $editor_settings['plugins'])) {
break;
}
elseif ($definition->isConfigurable()) {
/** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface $plugin */
$plugin = $this->pluginManager->getPlugin($id, NULL);
$editor_settings['plugins'][$id] = $plugin->defaultConfiguration();
}
// No need to examine any other plugins.
break;
}
}
$editor->setSettings($editor_settings)->save();
}
}

View File

@ -0,0 +1,953 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Editor;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Form\SubformStateInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\ckeditor5\SmartDefaultSettings;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Plugin\Validation\Constraint\PrimitiveTypeConstraint;
use Drupal\editor\Attribute\Editor;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor as EditorEntity;
use Drupal\editor\Plugin\EditorBase;
use Drupal\filter\FilterFormatInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Defines a CKEditor 5-based text editor for Drupal.
*
* @internal
* Plugin classes are internal.
*/
#[Editor(
id: 'ckeditor5',
label: new TranslatableMarkup('CKEditor 5'),
supports_content_filtering: TRUE,
supports_inline_editing: TRUE,
is_xss_safe: FALSE,
supported_element_types: [
'textarea',
]
)]
class CKEditor5 extends EditorBase implements ContainerFactoryPluginInterface {
/**
* The CKEditor plugin manager.
*
* @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
*/
protected $ckeditor5PluginManager;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Smart default settings utility.
*
* @var \Drupal\ckeditor5\SmartDefaultSettings
*/
protected $smartDefaultSettings;
/**
* The set of configured CKEditor 5 plugins.
*
* @var \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface[]
*/
private $plugins = [];
/**
* The submitted editor.
*
* @var \Drupal\editor\EditorInterface
*/
private $submittedEditor;
/**
* The cache.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Constructs a CKEditor 5 editor plugin.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface $ckeditor5_plugin_manager
* The CKEditor 5 plugin manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\ckeditor5\SmartDefaultSettings $smart_default_settings
* The smart default settings utility.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, CKEditor5PluginManagerInterface $ckeditor5_plugin_manager, LanguageManagerInterface $language_manager, ModuleHandlerInterface $module_handler, SmartDefaultSettings $smart_default_settings, CacheBackendInterface $cache, LoggerInterface $logger) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->ckeditor5PluginManager = $ckeditor5_plugin_manager;
$this->languageManager = $language_manager;
$this->moduleHandler = $module_handler;
$this->smartDefaultSettings = $smart_default_settings;
$this->cache = $cache;
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('plugin.manager.ckeditor5.plugin'),
$container->get('language_manager'),
$container->get('module_handler'),
$container->get('ckeditor5.smart_default_settings'),
$container->get('cache.default'),
$container->get('logger.channel.ckeditor5')
);
}
/**
* {@inheritdoc}
*/
public function getDefaultSettings() {
return [
'toolbar' => [
'items' => ['heading', 'bold', 'italic'],
],
'plugins' => [
'ckeditor5_heading' => Heading::DEFAULT_CONFIGURATION,
],
];
}
/**
* Validates a Text Editor + Text Format pair.
*
* Drupal is designed to only verify schema conformity (and validation) of
* individual config entities. The Text Editor module layers a tightly coupled
* Editor entity on top of the Filter module's FilterFormat config entity.
* This inextricable coupling is clearly visible in EditorInterface:
* \Drupal\editor\EditorInterface::getFilterFormat(). They are always paired.
* Because not every text editor is guaranteed to be compatible with every
* text format, the pair must be validated.
*
* @param \Drupal\editor\EditorInterface $text_editor
* The paired text editor to validate.
* @param \Drupal\filter\FilterFormatInterface $text_format
* The paired text format to validate.
* @param bool $all_compatibility_problems
* Get all compatibility problems (default) or only fundamental ones.
*
* @return \Symfony\Component\Validator\ConstraintViolationListInterface
* The validation constraint violations.
*
* @throws \InvalidArgumentException
* Thrown when the text editor is not configured to use CKEditor 5.
*
* @see \Drupal\editor\EditorInterface::getFilterFormat()
* @see ckeditor5.pair.schema.yml
*/
public static function validatePair(EditorInterface $text_editor, FilterFormatInterface $text_format, bool $all_compatibility_problems = TRUE): ConstraintViolationListInterface {
if ($text_editor->getEditor() !== 'ckeditor5') {
throw new \InvalidArgumentException('This text editor is not configured to use CKEditor 5.');
}
$typed_config_manager = \Drupal::getContainer()->get('config.typed');
$typed_config = $typed_config_manager->createFromNameAndData(
'ckeditor5_valid_pair__format_and_editor',
[
// A mix of:
// - editor.editor.*.settings — note that "settings" is top-level in
// editor.editor.*, and so it is here, so all validation constraints
// will continue to work fine.
'settings' => $text_editor->toArray()['settings'],
// - filter.format.*.filters — note that "filters" is top-level in
// filter.format.*, and so it is here, so all validation constraints
// will continue to work fine.
'filters' => $text_format->toArray()['filters'],
// - editor.editor.*.image_upload — note that "image_upload" is
// top-level in editor.editor.*, and so it is here, so all validation
// constraints will continue to work fine.
'image_upload' => $text_editor->toArray()['image_upload'],
]
);
$violations = $typed_config->validate();
// Only consider validation constraint violations covering the pair, so not
// irrelevant details such as a PrimitiveTypeConstraint in filter settings,
// which do not affect CKEditor 5 anyway.
foreach ($violations as $i => $violation) {
assert($violation instanceof ConstraintViolation);
if (explode('.', $violation->getPropertyPath())[0] === 'filters' && is_a($violation->getConstraint(), PrimitiveTypeConstraint::class)) {
$violations->remove($i);
}
}
if (!$all_compatibility_problems) {
foreach ($violations as $i => $violation) {
// Remove all violations that are not fundamental — these are at the
// root (property path '').
if ($violation->getPropertyPath() !== '') {
$violations->remove($i);
}
}
}
return $violations;
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$editor = $form_state->get('editor');
assert($editor instanceof EditorEntity);
$language = $this->languageManager->getCurrentLanguage();
// When enabling CKEditor 5, generate sensible settings from the
// pre-existing text editor/format rather than the hardcoded defaults
// whenever possible.
// @todo Remove after https://www.drupal.org/project/drupal/issues/3226673.
$format = $form_state->getFormObject()->getEntity();
assert($format instanceof FilterFormatInterface);
if ($editor->isNew() && !$form_state->get('ckeditor5_is_active') && $form_state->get('ckeditor5_is_selected')) {
assert($editor->getSettings() === $this->getDefaultSettings());
if (!$format->isNew()) {
[$editor, $messages] = $this->smartDefaultSettings->computeSmartDefaultSettings($editor, $format);
$form_state->set('used_smart_default_settings', TRUE);
foreach ($messages as $type => $messages_per_type) {
foreach ($messages_per_type as $message) {
$this->messenger()->addMessage($message, $type);
}
}
if (isset($messages[MessengerInterface::TYPE_WARNING]) || isset($messages[MessengerInterface::TYPE_ERROR])) {
$this->messenger()->addMessage($this->t('Check <a href=":handbook">this handbook page</a> for details about compatibility issues of contrib modules.', [
':handbook' => 'https://www.drupal.org/node/3273985',
]), MessengerInterface::TYPE_WARNING);
}
}
$eventual_editor_and_format = $this->getEventualEditorWithPrimedFilterFormat($form_state, $editor);
// Provide the validated eventual pair in form state to
// ::getGeneratedAllowedHtmlValue(), to update filter_html's
// "allowed_html".
$form_state->set('ckeditor5_validated_pair', $eventual_editor_and_format);
// Ensure that CKEditor 5 plugins that need to interact with the Editor
// config entity are able to access the computed Editor, which was cloned
// from $form_state->get('editor').
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image::buildConfigurationForm
$form_state->set('editor', $editor);
}
// AJAX validation errors should appear visually close to the text editor
// since this is a very long form: otherwise they would not be noticed.
$form['real_time_validation_errors_location'] = [
'#type' => 'container',
'#id' => 'ckeditor5-realtime-validation-messages-container',
];
$form['toolbar'] = [
'#type' => 'container',
'#title' => $this->t('CKEditor 5 toolbar configuration'),
'#theme' => 'ckeditor5_settings_toolbar',
'#attached' => [
'library' => $this->ckeditor5PluginManager->getAdminLibraries(),
'drupalSettings' => [
'ckeditor5' => [
'language' => [
'dir' => $language->getDirection(),
'langcode' => $language->getId(),
],
],
],
],
];
$form['available_items_description'] = [
'#type' => 'container',
'#markup' => $this->t('Press the down arrow key to add to the toolbar.'),
'#id' => 'available-button-description',
'#attributes' => [
'class' => ['visually-hidden'],
],
];
$form['active_items_description'] = [
'#type' => 'container',
'#markup' => $this->t('Move this button in the toolbar by pressing the left or right arrow keys. Press the up arrow key to remove from the toolbar.'),
'#id' => 'active-button-description',
'#attributes' => [
'class' => ['visually-hidden'],
],
];
// The items are encoded in markup to provide a no-JS fallback.
// Although CKEditor 5 is useless without JS it would still be possible
// to see all the available toolbar items provided by plugins in the format
// that needs to be entered in the textarea. The UI app parses this list.
$form['toolbar']['available'] = [
'#type' => 'container',
'#title' => 'Available items',
'#id' => 'ckeditor5-toolbar-buttons-available',
'available_items' => [
'#markup' => Json::encode($this->ckeditor5PluginManager->getToolbarItems()),
],
];
$editor_settings = $editor->getSettings();
// This form field requires a JSON-style array of valid toolbar items.
// e.g. ["bold","italic","|","drupalInsertImage"].
// CKEditor 5 config for toolbar items takes an array of strings which
// correspond to the keys under toolbar_items in a plugin yml or annotation.
// @see https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html
$form['toolbar']['items'] = [
'#type' => 'textarea',
'#title' => $this->t('Toolbar items'),
'#rows' => 1,
'#default_value' => Json::encode($editor_settings['toolbar']['items']),
'#id' => 'ckeditor5-toolbar-buttons-selected',
'#attributes' => [
'tabindex' => '-1',
'aria-hidden' => 'true',
],
];
$form['plugin_settings'] = [
'#type' => 'vertical_tabs',
'#title' => $this->t('CKEditor 5 plugin settings'),
// Add an ID to the editor settings vertical tabs wrapper so it can be
// easily targeted by JavaScript.
'#wrapper_attributes' => [
'id' => 'plugin-settings-wrapper',
],
];
$this->injectPluginSettingsForm($form, $form_state, $editor);
// Allow reliable detection of switching to CKEditor 5 from another text
// editor (or none at all).
$form['is_already_using_ckeditor5'] = [
'#type' => 'hidden',
'#default_value' => TRUE,
];
return $form;
}
/**
* Determines whether the plugin settings form should be visible.
*
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $definition
* The configurable CKEditor 5 plugin to assess the visibility for.
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*
* @return bool
* Whether this configurable plugin's settings form should be visible.
*/
private function shouldHaveVisiblePluginSettingsForm(CKEditor5PluginDefinition $definition, EditorInterface $editor): bool {
assert($definition->isConfigurable());
$enabled_plugins = $this->ckeditor5PluginManager->getEnabledDefinitions($editor);
$plugin_id = $definition->id();
// Enabled plugins should be configurable.
if (isset($enabled_plugins[$plugin_id])) {
return TRUE;
}
// There are two circumstances where a plugin not listed in $enabled_plugins
// due to isEnabled() returning false, that should still have its config
// form provided:
// 1 - A conditionally enabled plugin that does not depend on a toolbar item
// to be active AND the plugins it depends on are enabled (if any) AND the
// filter it depends on is enabled (if any).
// 2 - A conditionally enabled plugin that does depend on a toolbar item,
// and that toolbar item is active.
if ($definition->hasConditions()) {
$conditions = $definition->getConditions();
if (!array_key_exists('toolbarItem', $conditions)) {
$conclusion = TRUE;
// The filter this plugin depends on must be enabled.
if (array_key_exists('filter', $conditions)) {
$required_filter = $conditions['filter'];
$format_filters = $editor->getFilterFormat()->filters();
$conclusion = $conclusion && $format_filters->has($required_filter) && $format_filters->get($required_filter)->status;
}
// The CKEditor 5 plugins this plugin depends on must be enabled.
if (array_key_exists('plugins', $conditions)) {
$all_plugins = $this->ckeditor5PluginManager->getDefinitions();
$dependencies = array_intersect_key($all_plugins, array_flip($conditions['plugins']));
$unmet_dependencies = array_diff_key($dependencies, $enabled_plugins);
$conclusion = $conclusion && empty($unmet_dependencies);
}
return $conclusion;
}
elseif (in_array($conditions['toolbarItem'], $editor->getSettings()['toolbar']['items'], TRUE)) {
return TRUE;
}
}
return FALSE;
}
/**
* Injects the CKEditor plugins settings forms as a vertical tabs subform.
*
* @param array &$form
* A reference to an associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\editor\EditorInterface $editor
* A configured text editor object.
*/
private function injectPluginSettingsForm(array &$form, FormStateInterface $form_state, EditorInterface $editor): void {
$definitions = $this->ckeditor5PluginManager->getDefinitions();
$eventual_editor_and_format = $this->getEventualEditorWithPrimedFilterFormat($form_state, $editor);
foreach ($definitions as $plugin_id => $definition) {
if ($definition->isConfigurable() && $this->shouldHaveVisiblePluginSettingsForm($definition, $eventual_editor_and_format)) {
$plugin = $this->ckeditor5PluginManager->getPlugin($plugin_id, $editor);
$plugin_settings_form = [];
$form['plugins'][$plugin_id] = [
'#type' => 'details',
'#title' => $definition->label(),
'#open' => TRUE,
'#group' => 'editor][settings][plugin_settings',
'#attributes' => [
'data-ckeditor5-plugin-id' => $plugin_id,
],
];
$form['plugins'][$plugin_id] += $plugin->buildConfigurationForm($plugin_settings_form, $form_state);
}
}
}
/**
* Form #after_build callback: provides text editor state changes.
*
* Updates the internal $this->entity object with submitted values when the
* form is being rebuilt (e.g. submitted via AJAX), so that subsequent
* processing (e.g. AJAX callbacks) can rely on it.
*
* @see \Drupal\Core\Entity\EntityForm::afterBuild()
*/
public static function assessActiveTextEditorAfterBuild(array $element, FormStateInterface $form_state): array {
// The case of the form being built initially, and the text editor plugin in
// use is already CKEditor 5.
if (!$form_state->isProcessingInput()) {
$editor = $form_state->get('editor');
$already_using_ckeditor5 = $editor && $editor->getEditor() === 'ckeditor5';
}
else {
// Whenever there is user input, this cannot be the initial build of the
// form and hence we need to inspect user input.
$already_using_ckeditor5 = FALSE;
NestedArray::getValue($form_state->getUserInput(), ['editor', 'settings', 'is_already_using_ckeditor5'], $already_using_ckeditor5);
}
$form_state->set('ckeditor5_is_active', $already_using_ckeditor5);
$form_state->set('ckeditor5_is_selected', $form_state->getValue(['editor', 'editor']) === 'ckeditor5');
return $element;
}
/**
* Validate callback to inform the user of CKEditor 5 compatibility problems.
*/
public static function validateSwitchingToCKEditor5(array $form, FormStateInterface $form_state): void {
if (!$form_state->get('ckeditor5_is_active') && $form_state->get('ckeditor5_is_selected')) {
$minimal_ckeditor5_editor = EditorEntity::create([
'format' => NULL,
'editor' => 'ckeditor5',
]);
$submitted_filter_format = CKEditor5::getSubmittedFilterFormat($form_state);
$fundamental_incompatibilities = CKEditor5::validatePair($minimal_ckeditor5_editor, $submitted_filter_format, FALSE);
foreach ($fundamental_incompatibilities as $violation) {
// If the violation uses the nonAllowedElementsMessage template, it can
// be skipped because this is a violation that automatically fixed
// within SmartDefaultSettings, but SmartDefaultSettings does not
// execute until this validator passes.
if ($violation->getMessageTemplate() === $violation->getConstraint()->nonAllowedElementsMessage) {
continue;
}
// @codingStandardsIgnoreLine
$form_state->setErrorByName('editor][editor', t($violation->getMessageTemplate(), $violation->getParameters()));
}
}
}
/**
* Value callback to set the CKEditor 5-generated "allowed_html" value.
*
* Used to set the value of filter_html's "allowed_html" form item if the form
* has been validated and hence `ckeditor5_validated_pair` is available
* in form state. This allows setting a guaranteed to be valid value.
*
* `ckeditor5_validated_pair` can be set from two places:
* - When switching to CKEditor 5, this is populated by
* CKEditor5::buildConfigurationForm().
* - When making filter or editor settings changes, it is populated by
* CKEditor5::validateConfigurationForm().
*
* @param array $element
* An associative array containing the properties of the element.
* @param mixed $input
* The incoming input to populate the form element. If this is FALSE,
* the element's default value should be returned.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return string
* The value to assign to the element.
*/
public static function getGeneratedAllowedHtmlValue(array &$element, $input, FormStateInterface $form_state): string {
if ($form_state->isValidationComplete()) {
$validated_format = $form_state->get('ckeditor5_validated_pair')->getFilterFormat();
$configuration = $validated_format->filters()->get('filter_html')->getConfiguration();
return $configuration['settings']['allowed_html'];
}
else {
if ($input !== FALSE) {
return $input;
}
return $element['#default_value'];
}
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$json = $form_state->getValue(['toolbar', 'items']);
$toolbar_items = Json::decode($json);
// This basic validation must live in the form logic because it can only
// occur in a form context.
if (!$toolbar_items) {
$form_state->setErrorByName('toolbar][items', $this->t('Invalid toolbar value.'));
return;
}
// Construct a Text Editor config entity with the submitted values for
// validation. Do this on a clone: do not manipulate form state.
$submitted_editor = clone $form_state->get('editor');
$settings = $submitted_editor->getSettings();
// Update settings first to match the submitted toolbar items. This is
// necessary for ::shouldHaveVisiblePluginSettingsForm() to work.
$settings['toolbar']['items'] = $toolbar_items;
$submitted_editor->setSettings($settings);
$eventual_editor_and_format_for_plugin_settings_visibility = $this->getEventualEditorWithPrimedFilterFormat($form_state, $submitted_editor);
$settings['plugins'] = [];
$default_configurations = [];
foreach ($this->ckeditor5PluginManager->getDefinitions() as $plugin_id => $definition) {
if (!$definition->isConfigurable()) {
continue;
}
// Create a fresh instance of this CKEditor 5 plugin, not tied to a text
// editor configuration entity.
$plugin = $this->ckeditor5PluginManager->getPlugin($plugin_id, NULL);
// If this plugin is configurable but it has empty default configuration,
// that means the configuration must be stored out of band.
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
// @see editor_image_upload_settings_form()
$default_configuration = $plugin->defaultConfiguration();
$configuration_stored_out_of_band = empty($default_configuration);
// If this plugin is configurable but has not yet had user interaction,
// the default configuration will still be active and may trigger
// validation errors. Do not trigger those validation errors until the
// form is actually saved, to allow the user to first configure other
// CKEditor 5 functionality.
$default_configurations[$plugin_id] = $default_configuration;
if ($form_state->hasValue(['plugins', $plugin_id])) {
$subform = $form['plugins'][$plugin_id];
$subform_state = SubformState::createForSubform($subform, $form, $form_state);
$plugin->validateConfigurationForm($subform, $subform_state);
$plugin->submitConfigurationForm($subform, $subform_state);
// If the configuration is stored out of band, ::submitConfigurationForm
// will already have stored it. If it is not stored out of band,
// populate $settings, to populate $submitted_editor.
if (!$configuration_stored_out_of_band) {
$settings['plugins'][$plugin_id] = $plugin->getConfiguration();
}
}
// @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::injectPluginSettingsForm()
elseif ($this->shouldHaveVisiblePluginSettingsForm($definition, $eventual_editor_and_format_for_plugin_settings_visibility)) {
if (!$configuration_stored_out_of_band) {
$settings['plugins'][$plugin_id] = $default_configuration;
}
}
}
// All plugin settings have been collected, including defaults that depend
// on visibility. Store the collected settings, throw away the interim state
// that allowed determining which defaults to add.
// Create a new clone, because the plugins whose data is being stored
// out-of-band may have modified the Text Editor config entity in the form
// state.
// @see \Drupal\editor\EditorInterface::setImageUploadSettings()
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image::submitConfigurationForm()
unset($eventual_editor_and_format_for_plugin_settings_visibility);
$submitted_editor = clone $form_state->get('editor');
$submitted_editor->setSettings($settings);
// Validate the text editor + text format pair.
// Note that the eventual pair is computed and validated, not the received
// pair: if the filter_html filter is in use, the CKEditor 5 configuration
// dictates the filter_html's filter plugin's "allowed_html" setting.
// @see ckeditor5_form_filter_format_form_alter()
// @see ::getGeneratedAllowedHtmlValue()
$eventual_editor_and_format = $this->getEventualEditorWithPrimedFilterFormat($form_state, $submitted_editor);
$violations = CKEditor5::validatePair($eventual_editor_and_format, $eventual_editor_and_format->getFilterFormat());
foreach ($violations as $violation) {
$property_path_parts = explode('.', $violation->getPropertyPath());
// Special case: AJAX updates that do not submit the form (that cannot
// result in configuration being saved).
if (in_array('editor_form_filter_admin_format_editor_configure', $form_state->getSubmitHandlers(), TRUE)) {
// Ensure that plugins' validation constraints do not immediately
// trigger a validation error: the user may choose to configure other
// CKEditor 5 aspects first.
if ($property_path_parts[0] === 'settings' && $property_path_parts[1] === 'plugins') {
$plugin_id = $property_path_parts[2];
// This CKEditor 5 plugin settings form was just added: the user has
// not yet had a chance to configure it.
if (!$form_state->hasValue(['plugins', $plugin_id])) {
continue;
}
// This CKEditor 5 plugin settings form was added recently, the user
// is triggering AJAX rebuilds of the configuration UI because they're
// configuring other functionality first. Only require these to be
// valid at form submission time.
if ($form_state->getValue(['plugins', $plugin_id]) === $default_configurations[$plugin_id]) {
continue;
}
}
}
$form_item_name = static::mapPairViolationPropertyPathsToFormNames($violation->getPropertyPath(), $form);
// When adding a toolbar item, it is possible that not all conditions for
// using it have been met yet. FormBuilder refuses to rebuild forms when a
// validation error is present. But to meet the condition for the toolbar
// item, configuration must be set in a vertical tab that must still
// appear. Work-around: reduce the validation error to a warning message.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\ToolbarItemConditionsMetConstraintValidator
if ($form_state->isRedirectDisabled() && $form_item_name === 'editor][settings][toolbar][items') {
$this->messenger()->addWarning($violation->getMessage());
continue;
}
$form_state->getCompleteFormState()->setErrorByName($form_item_name, $violation->getMessage());
}
// Pass it on to ::submitConfigurationForm().
$form_state->get('editor')->setSettings($settings);
// Provide the validated eventual pair in form state to
// ::getGeneratedAllowedHtmlValue(), to update filter_html's
// "allowed_html".
$form_state->set('ckeditor5_validated_pair', $eventual_editor_and_format);
}
/**
* Gets the submitted text format config entity from form state.
*
* Needed for validation.
*
* @param \Drupal\Core\Form\FormStateInterface $filter_format_form_state
* The text format configuration form's form state.
*
* @return \Drupal\filter\FilterFormatInterface
* A FilterFormat config entity representing the current filter form state.
*/
protected static function getSubmittedFilterFormat(FormStateInterface $filter_format_form_state): FilterFormatInterface {
$submitted_filter_format = clone $filter_format_form_state->getFormObject()->getEntity();
assert($submitted_filter_format instanceof FilterFormatInterface);
// Get only the values of the filter_format form state that are relevant for
// checking compatibility. This logic is copied from FilterFormatFormBase.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraintValidator
// @see \Drupal\filter\FilterFormatFormBase::submitForm()
$filter_format_form_values = array_intersect_key(
$filter_format_form_state->getValues(),
array_flip(['filters', 'filter_settings']
));
foreach ($filter_format_form_values as $key => $value) {
if ($key !== 'filters') {
$submitted_filter_format->set($key, $value);
}
else {
foreach ($value as $instance_id => $config) {
$submitted_filter_format->setFilterConfig($instance_id, $config);
}
}
}
return $submitted_filter_format;
}
/**
* Gets the eventual text format config entity: from form state + editor.
*
* Needed for validation.
*
* @param \Drupal\Core\Form\SubformStateInterface $editor_form_state
* The text editor configuration form's form state.
* @param \Drupal\editor\EditorInterface $submitted_editor
* The current text editor config entity.
*
* @return \Drupal\editor\EditorInterface
* A clone of the received Editor config entity , with a primed associated
* FilterFormat that corresponds to the current form state, to avoid the
* stored FilterFormat config entity being loaded.
*/
protected function getEventualEditorWithPrimedFilterFormat(SubformStateInterface $editor_form_state, EditorInterface $submitted_editor): EditorInterface {
$submitted_filter_format = static::getSubmittedFilterFormat($editor_form_state->getCompleteFormState());
$pair = static::createEphemeralPairedEditor($submitted_editor, $submitted_filter_format);
// When CKEditor 5 plugins are disabled in the form-based admin UI, the
// associated settings (if any) should be omitted too, except for plugins
// that are enabled using `requiresConfiguration` (because whether they are
// enabled or not depends on the associated settings).
$original_settings = $pair->getSettings();
$enabled_plugins = $this->ckeditor5PluginManager->getEnabledDefinitions($pair);
$config_enabled_plugins = [];
foreach ($this->ckeditor5PluginManager->getDefinitions() as $id => $definition) {
if ($definition->hasConditions() && isset($definition->getConditions()['requiresConfiguration'])) {
$config_enabled_plugins[$id] = TRUE;
}
}
$updated_settings = [
'plugins' => array_intersect_key($original_settings['plugins'], $enabled_plugins + $config_enabled_plugins),
] + $original_settings;
$pair->setSettings($updated_settings);
if ($pair->getFilterFormat()->filters('filter_html')->status) {
// Compute elements provided by the current CKEditor 5 settings.
$restrictions = new HTMLRestrictions($this->ckeditor5PluginManager->getProvidedElements(array_keys($enabled_plugins), $pair));
// Compute eventual filter_html setting. Eventual as in: this is the list
// of eventually allowed HTML tags.
// @see \Drupal\filter\FilterFormatFormBase::submitForm()
// @see ckeditor5_form_filter_format_form_alter()
$filter_html_config = $pair->getFilterFormat()->filters('filter_html')->getConfiguration();
$filter_html_config['settings']['allowed_html'] = $restrictions->toFilterHtmlAllowedTagsString();
$pair->getFilterFormat()->setFilterConfig('filter_html', $filter_html_config);
}
return $pair;
}
/**
* Creates an ephemeral pair of text editor + text format config entity.
*
* Clones the given text editor config entity object and then overwrites its
* $filterFormat property, to prevent loading the text format config entity
* from entity storage in calls to Editor::hasAssociatedFilterFormat() and
* Editor::getFilterFormat().
* This is necessary to be able to evaluate unsaved text editor and format
* config entities:
* - for assessing which CKEditor 5 plugins are enabled and whose settings
* forms to show
* - for validating them.
*
* @param \Drupal\editor\EditorInterface $editor
* The submitted text editor config entity, constructed from form values.
* @param \Drupal\filter\FilterFormatInterface $filter_format
* The submitted text format config entity, constructed from form values.
*
* @return \Drupal\editor\EditorInterface
* A clone of the given text editor config entity, with its $filterFormat
* property set to a clone of the given text format config entity.
*
* @throws \ReflectionException
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::isPluginDisabled()
* @todo Remove this in https://www.drupal.org/project/drupal/issues/3231347
*/
protected static function createEphemeralPairedEditor(EditorInterface $editor, FilterFormatInterface $filter_format): EditorInterface {
$paired_editor = clone $editor;
// If the editor is still being configured, the configuration may not yet be
// valid. Explicitly mark the ephemeral paired editor as new to allow other
// code to treat this accordingly.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getProvidedElements()
$paired_editor->enforceIsNew(TRUE);
$reflector = new \ReflectionObject($paired_editor);
$property = $reflector->getProperty('filterFormat');
$property->setValue($paired_editor, clone $filter_format);
return $paired_editor;
}
/**
* Maps Text Editor config object property paths to form names.
*
* @param string $property_path
* A config object property path.
* @param array $subform
* The subform being checked.
*
* @return string
* The corresponding form name in the subform.
*/
protected static function mapViolationPropertyPathsToFormNames(string $property_path, array $subform): string {
$parts = explode('.', $property_path);
// The "settings" form element does exist, but one level above the Text
// Editor-specific form. This is operating on a subform.
$shifted = array_shift($parts);
assert($shifted === 'settings');
// It is not required (nor sensible) for the form structure to match the
// config schema structure 1:1. Automatically identify the relevant form
// name. Try to be specific. Worst case, an entire plugin settings vertical
// tab is targeted. (Hence the minimum of 2 parts: the property path gets at
// minimum mapped to 'toolbar.items' or 'plugins.<plugin ID>'.)
while (count($parts) > 2 && !NestedArray::keyExists($subform, $parts)) {
array_pop($parts);
}
assert(NestedArray::keyExists($subform, $parts));
return implode('][', array_merge(['settings'], $parts));
}
/**
* Maps Text Editor + Text Format pair property paths to form names.
*
* @param string $property_path
* A config object property path.
* @param array $form
* The form being checked.
*
* @return string
* The corresponding form name in the complete form.
*/
protected static function mapPairViolationPropertyPathsToFormNames(string $property_path, array $form): string {
// Fundamental compatibility errors are at the root. Map these to the text
// editor plugin dropdown.
if ($property_path === '') {
return 'editor][editor';
}
// Filters are top-level.
if (preg_match('/^filters\..*/', $property_path)) {
return implode('][', array_merge(explode('.', $property_path), ['settings']));
}
// Image upload settings are stored out-of-band and may also trigger
// validation errors.
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
if (str_starts_with($property_path, 'image_upload.')) {
$image_upload_setting_property_path = str_replace('image_upload.', '', $property_path);
return 'editor][settings][plugins][ckeditor5_image][' . implode('][', explode('.', $image_upload_setting_property_path));
}
// Everything else is in the subform.
return 'editor][' . static::mapViolationPropertyPathsToFormNames($property_path, $form);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
// @see ::validateConfigurationForm()
$editor = $form_state->get('editor');
// Prepare the editor settings for editor_form_filter_admin_format_submit().
// This strips away unwanted form values too, because those never can exist
// in the already validated Editor config entity.
$form_state->setValues($editor->getSettings());
parent::submitConfigurationForm($form, $form_state);
if ($form_state->get('used_smart_default_settings')) {
$format_name = $editor->getFilterFormat()->get('name');
$this->logger->info($this->t('The migration of %text_format to CKEditor 5 has been saved.', ['%text_format' => $format_name]));
}
}
/**
* {@inheritdoc}
*/
public function getJSSettings(EditorEntity $editor) {
$toolbar_items = $editor->getSettings()['toolbar']['items'];
$plugin_config = $this->ckeditor5PluginManager->getCKEditor5PluginConfig($editor);
$settings = [
'toolbar' => [
'items' => $toolbar_items,
'shouldNotGroupWhenFull' => in_array('-', $toolbar_items, TRUE),
],
] + $plugin_config;
$settings['config']['licenseKey'] ??= 'GPL';
if ($this->moduleHandler->moduleExists('locale')) {
$language_interface = $this->languageManager->getCurrentLanguage();
$settings['language']['ui'] = _ckeditor5_get_langcode_mapping($language_interface->getId());
}
return $settings;
}
/**
* {@inheritdoc}
*/
public function getLibraries(EditorEntity $editor) {
$plugin_libraries = $this->ckeditor5PluginManager->getEnabledLibraries($editor);
if ($this->moduleHandler->moduleExists('locale')) {
$language_interface = $this->languageManager->getCurrentLanguage();
$plugin_libraries[] = 'core/ckeditor5.translations.' . _ckeditor5_get_langcode_mapping($language_interface->getId());
}
return $plugin_libraries;
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* CKEditor 5 element.
*/
#[Constraint(
id: 'CKEditor5Element',
label: new TranslatableMarkup('CKEditor 5 element', [], ['context' => 'Validation'])
)]
class CKEditor5ElementConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'The following tag is not valid HTML: %provided_element.';
/**
* Violation message when a required attribute is missing.
*
* @var string
*/
public $missingRequiredAttributeMessage = 'The following tag is missing the required attribute <code>@required_attribute_name</code>: <code>@provided_element</code>.';
/**
* Violation message when a required attribute does not allow enough values.
*
* @var string
*/
public $requiredAttributeMinValuesMessage = 'The following tag does not have the minimum of @min_attribute_value_count allowed values for the required attribute <code>@required_attribute_name</code>: <code>@provided_element</code>.';
/**
* Validation constraint option to impose attributes to be specified.
*
* @var null|array
*/
public $requiredAttributes = NULL;
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\ckeditor5\HTMLRestrictions;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* CKEditor 5 element validator.
*
* @internal
*/
class CKEditor5ElementConstraintValidator extends ConstraintValidator {
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($element, $constraint): void {
if (!$constraint instanceof CKEditor5ElementConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\CKEditor5Element');
}
$parsed = HTMLRestrictions::fromString($element);
if ($parsed->allowsNothing() || count($parsed->getAllowedElements()) > 1 || $element !== $parsed->toCKEditor5ElementsArray()[0]) {
$this->context->buildViolation($constraint->message)
->setParameter('%provided_element', $element)
->addViolation();
}
// The optional "requiredAttributes" constraint property allows more
// detailed validation.
if (isset($constraint->requiredAttributes)) {
$allowed_elements = $parsed->getAllowedElements();
$tag = array_keys($allowed_elements)[0];
$attribute_restrictions = $allowed_elements[$tag];
assert(is_array($constraint->requiredAttributes));
foreach ($constraint->requiredAttributes as $required_attribute) {
// Validate attributeName.
$required_attribute_name = $required_attribute['attributeName'];
if (!is_array($attribute_restrictions) || !isset($attribute_restrictions[$required_attribute_name])) {
$this->context->buildViolation($constraint->missingRequiredAttributeMessage)
->setParameter('@provided_element', $element)
->setParameter('@required_attribute_name', $required_attribute_name)
->addViolation();
continue;
}
$attribute_values = $attribute_restrictions[$required_attribute_name];
// Validate minAttributeValueCount if specified.
if (isset($required_attribute['minAttributeValueCount'])) {
$min_attribute_value_count = $required_attribute['minAttributeValueCount'];
if (!is_array($attribute_values) || count($attribute_values) < $min_attribute_value_count) {
$this->context->buildViolation($constraint->requiredAttributeMinValuesMessage)
->setParameter('@provided_element', $element)
->setParameter('@required_attribute_name', $required_attribute_name)
->setParameter('@min_attribute_value_count', $min_attribute_value_count)
->addViolation();
continue;
}
}
}
}
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Ensure CKEditor 5 media plugin's and media filter's settings are in sync.
*
* @internal
*/
#[Constraint(
id: 'CKEditor5MediaAndFilterSettingsInSync',
label: new TranslatableMarkup('CKEditor 5 Media plugin in sync with filter settings', [], ['context' => 'Validation'])
)]
class CKEditor5MediaAndFilterSettingsInSyncConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'The CKEditor 5 "%cke5_media_plugin_label" plugin\'s "%cke5_allow_view_mode_override_label" setting should be in sync with the "%filter_media_plugin_label" filter\'s "%filter_media_allowed_view_modes_label" setting: when checked, two or more view modes must be allowed by the filter.';
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Drupal\filter\FilterPluginManager;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* CKEditor 5 Media plugin in sync with the filter settings validator.
*
* @internal
*/
class CKEditor5MediaAndFilterSettingsInSyncConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PluginManagerDependentValidatorTrait;
use TextEditorObjectDependentValidatorTrait;
use StringTranslationTrait;
/**
* The filter plugin manager service.
*
* @var \Drupal\filter\FilterPluginManager
*/
protected $filterPluginManager;
/**
* The typed config manager service.
*
* @var \Drupal\Core\Config\TypedConfigManagerInterface
*/
protected $typedConfigManager;
/**
* Constructs a new CKEditor5MediaAndFilterSettingsInSyncConstraintValidator.
*
* @param \Drupal\filter\FilterPluginManager $filter_plugin_manager
* The filter plugin manager service.
* @param \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager
* The typed config manager service.
*/
public function __construct(FilterPluginManager $filter_plugin_manager, TypedConfigManagerInterface $typed_config_manager) {
$this->filterPluginManager = $filter_plugin_manager;
$this->typedConfigManager = $typed_config_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.filter'),
$container->get('config.typed'),
);
}
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($toolbar_item, Constraint $constraint): void {
if (!$constraint instanceof CKEditor5MediaAndFilterSettingsInSyncConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\CKEditor5MediaAndFilterSettingsInSync');
}
$text_editor = $this->createTextEditorObjectFromContext();
if (isset($text_editor->getSettings()['plugins']['media_media'])) {
$cke5_plugin_overrides_allowed = $text_editor->getSettings()['plugins']['media_media']['allow_view_mode_override'];
$filter_allowed_view_modes = $text_editor->getFilterFormat()->filters('media_embed')->getConfiguration()['settings']['allowed_view_modes'];
$filter_media_plugin_label = $this->filterPluginManager->getDefinition('media_embed')['title']->render();
$filter_media_allowed_view_modes_label = $this->typedConfigManager->getDefinition('filter_settings.media_embed')['mapping']['allowed_view_modes']['label'];
// Whenever the CKEditor 5 plugin is configured to allow overrides, the
// filter must be configured to allow 2 or more view modes.
if ($cke5_plugin_overrides_allowed && count($filter_allowed_view_modes) < 2) {
$this->context->addViolation($constraint->message, [
'%cke5_media_plugin_label' => $this->t('Media'),
'%cke5_allow_view_mode_override_label' => $this->t('Allow the user to override the default view mode'),
'%filter_media_plugin_label' => $filter_media_plugin_label,
'%filter_media_allowed_view_modes_label' => $filter_media_allowed_view_modes_label,
]);
}
}
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* The CKEditor 5 plugin settings.
*
* @internal
*/
#[Constraint(
id: 'CKEditor5EnabledConfigurablePlugins',
label: new TranslatableMarkup('CKEditor 5 enabled configurable plugins', [], ['context' => 'Validation'])
)]
class EnabledConfigurablePluginsConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'Configuration for the enabled plugin "%plugin_label" (%plugin_id) is missing.';
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Enabled configurable plugin settings validator.
*
* @internal
*/
class EnabledConfigurablePluginsConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PluginManagerDependentValidatorTrait;
use TextEditorObjectDependentValidatorTrait;
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($settings, Constraint $constraint): void {
if (!$constraint instanceof EnabledConfigurablePluginsConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\EnabledConfigurablePluginsConstraint');
}
$configurable_enabled_definitions = $this->getConfigurableEnabledDefinitions();
try {
$plugin_settings = $this->context->getRoot()->get('settings.plugins')->getValue();
}
catch (\InvalidArgumentException) {
$plugin_settings = [];
}
foreach ($configurable_enabled_definitions as $id => $definition) {
// Create a fresh instance of this CKEditor 5 plugin, not tied to a text
// editor configuration entity.
$plugin = $this->pluginManager->getPlugin($id, NULL);
// If this plugin is configurable but it has empty default configuration,
// that means the configuration must be stored out of band.
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
// @see editor_image_upload_settings_form()
$default_configuration = $plugin->defaultConfiguration();
if ($default_configuration === []) {
continue;
}
if (!isset($plugin_settings[$id]) || empty($plugin_settings[$id])) {
$this->context->buildViolation($constraint->message)
->setParameter('%plugin_label', (string) $definition->label())
->setParameter('%plugin_id', $id)
->atPath("plugins.$id")
->addViolation();
}
}
}
/**
* Gets all configurable CKEditor 5 plugin definitions that are enabled.
*
* @return \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[]
* An array of enabled configurable CKEditor 5 plugin definitions.
*/
private function getConfigurableEnabledDefinitions(): array {
$text_editor = $this->createTextEditorObjectFromContext();
$enabled_definitions = $this->pluginManager->getEnabledDefinitions($text_editor);
$configurable_enabled_definitions = array_filter($enabled_definitions, function (CKEditor5PluginDefinition $definition): bool {
return $definition->isConfigurable();
});
return $configurable_enabled_definitions;
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* The fundamental compatibility constraint.
*
* @internal
*/
#[Constraint(
id: 'CKEditor5FundamentalCompatibility',
label: new TranslatableMarkup('CKEditor 5 fundamental text format compatibility', [], ['context' => 'Validation'])
)]
class FundamentalCompatibilityConstraint extends SymfonyConstraint {
/**
* The violation message when no markup filters are enabled.
*
* @var string
*/
public $noMarkupFiltersMessage = 'CKEditor 5 only works with HTML-based text formats. The "%filter_label" (%filter_plugin_id) filter implies this text format is not HTML anymore.';
/**
* The violation message when fundamental HTML elements are not allowed.
*
* @var string
*/
public $nonAllowedElementsMessage = 'CKEditor 5 needs at least the &lt;p&gt; and &lt;br&gt; tags to be allowed to be able to function. They are not allowed by the "%filter_label" (%filter_plugin_id) filter.';
/**
* The violation message when HTML elements cannot be generated by CKE5.
*
* @var string
*/
public $notSupportedElementsMessage = 'The current CKEditor 5 build requires the following elements and attributes: <br><code>@list</code><br>The following elements are not supported: <br><code>@diff</code>';
/**
* The violation message when CKE5 can generate disallowed HTML elements.
*
* @var string
*/
public $missingElementsMessage = 'The current CKEditor 5 build requires the following elements and attributes: <br><code>@list</code><br>The following elements are missing: <br><code>@diff</code>';
/**
* The violation message when CKE5 cannot create a needed tag.
*
* @var string
*/
public $nonCreatableTagMessage = 'The %plugin plugin needs another plugin to create <code>@non_creatable_tag</code>, for it to be able to create the following attributes: <code>@attributes_on_tag</code>. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.';
}

View File

@ -0,0 +1,309 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\editor\EditorInterface;
use Drupal\filter\FilterFormatInterface;
use Drupal\filter\Plugin\Filter\FilterAutoP;
use Drupal\filter\Plugin\Filter\FilterUrl;
use Drupal\filter\Plugin\FilterInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validates fundamental compatibility of CKEditor 5 with the given text format.
*
* Fundamental requirements:
* 1. No TYPE_MARKUP_LANGUAGE filters allowed.
* 2. Fundamental CKEditor 5 plugins' HTML tags are allowed.
* 3. All tags are actually creatable.
* 4. The HTML restrictions of all TYPE_HTML_RESTRICTOR filters allow the
* configured CKEditor 5 plugins to work.
*
* @see \Drupal\filter\Plugin\FilterInterface::TYPE_HTML_RESTRICTOR
*
* @internal
*/
class FundamentalCompatibilityConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PluginManagerDependentValidatorTrait;
use TextEditorObjectDependentValidatorTrait;
/**
* The fundamental CKEditor 5 plugins without which it cannot function.
*
* @var string[]
*/
const FUNDAMENTAL_CKEDITOR5_PLUGINS = [
'ckeditor5_essentials',
'ckeditor5_paragraph',
];
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($toolbar_item, Constraint $constraint): void {
if (!$constraint instanceof FundamentalCompatibilityConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\FundamentalCompatibility');
}
$text_editor = $this->createTextEditorObjectFromContext();
// First: the two fundamental checks against the text format. If any of
// them adds a constraint violation, return early, because it is a
// fundamental compatibility problem.
$this->checkNoMarkupFilters($text_editor->getFilterFormat(), $constraint);
if ($this->context->getViolations()->count() > 0) {
return;
}
$this->checkHtmlRestrictionsAreCompatible($text_editor->getFilterFormat(), $constraint);
if ($this->context->getViolations()->count() > 0) {
return;
}
// Second: ensure that all tags can actually be created.
$this->checkAllHtmlTagsAreCreatable($text_editor, $constraint);
// Finally: ensure the CKEditor 5 configuration's ability to generate HTML
// markup precisely matches that of the text format.
$this->checkHtmlRestrictionsMatch($text_editor, $constraint);
}
/**
* Checks no TYPE_MARKUP_LANGUAGE filters are present.
*
* Two TYPE_MARKUP_LANGUAGE filters are exempted:
* - filter_autop: pointless but harmless to have enabled
* - filter_url: not recommended but also harmless to have enabled
*
* These two commonly enabled filters with a long history in Drupal are
* considered to be acceptable to have enabled.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* The text format to validate.
* @param \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraint $constraint
* The constraint to validate.
*/
private function checkNoMarkupFilters(FilterFormatInterface $text_format, FundamentalCompatibilityConstraint $constraint): void {
$markup_filters = static::getFiltersInFormatOfType(
$text_format,
FilterInterface::TYPE_MARKUP_LANGUAGE
);
foreach ($markup_filters as $markup_filter) {
if ($markup_filter instanceof FilterAutoP || $markup_filter instanceof FilterUrl) {
continue;
}
$this->context->buildViolation($constraint->noMarkupFiltersMessage)
->setParameter('%filter_label', (string) $markup_filter->getLabel())
->setParameter('%filter_plugin_id', $markup_filter->getPluginId())
->addViolation();
}
}
/**
* Checks that fundamental CKEditor 5 plugins' HTML tags are allowed.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* The text format to validate.
* @param \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraint $constraint
* The constraint to validate.
*/
private function checkHtmlRestrictionsAreCompatible(FilterFormatInterface $text_format, FundamentalCompatibilityConstraint $constraint): void {
$html_restrictions = HTMLRestrictions::fromTextFormat($text_format);
if ($html_restrictions->isUnrestricted()) {
return;
}
$fundamental = new HTMLRestrictions($this->pluginManager->getProvidedElements(self::FUNDAMENTAL_CKEDITOR5_PLUGINS));
if (!$fundamental->diff($html_restrictions)->allowsNothing()) {
$offending_filter = static::findHtmlRestrictorFilterNotAllowingTags($text_format, $fundamental);
$this->context->buildViolation($constraint->nonAllowedElementsMessage)
->setParameter('%filter_label', (string) $offending_filter->getLabel())
->setParameter('%filter_plugin_id', $offending_filter->getPluginId())
->addViolation();
}
}
/**
* Checks the HTML restrictions match the enabled CKEditor 5 plugins' output.
*
* @param \Drupal\editor\EditorInterface $text_editor
* The text editor to validate.
* @param \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraint $constraint
* The constraint to validate.
*/
private function checkHtmlRestrictionsMatch(EditorInterface $text_editor, FundamentalCompatibilityConstraint $constraint): void {
$html_restrictor_filters = static::getFiltersInFormatOfType(
$text_editor->getFilterFormat(),
FilterInterface::TYPE_HTML_RESTRICTOR
);
$enabled_plugins = array_keys($this->pluginManager->getEnabledDefinitions($text_editor));
$provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins, $text_editor);
$provided = new HTMLRestrictions($provided_elements);
foreach ($html_restrictor_filters as $filter_plugin_id => $filter) {
$allowed = HTMLRestrictions::fromFilterPluginInstance($filter);
$diff_allowed = $allowed->diff($provided);
$diff_elements = $provided->diff($allowed);
if (!$diff_allowed->allowsNothing()) {
$this->context->buildViolation($constraint->notSupportedElementsMessage)
->setParameter('@list', implode(' ', $provided->toCKEditor5ElementsArray()))
->setParameter('@diff', implode(' ', $diff_allowed->toCKEditor5ElementsArray()))
->atPath("filters.$filter_plugin_id")
->addViolation();
}
if (!$diff_elements->allowsNothing()) {
$this->context->buildViolation($constraint->missingElementsMessage)
->setParameter('@list', implode(' ', $provided->toCKEditor5ElementsArray()))
->setParameter('@diff', implode(' ', $diff_elements->toCKEditor5ElementsArray()))
->atPath("filters.$filter_plugin_id")
->addViolation();
}
}
}
/**
* Checks all HTML tags supported by enabled CKEditor 5 plugins are creatable.
*
* @param \Drupal\editor\EditorInterface $text_editor
* The text editor to validate.
* @param \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraint $constraint
* The constraint to validate.
*/
private function checkAllHtmlTagsAreCreatable(EditorInterface $text_editor, FundamentalCompatibilityConstraint $constraint): void {
$enabled_definitions = $this->pluginManager->getEnabledDefinitions($text_editor);
$enabled_plugins = array_keys($enabled_definitions);
// When arbitrary HTML is supported, all tags are creatable.
if (in_array('ckeditor5_arbitraryHtmlSupport', $enabled_plugins, TRUE)) {
return;
}
$tags_and_attributes = new HTMLRestrictions($this->pluginManager->getProvidedElements($enabled_plugins, $text_editor));
$creatable_tags = new HTMLRestrictions($this->pluginManager->getProvidedElements($enabled_plugins, $text_editor, FALSE, TRUE));
$needed_tags = $tags_and_attributes->extractPlainTagsSubset();
$non_creatable_tags = $needed_tags->diff($creatable_tags);
if (!$non_creatable_tags->allowsNothing()) {
foreach ($non_creatable_tags->toCKEditor5ElementsArray() as $non_creatable_tag) {
// Find the plugin which has a non-creatable tag.
$needle = HTMLRestrictions::fromString($non_creatable_tag);
$matching_plugins = array_filter($enabled_definitions, function (CKEditor5PluginDefinition $d) use ($needle, $text_editor) {
if (!$d->hasElements()) {
return FALSE;
}
$haystack = new HTMLRestrictions($this->pluginManager->getProvidedElements([$d->id()], $text_editor, FALSE, FALSE));
return !$haystack->extractPlainTagsSubset()->intersect($needle)->allowsNothing();
});
assert(count($matching_plugins) === 1);
$plugin_definition = reset($matching_plugins);
assert($plugin_definition instanceof CKEditor5PluginDefinition);
// Compute which attributes it would be able to create on this tag.
$provided_elements = new HTMLRestrictions($this->pluginManager->getProvidedElements([$plugin_definition->id()], $text_editor, FALSE, FALSE));
$attributes_on_tag = $provided_elements->intersect(
new HTMLRestrictions(array_fill_keys(array_keys($needle->getAllowedElements()), TRUE))
);
$violation = $this->context->buildViolation($constraint->nonCreatableTagMessage)
->setParameter('@non_creatable_tag', $non_creatable_tag)
->setParameter('%plugin', $plugin_definition->label())
->setParameter('@attributes_on_tag', implode(', ', $attributes_on_tag->toCKEditor5ElementsArray()));
// If this plugin has a configurable subset, associate the violation
// with the property path pointing to this plugin's settings form.
if (is_a($plugin_definition->getClass(), CKEditor5PluginElementsSubsetInterface::class, TRUE)) {
$violation->atPath(sprintf('settings.plugins.%s', $plugin_definition->id()));
}
// If this plugin is associated with a toolbar item, associate the
// violation with the property path pointing to the active toolbar item.
elseif ($plugin_definition->hasToolbarItems()) {
$toolbar_items = $plugin_definition->getToolbarItems();
$active_toolbar_items = array_intersect(
$text_editor->getSettings()['toolbar']['items'],
array_keys($toolbar_items)
);
$violation->atPath(sprintf('settings.toolbar.items.%d', array_keys($active_toolbar_items)[0]));
}
$violation->addViolation();
}
}
}
/**
* Gets the filters of the given type in this text format.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* A text format whose filters to get.
* @param int $filter_type
* One of FilterInterface::TYPE_*.
* @param callable|null $extra_requirements
* An optional callable that can check a filter of this type for additional
* conditions to be met. Must return TRUE when it meets the conditions,
* FALSE otherwise.
*
* @return iterable|\Drupal\filter\Plugin\FilterInterface[]
* An iterable of matched filter plugins.
*/
private static function getFiltersInFormatOfType(FilterFormatInterface $text_format, int $filter_type, ?callable $extra_requirements = NULL): iterable {
assert(in_array($filter_type, [
FilterInterface::TYPE_MARKUP_LANGUAGE,
FilterInterface::TYPE_HTML_RESTRICTOR,
FilterInterface::TYPE_TRANSFORM_REVERSIBLE,
FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE,
]));
foreach ($text_format->filters() as $id => $filter) {
if ($filter->status && $filter->getType() === $filter_type && ($extra_requirements === NULL || $extra_requirements($filter))) {
yield $id => $filter;
}
}
}
/**
* Analyzes a text format to find the filter not allowing required tags.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* A text format whose filters to check for compatibility.
* @param \Drupal\ckeditor5\HTMLRestrictions $required
* A set of HTML restrictions, listing required HTML tags.
*
* @return \Drupal\filter\Plugin\FilterInterface
* The filter plugin instance not allowing the required tags.
*
* @throws \InvalidArgumentException
*/
private static function findHtmlRestrictorFilterNotAllowingTags(FilterFormatInterface $text_format, HTMLRestrictions $required): FilterInterface {
// Get HTML restrictor filters that actually restrict HTML.
$filters = static::getFiltersInFormatOfType(
$text_format,
FilterInterface::TYPE_HTML_RESTRICTOR,
function (FilterInterface $filter) {
return $filter->getHTMLRestrictions() !== FALSE;
}
);
foreach ($filters as $filter) {
// Return any filter not allowing >=1 of the required tags.
if (!$required->diff(HTMLRestrictions::fromFilterPluginInstance($filter))->allowsNothing()) {
return $filter;
}
}
throw new \InvalidArgumentException('This text format does not have a "tags allowed" restriction that excludes the required tags.');
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
// cspell:ignore enableable
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\editor\EditorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Common functionality for many CKEditor 5 validation constraints.
*
* @internal
*/
trait PluginManagerDependentValidatorTrait {
/**
* The CKEditor 5 plugin manager.
*
* @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
*/
protected $pluginManager;
/**
* Constructs a CKEditor5ConstraintValidatorTrait object.
*
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface $plugin_manager
* The CKEditor 5 plugin manager.
*/
public function __construct(CKEditor5PluginManagerInterface $plugin_manager) {
$this->pluginManager = $plugin_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.ckeditor5.plugin')
);
}
/**
* Gets all other enabled CKEditor 5 plugin definitions.
*
* @param \Drupal\editor\EditorInterface $text_editor
* A Text Editor config entity configured to use CKEditor 5.
* @param string $except
* A CKEditor 5 plugin ID to exclude: all enabled plugins other than this
* one are returned.
*
* @return \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[]
* A list of CKEditor 5 plugin definitions keyed by plugin ID.
*/
private function getOtherEnabledPlugins(EditorInterface $text_editor, string $except): array {
$enabled_plugins = $this->pluginManager->getEnabledDefinitions($text_editor);
unset($enabled_plugins[$except]);
return $enabled_plugins;
}
/**
* Gets all disabled CKEditor 5 plugin definitions the user can enable.
*
* @param \Drupal\editor\EditorInterface $text_editor
* A Text Editor config entity configured to use CKEditor 5.
*
* @return \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[]
* A list of CKEditor 5 plugin definitions keyed by plugin ID.
*/
private function getEnableableDisabledPlugins(EditorInterface $text_editor) {
$disabled_plugins = array_diff_key(
$this->pluginManager->getDefinitions(),
$this->pluginManager->getEnabledDefinitions($text_editor)
);
// Only consider plugins that can be explicitly enabled by the user: plugins
// that have a toolbar item and do not have conditions. Those are the only
// plugins that are truly available for the site builder to enable without
// other consequences.
// In the future, we may choose to expand this, but it will require complex
// infrastructure to generate messages that explain which of the conditions
// are already fulfilled and which are not.
$enableable_disabled_plugins = array_filter($disabled_plugins, function (CKEditor5PluginDefinition $definition) {
return $definition->hasToolbarItems() && !$definition->hasConditions();
});
return $enableable_disabled_plugins;
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\Validation\ExecutionContext;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* A constraint may need preceding constraints to not have been violated.
*
* @internal
*/
trait PrecedingConstraintAwareValidatorTrait {
/**
* Checks whether any preceding constraints have been violated.
*
* @param \Symfony\Component\Validator\Constraint $current_constraint
* The constraint currently being validated.
*
* @return bool
* TRUE if any preceding constraints have been violated, FALSE otherwise.
*/
protected function hasViolationsForPrecedingConstraints(Constraint $current_constraint): bool {
assert($this->context instanceof ExecutionContext);
$earlier_constraints = iterator_to_array($this->getPrecedingConstraints($current_constraint));
$earlier_violations = array_filter(
iterator_to_array($this->context->getViolations()),
function (ConstraintViolationInterface $violation) use ($earlier_constraints) {
return in_array($violation->getConstraint(), $earlier_constraints);
}
);
return !empty($earlier_violations);
}
/**
* Gets the constraints preceding the given constraint in the current context.
*
* @param \Symfony\Component\Validator\Constraint $needle
* The constraint to find the preceding constraints for.
*
* @return iterable
* The preceding constraints.
*/
private function getPrecedingConstraints(Constraint $needle): iterable {
assert($this->context instanceof ExecutionContext);
$constraints = $this->context->getMetadata()->getConstraints();
if (!in_array($needle, $constraints)) {
throw new \OutOfBoundsException();
}
foreach ($constraints as $constraint) {
if ($constraint != $needle) {
yield $constraint;
}
}
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* For disallowing Source Editing configuration that allows self-XSS.
*
* @internal
*/
#[Constraint(
id: 'SourceEditingPreventSelfXssConstraint',
label: new TranslatableMarkup('Source Editing should never allow self-XSS.', [], ['context' => 'Validation'])
)]
class SourceEditingPreventSelfXssConstraint extends SymfonyConstraint {
/**
* When Source Editing is configured to allow self-XSS.
*
* @var string
*/
public $message = 'The following tag in the Source Editing "Manually editable HTML tags" field is a security risk: %dangerous_tag.';
}

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\ckeditor5\HTMLRestrictions;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Ensures Source Editing cannot be configured to allow self-XSS.
*
* @internal
*/
class SourceEditingPreventSelfXssConstraintValidator extends ConstraintValidator {
use TextEditorObjectDependentValidatorTrait;
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($value, Constraint $constraint): void {
if (!$constraint instanceof SourceEditingPreventSelfXssConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\SourceEditingPreventSelfXssConstraint');
}
if (empty($value)) {
return;
}
$restrictions = HTMLRestrictions::fromString($value);
// @todo Remove this early return in
// https://www.drupal.org/project/drupal/issues/2820364. It is only
// necessary because CKEditor5ElementConstraintValidator does not run
// before this, which means that this validator cannot assume it receives
// valid values.
if ($restrictions->allowsNothing() || count($restrictions->getAllowedElements()) > 1) {
return;
}
// This validation constraint only validates attributes, not tags; so if all
// attributes are allowed (TRUE) or no attributes are allowed (FALSE),
// return early. Only proceed when some attributes are allowed (an array).
$allowed_elements = $restrictions->getAllowedElements(FALSE);
assert(count($allowed_elements) === 1);
$tag = array_key_first($allowed_elements);
$attribute_restrictions = $allowed_elements[$tag];
if (!is_array($attribute_restrictions)) {
return;
}
$text_editor = $this->createTextEditorObjectFromContext();
$text_format_allowed_elements = HTMLRestrictions::fromTextFormat($text_editor->getFilterFormat())
->getAllowedElements();
// Any XSS-prevention related measures imposed by filter plugins are relayed
// through their ::getHtmlRestrictions() return value. The global attribute
// `*` HTML tag allows attributes to be forbidden.
// @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
// @see \Drupal\ckeditor5\HTMLRestrictions::validateAllowedRestrictionsPhase4()
// @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions()
$forbidden_attributes = [];
if (array_key_exists('*', $text_format_allowed_elements)) {
$forbidden_attributes = array_keys(array_filter($text_format_allowed_elements['*'], function ($attribute_value_restriction, string $attribute_name) {
return $attribute_value_restriction === FALSE;
}, ARRAY_FILTER_USE_BOTH));
}
foreach ($forbidden_attributes as $forbidden_attribute_name) {
// Forbidden attributes not containing wildcards, such as `style`.
if (!self::isWildcardAttributeName($forbidden_attribute_name)) {
if (array_key_exists($forbidden_attribute_name, $attribute_restrictions)) {
$this->context->buildViolation($constraint->message)
->setParameter('%dangerous_tag', $value)
->addViolation();
}
}
// Forbidden attributes containing wildcards such as `on*`.
else {
$regex = self::getRegExForWildCardAttributeName($forbidden_attribute_name);
if (!empty(preg_grep($regex, array_keys($attribute_restrictions)))) {
$this->context->buildViolation($constraint->message)
->setParameter('%dangerous_tag', $value)
->addViolation();
}
}
}
}
/**
* Checks whether the given attribute name contains a wildcard, e.g. `data-*`.
*
* @param string $attribute_name
* The attribute name to check.
*
* @return bool
* Whether the given attribute name contains a wildcard.
*/
private static function isWildcardAttributeName(string $attribute_name): bool {
assert($attribute_name !== '*');
return str_contains($attribute_name, '*');
}
/**
* Computes a regular expression for matching a wildcard attribute name.
*
* @param string $wildcard_attribute_name
* The wildcard attribute name for which to compute a regular expression.
*
* @return string
* The computed regular expression.
*/
private static function getRegExForWildCardAttributeName(string $wildcard_attribute_name): string {
assert(self::isWildcardAttributeName($wildcard_attribute_name));
return '/^' . str_replace('*', '.*', $wildcard_attribute_name) . '$/';
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* For disallowing Source Editing elements already supported by a plugin.
*
* @internal
*/
#[Constraint(
id: 'SourceEditingRedundantTags',
label: new TranslatableMarkup('Source editing should only use otherwise unavailable tags and attributes', [], ['context' => 'Validation'])
)]
class SourceEditingRedundantTagsConstraint extends SymfonyConstraint {
/**
* When a Source Editing element is added that an enabled plugin supports.
*
* @var string
*/
public $enabledPluginsMessage = 'The following @element_type(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: %overlapping_tags.';
/**
* When a Source Editing element is added that an enabled plugin supports.
*
* @var string
*/
public $enabledPluginsOptionalMessage = 'The following @element_type(s) can optionally be supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: %overlapping_tags.';
/**
* When a Source Editing element is added that a disabled plugin supports.
*
* @var string
*/
public $availablePluginsMessage = 'The following @element_type(s) are already supported by available plugins and should not be added to the Source Editing "Manually editable HTML tags" field. Instead, enable the following plugins to support these @element_types: %overlapping_tags.';
}

View File

@ -0,0 +1,220 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
// cspell:ignore enableable
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Ensures tags already available via plugin are not be added to Source Editing.
*
* @internal
*/
class SourceEditingRedundantTagsConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PluginManagerDependentValidatorTrait;
use StringTranslationTrait;
use TextEditorObjectDependentValidatorTrait;
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($value, Constraint $constraint): void {
if (!$constraint instanceof SourceEditingRedundantTagsConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\SourceEditingRedundantTagsConstraint');
}
if (empty($value)) {
return;
}
$text_editor = $this->createTextEditorObjectFromContext();
$other_enabled_plugins = $this->getOtherEnabledPlugins($text_editor, 'ckeditor5_sourceEditing');
$enableable_disabled_plugins = $this->getEnableableDisabledPlugins($text_editor);
// An array of tags enabled by every plugin other than Source Editing.
$enabled_plugin_elements = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($other_enabled_plugins), $text_editor, FALSE));
$enabled_plugin_elements_optional = (new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($other_enabled_plugins))))
->diff($enabled_plugin_elements);
$disabled_plugin_elements = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($enableable_disabled_plugins), $text_editor, FALSE));
$enabled_plugin_plain_tags = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($other_enabled_plugins), $text_editor, FALSE, TRUE));
$disabled_plugin_plain_tags = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($enableable_disabled_plugins), $text_editor, FALSE, TRUE));
// The single element for which source editing is enabled, which we are
// checking now.
$source_enabled_element = HTMLRestrictions::fromString($value);
// Test for empty allowed elements with resolved wildcards since, for the
// purposes of this validator, HTML restrictions containing only wildcards
// should be considered empty.
// @todo Remove this early return in
// https://www.drupal.org/project/drupal/issues/2820364. It is only
// necessary because CKEditor5ElementConstraintValidator does not run
// before this, which means that this validator cannot assume it receives
// valid values.
if (count($source_enabled_element->getAllowedElements()) !== 1) {
return;
}
$enabled_plugin_overlap = $enabled_plugin_elements->intersect($source_enabled_element);
$enabled_plugin_optional_overlap = $enabled_plugin_elements_optional->intersect($source_enabled_element);
$disabled_plugin_overlap = $disabled_plugin_elements
// Merge the enabled plugins' elements, to allow wildcards to be resolved.
->merge($enabled_plugin_elements)
// Compute the overlap.
->intersect($source_enabled_element)
// Exclude the enabled plugin tags from the overlap; we merged these
// previously to be able to resolve wildcards.
->diff($enabled_plugin_overlap);
foreach ([$enabled_plugin_overlap, $enabled_plugin_optional_overlap, $disabled_plugin_overlap] as $overlap) {
$checking_enabled = $overlap === $enabled_plugin_overlap || $overlap === $enabled_plugin_optional_overlap;
if (!$overlap->allowsNothing()) {
$plugins_to_check_against = $checking_enabled ? $other_enabled_plugins : $enableable_disabled_plugins;
$plain_tags_to_check_against = $checking_enabled ? $enabled_plugin_plain_tags : $disabled_plugin_plain_tags;
$tags_plugin_report = $this->pluginsSupplyingTagsMessage($overlap, $plugins_to_check_against, $enabled_plugin_elements);
$message = match($overlap) {
$enabled_plugin_overlap => $constraint->enabledPluginsMessage,
$enabled_plugin_optional_overlap => $constraint->enabledPluginsOptionalMessage,
$disabled_plugin_overlap => $constraint->availablePluginsMessage,
};
// Determine which element type is relevant for the violation message.
assert(count($overlap->getAllowedElements(FALSE)) === 1);
$overlap_tag = array_keys($overlap->getAllowedElements(FALSE))[0];
$is_attr_overlap = self::tagHasAttributeRestrictions($overlap, $overlap_tag);
// If one or more attributes (and all of the allowed attribute values)
// of the HTML elements being configured to be edited via the Source
// Editing plugin is supported by a CKEditor 5 plugin, complain. But if
// an attribute overlap is detected due to a wildcard attribute, then do
// not generate a violation message.
// For example:
// - value `<ol start foo>` triggers a violation because `<ol start>` is
// supported by the `ckeditor5_list` plugin
// - value `<img data-*>` does NOT trigger a violation because only
// concrete `data-`-attributes are supported by the
// `ckeditor5_imageUpload`, `ckeditor5_imageCaption` and
// `ckeditor5_imageAlign` plugins
if ($is_attr_overlap && $source_enabled_element->diff($overlap)->getAllowedElements(FALSE) == $source_enabled_element->getAllowedElements(FALSE)) {
continue;
}
// If there is overlap, but the plain tag is not supported in the
// overlap, exit this iteration without generating a violation message.
// Essentially when assessing a particular value (for example `<span>`),
// CKEditor 5 plugins supporting only the creation of attributes on this
// tag (`<span lang>`) and not supporting the creation of this plain tag
// (`<span>` explicitly listed in their elements) can trigger a
// violation.
if (!$is_attr_overlap) {
$value_is_plain_tag_only = !self::tagHasAttributeRestrictions($source_enabled_element, $overlap_tag);
// When the configured value is a plain tag (`<tag>`): do not generate
// a violation message if this tag cannot be created by any CKEditor 5
// plugin.
if ($value_is_plain_tag_only && $overlap->intersect($plain_tags_to_check_against)->allowsNothing()) {
continue;
}
// When the configured value is not a plain tag (so the value has the
// shape `<tag attr>`, not `<tag>`): do not generate a violation
// message if the tag can already be created by another CKEditor 5
// plugin: this is just adding the ability to set more attributes.
// Note: this does not check whether the plain tag can indeed be
// created, validating that is out of scope for this validator.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraintValidator::checkAllHtmlTagsAreCreatable()
if (!$value_is_plain_tag_only) {
continue;
}
}
// If we reach this, it means the entirety (so not just the tag but also
// the attributes, and not just some of the attribute values, but all of
// them) of the HTML elements being configured to be edited via the
// Source Editing plugin's 'allowed_tags' configuration is supported by
// a CKEditor 5 plugin. This earns a violation.
$this->context->buildViolation($message)
->setParameter('@element_type', $is_attr_overlap
? $this->t('attribute')
: $this->t('tag')
)
->setParameter('%overlapping_tags', $tags_plugin_report)
->addViolation();
}
}
}
/**
* Inspects whether the given tag has attribute restrictions.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $r
* A set of HTML restrictions to inspect.
* @param string $tag_name
* The tag to check for attribute restrictions in $r.
*
* @return bool
* TRUE if the given tag has attribute restrictions, FALSE otherwise.
*/
private static function tagHasAttributeRestrictions(HTMLRestrictions $r, string $tag_name): bool {
$all_elements = $r->getAllowedElements(FALSE);
assert(isset($all_elements[$tag_name]));
return is_array($r->getAllowedElements(FALSE)[$tag_name]);
}
/**
* Creates a message listing plugins and the overlapping tags they provide.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $overlap
* An array of overlapping tags.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[] $plugin_definitions
* An array of plugin definitions where overlap was found.
* @param \Drupal\ckeditor5\HTMLRestrictions $enabled_plugin_restrictions
* The set of HTML restrictions for all already enabled CKEditor 5 plugins.
*
* @return string
* A list of plugins that provide the overlapping tags.
*/
private function pluginsSupplyingTagsMessage(HTMLRestrictions $overlap, array $plugin_definitions, HTMLRestrictions $enabled_plugin_restrictions): string {
$message_array = [];
$message_string = '';
foreach ($plugin_definitions as $definition) {
if ($definition->hasElements()) {
$plugin_capabilities = HTMLRestrictions::fromString(implode(' ', $definition->getElements()));
// If this plugin supports wildcards, resolve them.
if (!$plugin_capabilities->getWildcardSubset()->allowsNothing()) {
$plugin_capabilities = $plugin_capabilities
// Resolve wildcards.
->merge($enabled_plugin_restrictions)
->diff($enabled_plugin_restrictions);
}
// Skip plugins that provide a subset, only mention the plugin that
// actually provides the overlap.
// For example: avoid listing the image alignment/captioning plugins
// when matching `<img src>`; only lists the main image plugin.
if (!$overlap->diff($plugin_capabilities)->allowsNothing()) {
continue;
}
foreach ($plugin_capabilities->intersect($overlap)->toCKEditor5ElementsArray() as $element) {
$message_array[(string) $definition->label()][] = $element;
}
}
}
foreach ($message_array as $plugin_label => $tag_list) {
$tags_string = implode(', ', $tag_list);
$message_string .= "$plugin_label ($tags_string), ";
}
return trim($message_string, ' ,');
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Styles can only be specified for HTML5 tags and extra classes.
*
* @internal
*/
#[Constraint(
id: 'StyleSensibleElement',
label: new TranslatableMarkup('Styles can only be specified for already supported tags.', [], ['context' => 'Validation'])
)]
class StyleSensibleElementConstraint extends SymfonyConstraint {
/**
* When a style is defined for a non-HTML5 tag.
*
* @var string
*/
public $nonHtml5TagMessage = 'A style can only be specified for an HTML 5 tag. <code>@tag</code> is not an HTML5 tag.';
/**
* When a Style is defined with classes supported by an enabled plugin.
*
* @var string
*/
public $conflictingEnabledPluginMessage = 'A style must only specify classes not supported by other plugins. The <code>@classes</code> classes on <code>@tag</code> are already supported by the enabled %plugin plugin.';
/**
* When a Style is defined with classes supported by a disabled plugin.
*
* @var string
*/
public $conflictingDisabledPluginMessage = 'A style must only specify classes not supported by other plugins. The <code>@classes</code> classes on <code>@tag</code> are supported by the %plugin plugin. Remove this style and enable that plugin instead.';
/**
* When a Style is defined for a plugin that does not yet support Style.
*
* @var string
*/
public $unsupportedTagMessage = 'The <code>@tag</code> tag is not yet supported by the Style plugin.';
}

View File

@ -0,0 +1,227 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
// cspell:ignore enableable
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Styles can only be specified for HTML5 tags and extra classes.
*
* @internal
*/
class StyleSensibleElementConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PrecedingConstraintAwareValidatorTrait;
use PluginManagerDependentValidatorTrait;
use TextEditorObjectDependentValidatorTrait;
/**
* Tags whose plugins are known to not yet integrate with the Style plugin.
*
* To prevent the user from configuring the Style plugin and reasonably
* expecting it to work correctly for tags of plugins that are known to
* yet integrate with the Style plugin, generate a validation error for these.
*/
protected const KNOWN_UNSUPPORTED_TAGS = [
// @see https://www.drupal.org/project/drupal/issues/3117172
'<drupal-media>',
// @see https://github.com/ckeditor/ckeditor5/issues/13778
'<img>',
// @see https://github.com/ckeditor/ckeditor5/blob/39ad30090ead9dd2d54c3ac53d7f446ade9fd8ce/packages/ckeditor5-html-support/src/schemadefinitions.ts#L12-L50
'<keygen>',
'<applet>',
'<basefont>',
'<isindex>',
'<hr>',
'<br>',
'<area>',
'<command>',
'<map>',
'<wbr>',
'<colgroup>',
'<col>',
'<datalist>',
'<track>',
'<source>',
'<option>',
'<param>',
'<optgroup>',
'<link>',
'<noscript>',
];
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($element, Constraint $constraint): void {
if (!$constraint instanceof StyleSensibleElementConstraint) {
throw new UnexpectedTypeException($constraint, StyleSensibleElementConstraint::class);
}
// The preceding constraints (in this case: CKEditor5Element) must be valid.
if ($this->hasViolationsForPrecedingConstraints($constraint)) {
return;
}
$text_editor = $this->createTextEditorObjectFromContext();
// The single tag for which a style is specified, which we are checking now.
$style_element = HTMLRestrictions::fromString($element);
assert(count($style_element->getAllowedElements()) === 1);
[$tag, $classes] = Style::getTagAndClasses($style_element);
// Ensure the tag is in the range supported by the Style plugin.
$superset = HTMLRestrictions::fromString('<$any-html5-element class>');
$supported_range = $superset->merge($style_element->extractPlainTagsSubset());
if (!$style_element->diff($supported_range)->allowsNothing()) {
$this->context->buildViolation($constraint->nonHtml5TagMessage)
->setParameter('@tag', sprintf("<%s>", $tag))
->addViolation();
return;
}
// Get the list of tags enabled by every plugin other than Style.
$other_enabled_plugins = $this->getOtherEnabledPlugins($text_editor, 'ckeditor5_style');
$enableable_disabled_plugins = $this->getEnableableDisabledPlugins($text_editor);
$other_enabled_plugin_elements = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($other_enabled_plugins), $text_editor, FALSE));
$disabled_plugin_elements = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($enableable_disabled_plugins), $text_editor, FALSE));
// Next, validate that the classes specified for this style are not
// supported by an enabled plugin.
if (self::intersectionWithClasses($style_element, $other_enabled_plugin_elements)) {
$this->context->buildViolation($constraint->conflictingEnabledPluginMessage)
->setParameter('@tag', sprintf("<%s>", $tag))
->setParameter('@classes', implode(", ", $classes))
->setParameter('%plugin', $this->findStyleConflictingPluginLabel($style_element))
->addViolation();
}
// Next, validate that the classes specified for this style are not
// supported by a disabled plugin.
elseif (self::intersectionWithClasses($style_element, $disabled_plugin_elements)) {
$this->context->buildViolation($constraint->conflictingDisabledPluginMessage)
->setParameter('@tag', sprintf("<%s>", $tag))
->setParameter('@classes', implode(", ", $classes))
->setParameter('%plugin', $this->findStyleConflictingPluginLabel($style_element))
->addViolation();
}
// Finally, while the configuration is technically valid if this point was
// reached, there are some known compatibility issues. Inform the user that
// for that reason, this configuration must be considered invalid.
$unsupported = $style_element->intersect(HTMLRestrictions::fromString(implode(' ', static::KNOWN_UNSUPPORTED_TAGS)));
if (!$unsupported->allowsNothing()) {
$this->context->buildViolation($constraint->unsupportedTagMessage)
->setParameter('@tag', sprintf("<%s>", $tag))
->addViolation();
}
}
/**
* Checks if there is an intersection on allowed 'class' attribute values.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $a
* One set of HTML restrictions.
* @param \Drupal\ckeditor5\HTMLRestrictions $b
* Another set of HTML restrictions.
*
* @return bool
* Whether there is an intersection.
*/
private static function intersectionWithClasses(HTMLRestrictions $a, HTMLRestrictions $b): bool {
// Compute the intersection, but first resolve wildcards, by merging
// tags of the other operand. Because only tags are merged, this cannot
// introduce a 'class' attribute intersection.
// For example: a plugin may support `<$text-container class="foo">`. On its
// own that would not trigger an intersection, but when resolved into
// concrete tags it could.
$tags_from_a = array_diff(array_keys($a->getConcreteSubset()->getAllowedElements()), ['*']);
$tags_from_b = array_diff(array_keys($b->getConcreteSubset()->getAllowedElements()), ['*']);
$a = $a->merge(new HTMLRestrictions(array_fill_keys($tags_from_b, FALSE)));
$b = $b->merge(new HTMLRestrictions(array_fill_keys($tags_from_a, FALSE)));
// When a plugin allows all classes on a tag, we assume there is no
// problem with having the style plugin adding classes to that element.
// When allowing all classes we don't expect a specific user experience
// so adding a class through a plugin or the style plugin is the same.
$b_without_class_wildcard = $b->getAllowedElements();
foreach ($b_without_class_wildcard as $allowedElement => $config) {
// When all classes are allowed, remove the configuration so that
// the intersect below does not include classes.
if (!empty($config['class']) && $config['class'] === TRUE) {
unset($b_without_class_wildcard[$allowedElement]['class']);
}
// HTMLRestrictions does not accept a tag with an empty array, make sure
// to remove them here.
if (empty($b_without_class_wildcard[$allowedElement])) {
unset($b_without_class_wildcard[$allowedElement]);
}
}
$intersection = $a->intersect(new HTMLRestrictions($b_without_class_wildcard));
// Leverage the "GHS configuration" representation to easily find whether
// there is an intersection for classes. Other implementations are possible.
$intersection_as_ghs_config = $intersection->toGeneralHtmlSupportConfig();
$ghs_config_classes = array_column($intersection_as_ghs_config, 'classes');
return !empty($ghs_config_classes);
}
/**
* Finds the plugin with elements that conflict with the style element.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $needle
* A style definition element: a single tag, plus the 'class' attribute,
* plus >=1 allowed 'class' attribute values.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The label of the plugin that is conflicting with this style.
*
* @throws \OutOfBoundsException
* When a $needle is provided which does not exist among the other plugins.
*/
private function findStyleConflictingPluginLabel(HTMLRestrictions $needle): TranslatableMarkup {
foreach ($this->pluginManager->getDefinitions() as $id => $definition) {
// We're looking to find the other plugin, not this one.
if ($id === 'ckeditor5_style') {
continue;
}
assert($definition instanceof CKEditor5PluginDefinition);
if (!$definition->hasElements()) {
continue;
}
$haystack = HTMLRestrictions::fromString(implode($definition->getElements()));
if ($id === 'ckeditor5_sourceEditing') {
// The Source Editing plugin's allowed elements are based on stored
// config. This differs from all other plugins, which establish allowed
// elements as part of their definition. Because of this, the $haystack
// is calculated differently for Source Editing.
$text_editor = $this->createTextEditorObjectFromContext();
$editor_plugins = $text_editor->getSettings()['plugins'];
if (!empty($editor_plugins['ckeditor5_sourceEditing'])) {
$source_tags = $editor_plugins['ckeditor5_sourceEditing']['allowed_tags'];
$haystack = HTMLRestrictions::fromString(implode($source_tags));
}
}
if (self::intersectionWithClasses($needle, $haystack)) {
return $definition->label();
}
}
throw new \OutOfBoundsException();
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\filter\FilterFormatInterface;
/**
* Some CKEditor 5 constraint validators need a Text Editor object.
*/
trait TextEditorObjectDependentValidatorTrait {
/**
* Creates a text editor object from the execution context.
*
* Works both for an individual text editor config entity and a pair.
*
* @return \Drupal\editor\EditorInterface
* A text editor object, with the text format pre-populated.
*/
private function createTextEditorObjectFromContext(): EditorInterface {
if ($this->context->getRoot()->getDataDefinition()->getDataType() === 'ckeditor5_valid_pair__format_and_editor') {
$text_format = FilterFormat::create([
'filters' => $this->context->getRoot()->get('filters')->toArray(),
]);
}
else {
assert(in_array($this->context->getRoot()->getDataDefinition()->getDataType(), ['editor.editor.*', 'entity:editor'], TRUE));
$text_format = FilterFormat::load($this->context->getRoot()->get('format')->getValue());
// This validator must not complain about a missing text format.
// @see \Drupal\Tests\editor\Kernel\EditorValidationTest::testInvalidFormat()
if ($text_format === NULL) {
$text_format = FilterFormat::create([]);
}
}
assert($text_format instanceof FilterFormatInterface);
$text_editor = Editor::create([
'editor' => 'ckeditor5',
'settings' => $this->context->getRoot()->get('settings')->toArray(),
'image_upload' => $this->context->getRoot()->get('image_upload')->toArray(),
// Specify `filterFormat` to ensure that the generated Editor config
// entity object already has the $filterFormat property set, to prevent
// calls to Editor::hasAssociatedFilterFormat() and
// Editor::getFilterFormat() from loading the FilterFormat from storage.
// As far as this validation constraint validator is concerned, the
// concrete FilterFormat entity ID does not matter, all that matters is
// its filter configuration. Those exist in $text_format.
'filterFormat' => $text_format,
]);
assert($text_editor instanceof EditorInterface);
return $text_editor;
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* A (placed) CKEditor 5 toolbar item's conditions must be met.
*
* @internal
*/
#[Constraint(
id: 'CKEditor5ToolbarItemConditionsMet',
label: new TranslatableMarkup('CKEditor 5 toolbar item conditions must be met', [], ['context' => 'Validation'])
)]
class ToolbarItemConditionsMetConstraint extends SymfonyConstraint {
/**
* The violation message when the required image upload status is not set.
*
* @var string
*/
public $imageUploadStatusRequiredMessage = 'The %toolbar_item toolbar item requires image uploads to be enabled.';
/**
* The violation message when a required filter is missing.
*
* @var string
*/
public $filterRequiredMessage = 'The %toolbar_item toolbar item requires the %filter filter to be enabled.';
/**
* The violation message when 1 required plugin is missing.
*
* @var string
*/
public $singleMissingRequiredPluginMessage = 'The %toolbar_item toolbar item requires the %plugin plugin to be enabled.';
/**
* The violation message when >1 required plugin is missing.
*
* @var string
*/
public $multipleMissingRequiredPluginMessage = 'The %toolbar_item toolbar item requires the %plugins plugins to be enabled.';
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Toolbar item conditions met constraint validator.
*
* @internal
*/
class ToolbarItemConditionsMetConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PluginManagerDependentValidatorTrait;
use TextEditorObjectDependentValidatorTrait;
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($toolbar_item, Constraint $constraint): void {
if (!$constraint instanceof ToolbarItemConditionsMetConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\ToolbarItemConditionsMetConstraint');
}
try {
$definition = $this->findDefinitionForToolbarItem($toolbar_item);
}
catch (\OutOfBoundsException) {
// No plugin definition found for this toolbar item. It's the
// responsibility of another validation constraint to raise this problem.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\ToolbarItemConstraint
return;
}
// If there are no conditions, there is nothing to validate.
if (!$definition->hasConditions()) {
return;
}
$toolbar_item_label = $definition->getToolbarItems()[$toolbar_item]['label'];
$text_editor = $this->createTextEditorObjectFromContext();
$conditions = $definition->getConditions();
foreach ($conditions as $condition_type => $required_value) {
switch ($condition_type) {
case 'toolbarItem':
// Nothing to validate.
break;
case 'imageUploadStatus':
$image_upload_settings = $text_editor->getImageUploadSettings();
if (!isset($image_upload_settings['status']) || (bool) $image_upload_settings['status'] !== TRUE) {
$this->context->buildViolation($constraint->imageUploadStatusRequiredMessage)
->setParameter('%toolbar_item', (string) $toolbar_item_label)
->setInvalidValue($toolbar_item)
->addViolation();
}
break;
case 'filter':
$filters = $text_editor->getFilterFormat()->filters();
if (!$filters->has($required_value) || !$filters->get($required_value)->status) {
$filter_label = $filters->has($required_value)
? $filters->get($required_value)->getLabel()
: $required_value;
$this->context->buildViolation($constraint->filterRequiredMessage)
->setParameter('%toolbar_item', (string) $toolbar_item_label)
->setParameter('%filter', (string) $filter_label)
->setInvalidValue($toolbar_item)
->addViolation();
}
break;
case 'plugins':
$enabled_definitions = $this->pluginManager->getEnabledDefinitions($text_editor);
if (!array_key_exists($definition->id(), $enabled_definitions)) {
$required_plugin_ids = $definition->getConditions()['plugins'];
$missing_plugin_ids = array_diff($required_plugin_ids, array_keys($enabled_definitions));
$all_plugins = $this->pluginManager->getDefinitions();
$missing_plugin_labels = array_map(function (string $plugin_id) use ($all_plugins): TranslatableMarkup {
return !array_key_exists($plugin_id, $all_plugins)
? $plugin_id
: $all_plugins[$plugin_id]->label();
}, $missing_plugin_ids);
if (count($missing_plugin_ids) === 1) {
$message = $constraint->singleMissingRequiredPluginMessage;
$parameter = '%plugin';
}
else {
$message = $constraint->multipleMissingRequiredPluginMessage;
$parameter = '%plugins';
}
$this->context->buildViolation($message)
->setParameter('%toolbar_item', (string) $toolbar_item_label)
->setParameter($parameter, implode(', ', $missing_plugin_labels))
->setInvalidValue($toolbar_item)
->addViolation();
}
break;
}
}
}
/**
* Searches for CKEditor 5 plugin that provides a given toolbar item.
*
* @param string $toolbar_item
* The toolbar item to be searched for within plugin definitions.
*
* @return \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition
* The corresponding plugin definition.
*
* @throws \OutOfBoundsException
*/
protected function findDefinitionForToolbarItem(string $toolbar_item): CKEditor5PluginDefinition {
$definitions = $this->pluginManager->getDefinitions();
foreach ($definitions as $definition) {
if (array_key_exists($toolbar_item, $definition->getToolbarItems())) {
return $definition;
}
}
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\ToolbarItemConstraint
throw new \OutOfBoundsException("Toolbar item '$toolbar_item' not found.");
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* A CKEditor 5 toolbar item.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html
*/
#[Constraint(
id: 'CKEditor5ToolbarItem',
label: new TranslatableMarkup('CKEditor 5 toolbar item', [], ['context' => 'Validation'])
)]
class ToolbarItemConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'The provided toolbar item %toolbar_item is not valid.';
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Toolbar item constraint validator.
*
* @internal
*/
class ToolbarItemConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PluginManagerDependentValidatorTrait;
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($toolbar_item, Constraint $constraint): void {
if (!$constraint instanceof ToolbarItemConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\ToolbarItem');
}
if ($toolbar_item === NULL) {
return;
}
if (!static::isValidToolbarItem($toolbar_item)) {
$this->context->buildViolation($constraint->message)
->setParameter('%toolbar_item', $toolbar_item)
->setInvalidValue($toolbar_item)
->addViolation();
}
}
/**
* Validates the given toolbar item.
*
* @param string $toolbar_item
* A toolbar item as expected by CKEditor 5.
*
* @return bool
* Whether the given toolbar item is valid or not.
*/
protected function isValidToolbarItem(string $toolbar_item): bool {
// Special case: the toolbar group separator.
// @see https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#separating-toolbar-items
if ($toolbar_item === '|') {
return TRUE;
}
// Special case: the breakpoint separator.
// @see https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#explicit-wrapping-breakpoint
if ($toolbar_item === '-') {
return TRUE;
}
$available_toolbar_items = array_keys($this->pluginManager->getToolbarItems());
return in_array($toolbar_item, $available_toolbar_items, TRUE);
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* A CKEditor 5 toolbar item.
*
* @internal
*/
#[Constraint(
id: 'CKEditor5ToolbarItemDependencyConstraint',
label: new TranslatableMarkup('CKEditor 5 toolbar item dependency', [], ['context' => 'Validation'])
)]
class ToolbarItemDependencyConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'Depends on %toolbar_item, which is not enabled.';
/**
* The toolbar item that this validation constraint requires to be enabled.
*
* @var null|string
*/
public $toolbarItem = NULL;
/**
* {@inheritdoc}
*/
public function getRequiredOptions(): array {
return ['toolbarItem'];
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Toolbar item dependency constraint validator.
*
* @internal
*/
class ToolbarItemDependencyConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use PluginManagerDependentValidatorTrait;
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($toolbar_item, Constraint $constraint): void {
if (!$constraint instanceof ToolbarItemDependencyConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\ToolbarItemDependency');
}
$toolbar_items = $this->context->getRoot()->get('settings.toolbar.items')->toArray();
if (!in_array($constraint->toolbarItem, $toolbar_items, TRUE)) {
$this->context->buildViolation($constraint->message)
->setParameter('%toolbar_item', $constraint->toolbarItem)
->addViolation();
}
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Uniquely labeled list item constraint.
*
* @internal
*/
#[Constraint(
id: 'UniqueLabelInList',
label: new TranslatableMarkup('Unique label in list', [], ['context' => 'Validation'])
)]
class UniqueLabelInListConstraint extends SymfonyConstraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'The label %label is not unique.';
/**
* The key of the label that this validation constraint should check.
*
* @var null|string
*/
public $labelKey = NULL;
/**
* {@inheritdoc}
*/
public function getRequiredOptions(): array {
return ['labelKey'];
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Uniquely labeled list item constraint validator.
*
* @internal
*/
class UniqueLabelInListConstraintValidator extends ConstraintValidator {
/**
* {@inheritdoc}
*
* @throws \Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown when the given constraint is not supported by this validator.
*/
public function validate($list, Constraint $constraint): void {
if (!$constraint instanceof UniqueLabelInListConstraint) {
throw new UnexpectedTypeException($constraint, UniqueLabelInListConstraint::class);
}
$labels = array_column($list, $constraint->labelKey);
$label_frequencies = array_count_values($labels);
foreach ($label_frequencies as $label => $frequency) {
if ($frequency > 1) {
$this->context->buildViolation($constraint->message)
->setParameter('%label', $label)
->addViolation();
}
}
}
}

View File

@ -0,0 +1,882 @@
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\FilterFormatInterface;
use Psr\Log\LoggerInterface;
/**
* Generates CKEditor 5 settings for existing text editors/formats.
*
* @internal
* This class may change at any time. It is not for use outside this module.
*/
final class SmartDefaultSettings {
use StringTranslationTrait;
/**
* The CKEditor 5 plugin manager.
*
* @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
*/
protected $pluginManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Constructs a SmartDefaultSettings object.
*
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface $plugin_manager
* The CKEditor 5 plugin manager.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
*/
public function __construct(CKEditor5PluginManagerInterface $plugin_manager, LoggerInterface $logger, ModuleHandlerInterface $module_handler, AccountInterface $current_user) {
$this->pluginManager = $plugin_manager;
$this->logger = $logger;
$this->moduleHandler = $module_handler;
$this->currentUser = $current_user;
}
/**
* Computes the closest equivalent settings for switching to CKEditor 5.
*
* @param \Drupal\editor\EditorInterface|null $text_editor
* The editor being reconfigured for CKEditor 5; infer the settings based on
* the HTML restrictions.
* @param \Drupal\filter\FilterFormatInterface $text_format
* The text format for which to compute smart default settings.
*
* @return array
* An array with two values:
* 1. The cloned config entity with settings modified for CKEditor 5 … or a
* completely new config entity if this text format did not yet have one.
* 2. Messages explaining what conclusions were reached.
*
* @throws \InvalidArgumentException
* Thrown when computing smart default settings for a new text format, or
* when the given text editor and format do not form a pair.
*/
public function computeSmartDefaultSettings(?EditorInterface $text_editor, FilterFormatInterface $text_format): array {
if ($text_format->isNew()) {
throw new \InvalidArgumentException('Smart default settings can only be computed when there is a pre-existing text format.');
}
if ($text_editor && $text_editor->id() !== $text_format->id()) {
throw new \InvalidArgumentException('The given text editor and text format must form a pair.');
}
$messages = [];
// Ensure that unsaved changes to the text format object are also respected.
if ($text_editor) {
// Overwrite the Editor config entity object's $filterFormat property, to
// prevent calls to Editor::hasAssociatedFilterFormat() and
// Editor::getFilterFormat() from loading the FilterFormat from storage.
// @todo Remove in https://www.drupal.org/project/drupal/issues/3231347.
$reflector = new \ReflectionObject($text_editor);
$property = $reflector->getProperty('filterFormat');
$property->setValue($text_editor, $text_format);
}
// When there is a pre-existing text editor, pass that. Otherwise, generate
// an empty shell of a text editor config entity — this will then
// automatically get the default CKEditor 5 settings.
// @todo Update after https://www.drupal.org/project/drupal/issues/3226673.
/** @var \Drupal\editor\Entity\Editor $editor */
$editor = $text_editor !== NULL
? clone $text_editor
: Editor::create([
'format' => $text_format->id(),
// @see \Drupal\editor\Entity\Editor::__construct()
// @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
'editor' => 'ckeditor5',
]);
$editor->setEditor('ckeditor5');
$source_editing_additions = HTMLRestrictions::emptySet();
// Compute the appropriate settings based on the HTML restrictions of the
// text format.
$old_editor = $editor->id() ? Editor::load($editor->id()) : NULL;
$old_editor_restrictions = $old_editor ? HTMLRestrictions::fromTextFormat($old_editor->getFilterFormat()) : HTMLRestrictions::emptySet();
// @todo Remove in https://www.drupal.org/project/drupal/issues/3245351
if ($old_editor) {
// When switching from another text editor, use CKEditor 5's default
// settings.
// @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
$editor->setSettings(Editor::create(['editor' => 'ckeditor5'])->getSettings());
$editor->setImageUploadSettings($old_editor->getImageUploadSettings());
}
// Add toolbar items based on HTML tags and attributes.
// NOTE: Helper updates $editor->settings by reference and returns info for
// the message.
$result = $this->addToolbarItemsToMatchHtmlElementsInFormat($text_format, $editor);
if ($result !== NULL) {
[$enabling_message_content, $enabled_for_attributes_message_content, $missing, $plugins_enabled] = $result;
// Distinguish between unsupported elements covering only tags or not.
$missing_attributes = new HTMLRestrictions(array_filter($missing->getAllowedElements()));
$unsupported = $missing->diff($missing_attributes);
if ($enabling_message_content) {
$this->logger->info('The CKEditor 5 migration enabled the following plugins to support tags that are allowed by the %text_format text format: %enabling_message_content. The text format must be saved to make these changes active.',
[
'%text_format' => $editor->getFilterFormat()->get('name'),
'%enabling_message_content' => $enabling_message_content,
]
);
}
// Warn user about unsupported tags.
if (!$unsupported->allowsNothing()) {
$this->addTagsToSourceEditing($editor, $unsupported);
$source_editing_additions = $source_editing_additions->merge($unsupported);
$this->logger->info("The following tags were permitted by the %text_format text format's filter configuration, but no plugin was available that supports them. To ensure the tags remain supported by this text format, the following were added to the Source Editing plugin's <em>Manually editable HTML tags</em>: @unsupported_string. The text format must be saved to make these changes active.", [
'%text_format' => $editor->getFilterFormat()->get('name'),
'@unsupported_string' => $unsupported->toFilterHtmlAllowedTagsString(),
]);
}
if ($enabled_for_attributes_message_content) {
$this->logger->info('The CKEditor 5 migration process enabled the following plugins to support specific attributes that are allowed by the %text_format text format: %enabled_for_attributes_message_content.',
[
'%text_format' => $editor->getFilterFormat()->get('name'),
'%enabled_for_attributes_message_content' => $enabled_for_attributes_message_content,
],
);
}
// Warn user about supported tags but missing attributes.
if (!$missing_attributes->allowsNothing()) {
$this->addTagsToSourceEditing($editor, $missing_attributes);
$source_editing_additions = $source_editing_additions->merge($missing_attributes);
$this->logger->info("As part of migrating to CKEditor 5, it was found that the %text_format text format's HTML filters includes plugins that support the following tags, but not some of their attributes. To ensure these attributes remain supported, the following were added to the Source Editing plugin's <em>Manually editable HTML tags</em>: @missing_attributes. The text format must be saved to make these changes active.", [
'%text_format' => $editor->getFilterFormat()->get('name'),
'@missing_attributes' => $missing_attributes->toFilterHtmlAllowedTagsString(),
]);
}
}
$has_html_restrictions = $editor->getFilterFormat()->filters('filter_html')->status;
$missing_fundamental_tags = HTMLRestrictions::emptySet();
if ($has_html_restrictions) {
$fundamental = new HTMLRestrictions($this->pluginManager->getProvidedElements([
'ckeditor5_essentials',
'ckeditor5_paragraph',
]));
$filter_html_restrictions = HTMLRestrictions::fromTextFormat($editor->getFilterFormat());
$missing_fundamental_tags = $fundamental->diff($filter_html_restrictions);
if (!$missing_fundamental_tags->allowsNothing()) {
$editor->getFilterFormat()->setFilterConfig('filter_html', $filter_html_restrictions->merge($fundamental)->getAllowedElements());
$this->logger->warning("As part of migrating the %text_format text format to CKEditor 5, the following tag(s) were added to <em>Limit allowed HTML tags and correct faulty HTML</em>, because they are needed to provide fundamental CKEditor 5 functionality : @missing_tags. The text format must be saved to make these changes active.", [
'%text_format' => $editor->getFilterFormat()->get('name'),
'@missing_tags' => $missing_fundamental_tags->toFilterHtmlAllowedTagsString(),
]);
}
}
// Finally: for all enabled plugins, find the ones that are configurable,
// and add their default settings. For enabled plugins with element subsets,
// compute the appropriate settings to achieve the subset that matches the
// original text format restrictions.
$this->addDefaultSettingsForEnabledConfigurablePlugins($editor);
if ($has_html_restrictions) {
// Determine what tags/attributes are allowed in this text format that
// were not allowed previous to the switch.
$allowed_by_new_plugin_config = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($this->pluginManager->getEnabledDefinitions($editor)), $editor));
$surplus_tags_attributes = $allowed_by_new_plugin_config->diff($old_editor_restrictions)->diff($missing_fundamental_tags);
$attributes_to_tag = [];
$added_tags = [];
if (!$surplus_tags_attributes->allowsNothing()) {
$surplus_elements = $surplus_tags_attributes->getAllowedElements();
$added_tags = array_diff_key($surplus_elements, $old_editor_restrictions->getAllowedElements());
foreach ($surplus_elements as $tag => $attributes) {
$the_attributes = is_array($attributes) ? $attributes : [];
foreach ($the_attributes as $attribute_name => $enabled) {
if ($enabled) {
$attributes_to_tag[$attribute_name][] = $tag;
}
}
}
}
$help_enabled = $this->moduleHandler->moduleExists('help');
$can_access_dblog = ($this->currentUser->hasPermission('access site reports') && $this->moduleHandler->moduleExists('dblog'));
if (!empty($plugins_enabled) || !$source_editing_additions->allowsNothing()) {
$beginning = $help_enabled ?
$this->t('To maintain the capabilities of this text format, <a target="_blank" href=":ck_migration_url">the CKEditor 5 migration</a> did the following:', [
':ck_migration_url' => Url::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'migration-settings'])->toString(),
]) :
$this->t('To maintain the capabilities of this text format, the CKEditor 5 migration did the following:');
$plugin_info = !empty($plugins_enabled) ?
$this->t('Enabled these plugins: (%plugins).', [
'%plugins' => implode(', ', $plugins_enabled),
]) : '';
$source_editing_info = '';
if (!$source_editing_additions->allowsNothing()) {
$source_editing_info = $help_enabled ?
$this->t('Added these tags/attributes to the Source Editing Plugin\'s <a target="_blank" href=":source_edit_url">Manually editable HTML tags</a> setting: @tag_list',
[
'@tag_list' => $source_editing_additions->toFilterHtmlAllowedTagsString(),
':source_edit_url' => Url::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'source-editing'])->toString(),
]) :
$this->t("Added these tags/attributes to the Source Editing Plugin's Manually editable HTML tags setting: @tag_list", ['@tag_list' => $source_editing_additions->toFilterHtmlAllowedTagsString()]);
}
$end = $can_access_dblog ?
$this->t('Additional details are available <a target="_blank" href=":dblog_url">in your logs</a>.',
[
':dblog_url' => Url::fromRoute('dblog.overview')
->setOption('query', ['type[]' => 'ckeditor5'])
->toString(),
]
) :
$this->t('Additional details are available in your logs.');
$messages[MessengerInterface::TYPE_STATUS][] = new FormattableMarkup('@beginning @plugin_info @source_editing_info. @end', [
'@beginning' => $beginning,
'@plugin_info' => $plugin_info,
'@source_editing_info' => $source_editing_info,
'@end' => $end,
]);
}
// Generate warning for:
// - The addition of <p>/<br> due to them being fundamental tags.
// - The addition of other tags/attributes previously unsupported by the
// format.
if (!$missing_fundamental_tags->allowsNothing() || !empty($attributes_to_tag) || !empty($added_tags)) {
$beginning = $this->t('Updating to CKEditor 5 added support for some previously unsupported tags/attributes.');
$fundamental_tags = '';
if ($help_enabled && !$missing_fundamental_tags->allowsNothing()) {
$fundamental_tags = $this->formatPlural(count($missing_fundamental_tags->toCKEditor5ElementsArray()),
'The @tag tag was added because it is <a target="_blank" href=":fundamental_tag_link">required by CKEditor 5</a>.',
'The @tag tags were added because they are <a target="_blank" href=":fundamental_tag_link">required by CKEditor 5</a>.',
[
'@tag' => implode(', ', $missing_fundamental_tags->toCKEditor5ElementsArray()),
':fundamental_tag_link' => URL::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'required-tags'])->toString(),
]);
}
elseif (!$missing_fundamental_tags->allowsNothing()) {
$fundamental_tags = $this->formatPlural(count($missing_fundamental_tags->toCKEditor5ElementsArray()),
'The @tag tag was added because it is required by CKEditor 5.',
'The @tag tags were added because they are required by CKEditor 5.',
[
'@tag' => implode(', ', $missing_fundamental_tags->toCKEditor5ElementsArray()),
]);
}
$added_elements_begin = !empty($attributes_to_tag) || !empty($added_tags) ? $this->t('A plugin introduced support for the following:') : '';
$added_elements_tags = !empty($added_tags) ? $this->formatPlural(
count($added_tags),
'The tag %tags;',
'The tags %tags;',
[
'%tags' => implode(', ', array_map(function ($tag_name) {
return "<$tag_name>";
}, array_keys($added_tags))),
]) : '';
$added_elements_attributes = !empty($attributes_to_tag) ? $this->formatPlural(
count($attributes_to_tag),
'This attribute: %attributes;',
'These attributes: %attributes;',
[
'%attributes' => rtrim(array_reduce(array_keys($attributes_to_tag), function ($carry, $item) use ($attributes_to_tag) {
$for_tags = implode(', ', array_map(function ($item) {
return "<$item>";
}, $attributes_to_tag[$item]));
return "$carry $item ({$this->t('for', [], ['context' => 'Ckeditor 5 tag list'])} $for_tags),";
}, ''), " ,"),
]
) : '';
$end = $can_access_dblog ?
$this->t('Additional details are available <a target="_blank" href=":dblog_url">in your logs</a>.',
[
':dblog_url' => Url::fromRoute('dblog.overview')
->setOption('query', ['type[]' => 'ckeditor5'])
->toString(),
]
) :
$this->t('Additional details are available in your logs.');
$messages[MessengerInterface::TYPE_WARNING][] = new FormattableMarkup('@beginning @added_elements_begin @fundamental_tags @added_elements_tags @added_elements_attributes @end',
[
'@beginning' => $beginning,
'@added_elements_begin' => $added_elements_begin,
'@fundamental_tags' => $fundamental_tags,
'@added_elements_tags' => $added_elements_tags,
'@added_elements_attributes' => $added_elements_attributes,
'@end' => $end,
]);
}
}
return [$editor, $messages];
}
private function addTagsToSourceEditing(EditorInterface $editor, HTMLRestrictions $tags): array {
$messages = [];
$settings = $editor->getSettings();
if (!isset($settings['toolbar']['items']) || !in_array('sourceEditing', $settings['toolbar']['items'])) {
$messages[MessengerInterface::TYPE_STATUS][] = $this->t('The <em>Source Editing</em> plugin was enabled to support tags and/or attributes that are not explicitly supported by any available CKEditor 5 plugins.');
// Add the "Source Editing" toolbar item in a new group.
$settings['toolbar']['items'][] = '|';
$settings['toolbar']['items'][] = 'sourceEditing';
}
$allowed_tags_array = $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] ?? [];
$allowed_tags_string = implode(' ', $allowed_tags_array);
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = HTMLRestrictions::fromString($allowed_tags_string)->merge($tags)->toCKEditor5ElementsArray();
$editor->setSettings($settings);
return $messages;
}
/**
* Computes net new needed elements when considering adding the given plugin.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $baseline
* The set of HTML restrictions already supported.
* @param \Drupal\ckeditor5\HTMLRestrictions $needed
* The set of HTML restrictions that are needed, that is: in addition to
* $baseline.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $added_plugin
* The CKEditor 5 plugin that is being evaluated to check if it would meet
* some of the needs.
*
* @return array
* An array containing two values:
* - a set of HTML restrictions that indicates the net new additions that
* are needed
* - a set of HTML restrictions that indicates the surplus additions (these
* are elements that were not needed, but are added by this plugin)
*/
private static function computeNetNewElementsForPlugin(HTMLRestrictions $baseline, HTMLRestrictions $needed, CKEditor5PluginDefinition $added_plugin): array {
$plugin_support = HTMLRestrictions::fromString(implode(' ', $added_plugin->getElements()));
// Do not inspect just $plugin_support, but the union of that with the
// already supported elements: wildcard restrictions will only resolve
// if the concrete tags they support are also present.
$potential_future = $baseline->merge($plugin_support);
// This is the heart of the operation: intersect the potential future
// with what we need to achieve, then subtract what is already
// supported. This yields the net new elements.
$net_new = $potential_future->intersect($needed)->diff($baseline);
// But … we may compute too many.
$surplus_additions = $potential_future->diff($needed)->diff($baseline);
return [$net_new, $surplus_additions];
}
/**
* Computes a score for the given surplus compared to the given need.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $surplus
* A surplus compared to what is needed.
* @param \Drupal\ckeditor5\HTMLRestrictions $needed
* Exactly what is needed.
*
* @return int
* A surplus score. Lower is better. Scores are a positive integer.
*
* @see https://www.drupal.org/project/drupal/issues/3231328#comment-14444987
*/
private static function computeSurplusScore(HTMLRestrictions $surplus, HTMLRestrictions $needed): int {
// Compute a score for surplus elements, while taking into account how much
// impact each surplus element has:
$surplus_score = 0;
foreach ($surplus->getAllowedElements() as $tag_name => $attributes_config) {
// 10^6 per surplus tag.
if (!isset($needed->getAllowedElements()[$tag_name])) {
$surplus_score += pow(10, 6);
}
// 10^5 per surplus "any attributes allowed".
if ($attributes_config === TRUE) {
$surplus_score += pow(10, 5);
}
if (!is_array($attributes_config)) {
continue;
}
foreach ($attributes_config as $attribute_name => $attribute_config) {
// 10^4 per surplus wildcard attribute.
if (str_contains($attribute_name, '*')) {
$surplus_score += pow(10, 4);
}
// 10^3 per surplus attribute.
else {
$surplus_score += pow(10, 3);
}
// 10^2 per surplus "any attribute values allowed".
if ($attribute_config === TRUE) {
$surplus_score += pow(10, 2);
}
if (!is_array($attribute_config)) {
continue;
}
foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
// 10^1 per surplus wildcard attribute value.
if (str_contains($allowed_attribute_value, '*')) {
$surplus_score += pow(10, 1);
}
// 10^0 per surplus attribute value.
else {
$surplus_score += pow(10, 0);
}
}
}
}
return $surplus_score;
}
/**
* Finds candidates for the still needed restrictions among disabled plugins.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $provided
* The already provided HTML restrictions, thanks to already enabled
* CKEditor 5 plugins.
* @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
* The still needed HTML restrictions, unmet by the already enabled CKEditor
* 5 plugins.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[] $disabled_plugin_definitions
* The list of not yet enabled CKEditor 5 plugin definitions, amongst which
* candidates must be found.
* @param \Drupal\editor\EditorInterface $prospective_editor
* The in-progress prospective editor to be generated by this class.
*
* @return array
* A nested array with a tree structure covering:
* 1. tag name
* 2. concrete attribute name, `-attribute-none-` (meaning no attributes
* allowed on this tag) or `-attribute-any-` (meaning any attribute
* allowed on this tag).
* 3. (optional) attribute value (if concrete attribute name in previous
* level), `TRUE` or `FALSE`
* 4. (optional) attribute value restriction
* 5. candidate CKEditor 5 plugin ID for the HTML elements in the hierarchy
* and the surplus score as the value. In other words: the leaf of this is
* always a leaf, and a selected CKEditor 5 plugin ID is always the parent
* of a leaf.
*/
private function getCandidates(HTMLRestrictions $provided, HTMLRestrictions $still_needed, array $disabled_plugin_definitions, EditorInterface $prospective_editor): array {
$plugin_candidates = [];
if (!$still_needed->allowsNothing()) {
foreach ($disabled_plugin_definitions as $definition) {
// Only proceed if the plugin has configured elements and the plugin
// does not have conditions, or only conditions that are met. In the
// future we could add support for automatically enabling filters, but
// for now we assume that the filter configuration cannot be modified.
if (!$definition->hasElements()) {
continue;
}
if (!$definition->hasConditions()) {
// Any plugin that has no conditions is a viable candidate.
$is_viable_candidate = TRUE;
}
else {
$is_viable_candidate = TRUE;
foreach ($definition->getConditions() as $condition_type => $required_value) {
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::isPluginDisabled()
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
$is_viable_candidate = match ($condition_type) {
// If this requires a toolbar item that is provided by this
// plugin, then this is a viable candidate: placing this plugin's
// toolbar item suffices.
'toolbarItem' => array_key_exists($definition->getConditions()['toolbarItem'], $definition->getToolbarItems()),
// The image upload status is not modified, so if the current
// value matches the condition, then this is a viable candidate.
'imageUploadStatus' => ($prospective_editor->getImageUploadSettings()['status'] ?? FALSE) === $required_value,
// The set of filters is not modified, so if the required filter
// is already enabled, then this is a viable candidate.
// @todo Simplify in https://www.drupal.org/project/drupal/issues/2385047
'filter' => $prospective_editor->getFilterFormat()->filters()->has($required_value) && $prospective_editor->getFilterFormat()->filters()->get($required_value)->status,
// The default configuration is used for each plugin that would be
// enabled, so if the default configuration contains the required
// configuration, then this is a viable candidate.
'requiresConfiguration' => array_intersect($this->pluginManager->createInstance($definition->id())->defaultConfiguration(), $required_value) === $required_value,
// If this requires plugins already in the current prospective
// editor, then this is a viable candidate.
'plugins' => array_intersect($required_value, array_keys($this->pluginManager->getEnabledDefinitions($prospective_editor))) === $required_value,
};
if (!$is_viable_candidate) {
break;
}
}
}
if ($is_viable_candidate) {
[$net_new, $surplus_additions] = self::computeNetNewElementsForPlugin($provided, $still_needed, $definition);
if (!$net_new->allowsNothing()) {
$plugin_id = $definition->id();
$creatable_elements = HTMLRestrictions::fromString(implode(' ', $definition->getCreatableElements()));
$surplus_score = static::computeSurplusScore($surplus_additions, $still_needed);
foreach ($net_new->getAllowedElements() as $tag_name => $attributes_config) {
// Non-specific attribute restrictions: `FALSE` or `TRUE`.
// TRICKY: PHP does not support boolean array keys, so map these
// to a string. The string must not be a valid attribute name, so
// use a leading and trailing dash.
if (!is_array($attributes_config)) {
if ($attributes_config === FALSE && !array_key_exists($tag_name, $creatable_elements->getAllowedElements())) {
// If this plugin is not able to create the plain tag, then
// cannot be a candidate for the tag without attributes.
continue;
}
$non_specific_attribute = $attributes_config ? '-attributes-any-' : '-attributes-none-';
$plugin_candidates[$tag_name][$non_specific_attribute][$plugin_id] = $surplus_score;
continue;
}
// With specific attribute restrictions: array.
foreach ($attributes_config as $attribute_name => $attribute_config) {
if (!is_array($attribute_config)) {
$plugin_candidates[$tag_name][$attribute_name][$attribute_config][$plugin_id] = $surplus_score;
}
else {
foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
$plugin_candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config][$plugin_id] = $surplus_score;
}
}
}
// If this plugin supports unneeded attributes, it still makes a
// valid candidate for supporting the HTML tag.
$plugin_candidates[$tag_name]['-attributes-none-'][$plugin_id] = $surplus_score;
}
}
}
}
}
return $plugin_candidates;
}
/**
* Selects best candidate for each of the still needed restrictions.
*
* @param array $candidates
* The output of ::getCandidates().
* @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
* The still needed HTML restrictions, unmet by the already enabled CKEditor
* 5 plugins.
* @param string[] $already_supported_tags
* A list of already supported HTML tags, necessary to select the best
* matching candidate for elements still needed in $still_needed.
*
* @return array
* A nested array with a tree structure, with each key a selected CKEditor 5
* plugin ID and its values expressing the reason it was enabled.
*/
private static function selectCandidate(array $candidates, HTMLRestrictions $still_needed, array $already_supported_tags): array {
assert(Inspector::assertAllStrings($already_supported_tags));
// Make a selection in the candidates: minimize the surplus count, to
// avoid generating surplus additions whenever possible.
$selected_plugins = [];
foreach ($still_needed->getAllowedElements() as $tag_name => $attributes_config) {
if (!isset($candidates[$tag_name])) {
// Sadly no plugin found for this tag.
continue;
}
// Non-specific attribute restrictions for tag.
if (is_bool($attributes_config)) {
$key = $attributes_config ? '-attributes-any-' : '-attributes-none-';
if (!isset($candidates[$tag_name][$key])) {
// Sadly no plugin found for this tag + unspecific attribute.
continue;
}
asort($candidates[$tag_name][$key]);
$selected_plugin_id = array_keys($candidates[$tag_name][$key])[0];
$selected_plugins[$selected_plugin_id][$key][$tag_name] = NULL;
continue;
}
// Specific attribute restrictions for tag.
foreach ($attributes_config as $attribute_name => $attribute_config) {
if (!isset($candidates[$tag_name][$attribute_name])) {
// Sadly no plugin found for this tag + attribute.
continue;
}
if (!is_array($attribute_config)) {
if (!isset($candidates[$tag_name][$attribute_name][$attribute_config])) {
// Sadly no plugin found for this tag + attribute + config.
continue;
}
asort($candidates[$tag_name][$attribute_name][$attribute_config]);
$selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$attribute_config])[0];
$selected_plugins[$selected_plugin_id][$attribute_name][$tag_name] = $attribute_config;
continue;
}
else {
foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
if (!isset($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])) {
// Sadly no plugin found for this tag + attr + value + config.
continue;
}
asort($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config]);
$selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])[0];
$selected_plugins[$selected_plugin_id][$attribute_name][$tag_name][$allowed_attribute_value] = $allowed_attribute_value_config;
continue;
}
}
}
// If we got to this point, no exact match was found. But selecting a
// plugin to support the tag at all (when it is not yet supported) is
// crucial to meet the user's expectations.
// For example: when `<blockquote cite>` is needed, select at least the
// plugin that can support `<blockquote>`, then only the `cite` attribute
// needs to be made possible using the `SourceEditing` plugin.
if (!in_array($tag_name, $already_supported_tags, TRUE) && isset($candidates[$tag_name]['-attributes-none-'])) {
asort($candidates[$tag_name]['-attributes-none-']);
$selected_plugin_id = array_keys($candidates[$tag_name]['-attributes-none-'])[0];
$selected_plugins[$selected_plugin_id]['-attributes-none-'][$tag_name] = NULL;
}
}
// The above selects all exact matches. It's possible the same plugin is
// selected for multiple reasons: for supporting the tag at all, but also
// for supporting more attributes on the tag. Whenever that scenario
// occurs, keep only the "tag" reason, since that is the most relevant one
// for the end user. Otherwise a single plugin being selected (and enabled)
// could generate multiple messages, which would be confusing and
// overwhelming for the user.
// For example: when `<a href>` is needed, supporting `<a>` is more
// relevant to be informed about as an end user than the plugin also being
// enabled to support the `href` attribute.
foreach ($selected_plugins as $selected_plugin_id => $reason) {
if (count($reason) > 1 && isset($reason['-attributes-none-'])) {
$selected_plugins[$selected_plugin_id] = array_intersect_key($reason, ['-attributes-none-' => TRUE]);
}
}
return $selected_plugins;
}
/**
* Adds CKEditor 5 toolbar items to match the format's HTML elements.
*
* @param \Drupal\filter\FilterFormatInterface $format
* The text format for which to compute smart default settings.
* @param \Drupal\editor\EditorInterface $editor
* The text editor config entity to update.
*
* @return array|null
* NULL when nothing happened, otherwise an array with four values:
* 1. a description (for use in a message) of which CKEditor 5 plugins were
* enabled to match the HTML tags allowed by the text format.
* 2. a description (for use in a message) of which CKEditor 5 plugins were
* enabled to match the HTML attributes allowed by the text format.
* 3. the unsupported elements, in an HTMLRestrictions value object.
* 4. the list of enabled plugin labels.
*/
private function addToolbarItemsToMatchHtmlElementsInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array {
$html_restrictions_needed_elements = $format->getHtmlRestrictions();
if ($html_restrictions_needed_elements === FALSE) {
// There are no HTML restrictions, so configure CKEditor 5 to allow
// arbitrary markup to be entered.
$editor_settings_to_update = $editor->getSettings();
// Create new group for all the added toolbar items.
$editor_settings_to_update['toolbar']['items'][] = '|';
$editor_settings_to_update['toolbar']['items'][] = 'sourceEditing';
$editor_settings_to_update['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = [];
$editor->setSettings($editor_settings_to_update);
return [
NULL,
NULL,
HTMLRestrictions::emptySet(),
$this->pluginManager->getDefinition('ckeditor5_sourceEditing'),
];
}
$all_definitions = $this->pluginManager->getDefinitions();
$enabled_definitions = $this->pluginManager->getEnabledDefinitions($editor);
$disabled_definitions = array_diff_key($all_definitions, $enabled_definitions);
$enabled_plugins = array_keys($enabled_definitions);
$provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins, $editor);
$provided = new HTMLRestrictions($provided_elements);
$needed = HTMLRestrictions::fromTextFormat($format);
// Plugins only supporting <tag attr> cannot create the tag. For that, they
// must support plain <tag> too. With this being the case, break down what
// is needed based on what is currently provided.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCreatableElements()
// TRICKY: the HTMLRestrictions value object can only convey complete
// restrictions: merging <foo> and <foo bar> results in just <foo bar>. The
// list of already provided plain tags must hence be constructed separately.
$provided_plain_tags = new HTMLRestrictions(
$this->pluginManager->getProvidedElements($enabled_plugins, NULL, FALSE, TRUE)
);
// Determine the still needed plain tags, the still needed attributes, and
// the union of both.
$still_needed_plain_tags = $needed->extractPlainTagsSubset()->diff($provided_plain_tags);
$still_needed_attributes = $needed->diff($provided)->diff($still_needed_plain_tags);
$still_needed = $still_needed_plain_tags->merge($still_needed_attributes);
if (!$still_needed->allowsNothing()) {
// Select plugins for supporting the still needed plain tags.
$prospective_editor = clone $editor;
$plugin_candidates_plain_tags = self::getCandidates($provided_plain_tags, $still_needed_plain_tags, $disabled_definitions, $prospective_editor);
$selected_plugins_plain_tags = self::selectCandidate($plugin_candidates_plain_tags, $still_needed_plain_tags, array_keys($provided_plain_tags->getAllowedElements()));
// Select plugins for supporting the still needed attributes.
$prospective_editor_settings = $prospective_editor->getSettings();
foreach (array_keys($selected_plugins_plain_tags) as $plugin_id) {
$plugin_definition = $this->pluginManager->getDefinition($plugin_id);
assert($plugin_definition instanceof CKEditor5PluginDefinition);
if ($plugin_definition->hasToolbarItems()) {
$prospective_editor_settings['toolbar']['items'] = [...$prospective_editor_settings['toolbar']['items'], ...array_keys($plugin_definition->getToolbarItems())];
}
if ($plugin_definition->isConfigurable()) {
$prospective_editor_settings['plugins'][$plugin_id] = $this->pluginManager->createInstance($plugin_id)->defaultConfiguration();
}
}
$prospective_editor->setSettings($prospective_editor_settings);
$plugin_candidates_attributes = self::getCandidates($provided, $still_needed_attributes, array_diff_key($disabled_definitions, $selected_plugins_plain_tags), $prospective_editor);
$selected_plugins_attributes = self::selectCandidate($plugin_candidates_attributes, $still_needed, array_keys($provided->getAllowedElements()));
// Combine the selection.
$selected_plugins = array_merge_recursive($selected_plugins_plain_tags, $selected_plugins_attributes);
// If additional plugins need to be enabled to support attribute config,
// loop through the list to enable the plugins and build a UI message that
// will convey this plugin-enabling to the user.
if (!empty($selected_plugins)) {
$enabled_for_tags_message_content = '';
$enabled_for_attributes_message_content = '';
$editor_settings_to_update = $editor->getSettings();
// Create new group for all the added toolbar items.
$editor_settings_to_update['toolbar']['items'][] = '|';
foreach ($selected_plugins as $plugin_id => $reason_why_enabled) {
$plugin_definition = $this->pluginManager->getDefinition($plugin_id);
$label = $plugin_definition->label();
$plugins_enabled[] = $label;
[$net_new] = self::computeNetNewElementsForPlugin($provided, $still_needed, $plugin_definition);
// Track remaining elements/attributes that are still needed.
$still_needed = $still_needed->diff($net_new);
// Fulfill the purpose of this method: generate the settings to add
// this plugin's toolbar item.
if ($plugin_definition->hasToolbarItems()) {
$editor_settings_to_update['toolbar']['items'] = array_merge($editor_settings_to_update['toolbar']['items'], array_keys($plugin_definition->getToolbarItems()));
foreach ($reason_why_enabled as $attribute_name => $attribute_config) {
// Plugin was selected for tag.
if (in_array($attribute_name, ['-attributes-none-', '-attributes-any-'], TRUE)) {
$tags = array_reduce(array_keys($net_new->getAllowedElements()), function ($carry, $item) {
return $carry . "<$item>";
});
$enabled_for_tags_message_content .= "$label (for tags: $tags) ";
// This plugin does not add attributes: continue to next plugin.
continue;
}
// Plugin was selected for attribute.
$enabled_for_attributes_message_content .= "$label (";
foreach ($attribute_config as $tag_name => $attribute_value_config) {
$enabled_for_attributes_message_content .= " for tag: <$tag_name> to support: $attribute_name";
if (is_array($attribute_value_config)) {
$enabled_for_attributes_message_content .= " with value(s): ";
foreach (array_keys($attribute_value_config) as $allowed_value) {
$enabled_for_attributes_message_content .= " $allowed_value,";
}
$enabled_for_attributes_message_content = substr($enabled_for_attributes_message_content, 0, -1) . '), ';
}
}
}
}
}
$editor->setSettings($editor_settings_to_update);
// Some plugins enabled, maybe some missing tags or attributes.
return [
substr($enabled_for_tags_message_content, 0, -1),
substr($enabled_for_attributes_message_content, 0, -2),
$still_needed,
$plugins_enabled,
];
}
else {
// No plugins enabled, maybe some missing tags or attributes.
return [
NULL,
NULL,
$still_needed,
NULL,
];
}
}
else {
return NULL;
}
}
/**
* Adds default settings for all enabled CKEditor 5 plugins.
*
* @param \Drupal\editor\EditorInterface $editor
* The text editor config entity to update.
*/
private function addDefaultSettingsForEnabledConfigurablePlugins(EditorInterface $editor): void {
$settings = $editor->getSettings();
$update_settings = FALSE;
$enabled_definitions = $this->pluginManager->getEnabledDefinitions($editor);
$configurable_definitions = array_filter($enabled_definitions, function (CKEditor5PluginDefinition $definition): bool {
return $definition->isConfigurable();
});
foreach ($configurable_definitions as $plugin_name => $definition) {
$default_plugin_configuration = $this->pluginManager->getPlugin($plugin_name, NULL)->defaultConfiguration();
// Skip plugins with an empty default configuration, the plugin
// configuration is most likely stored elsewhere. Also skip any plugin
// that already has configuration data as default values are not needed.
if ($default_plugin_configuration === [] || isset($settings['plugins'][$plugin_name])) {
continue;
}
$update_settings = TRUE;
$settings['plugins'][$plugin_name] = $default_plugin_configuration;
}
if ($update_settings) {
$editor->setSettings($settings);
}
}
}