ckeditor5-1.0.x-dev/src/Plugin/Editor/CKEditor5.php
src/Plugin/Editor/CKEditor5.php
<?php
namespace Drupal\ckeditor5\Plugin\Editor;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManager;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\editor\Plugin\EditorBase;
use Drupal\filter\FilterFormatInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Defines a CKEditor 5-based text editor for Drupal.
*
* @Editor(
* id = "ckeditor5",
* label = @Translation("CKEditor 5"),
* supports_content_filtering = TRUE,
* supports_inline_editing = TRUE,
* is_xss_safe = TRUE,
* supported_element_types = {
* "textarea"
* }
* )
*/
class CKEditor5 extends EditorBase implements ContainerFactoryPluginInterface {
/**
* The CKEditor plugin manager.
*
* @var \Drupal\ckeditor\Plugin\CKEditorPluginManager
*/
protected $ckeditor5PluginManager;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* Constructs a CKEditor5 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\CKEditor5PluginManager $ckeditor5_plugin_manager
* The CKEditor5 plugin manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, CKEditor5PluginManager $ckeditor5_plugin_manager, LanguageManagerInterface $language_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->ckeditor5PluginManager = $ckeditor5_plugin_manager;
$this->languageManager = $language_manager;
}
/**
* {@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')
);
}
/**
* {@inheritdoc}
*/
public function getDefaultSettings() {
// The default toolbar items will be accompanied by any plugin with an
// isEnabled() method that returns TRUE.
return [
'toolbar' => [
'items' => ['heading', 'bold', 'italic'],
],
];
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state, Editor $editor) {
}
/**
* 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.
*
* @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 \DomainException('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 being somewhere in
// filter settings.
foreach ($violations as $i => $violation) {
assert($violation instanceof ConstraintViolation);
if (strpos(get_class($violation->getConstraint()), 'Drupal\\ckeditor5\\') === 0) {
continue;
}
$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 Editor);
$language = $this->languageManager->getCurrentLanguage();
$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","|","uploadImage"].
// 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('CKEditor5 plugin settings'),
'#id' => 'ckeditor5-plugin-settings',
];
$this->ckeditor5PluginManager->injectPluginSettingsForm($form, $form_state, \Drupal::service('ckeditor5.admin_ui')->getSubmittedEditorAndFilterFormat($form_state->getCompleteFormState()));
// Immediately warn the user about fundamental incompatibilities, long
// before the user has spent time reconfiguring their text format.
// @see ckeditor5_form_filter_admin_format_validate()
$submitted_editor = \Drupal::service('ckeditor5.admin_ui')->getSubmittedEditorAndFilterFormat($form_state->getCompleteFormState());
$fundamental_incompatibilities = static::validatePair($submitted_editor, $submitted_editor->getFilterFormat(), FALSE);
$form_state->set('ckeditor5_fundamentally_compatible', $fundamental_incompatibilities->count() === 0);
foreach ($fundamental_incompatibilities as $violation) {
// @see \Drupal\Core\Entity\Entity\EntityFormDisplay::movePropertyPathViolationsRelativeToField()
// @see \Drupal\link\Plugin\Field\FieldWidget\LinkWidget::flagErrors()
// Note: we cannot use $violation->getMessage(): it contains double-
// escaped translatable markup parameters.
// @codingStandardsIgnoreLine
$this->messenger()->addError($this->t($violation->getMessageTemplate(), $violation->getParameters()));
}
return $form;
}
/**
* {@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;
}
$submitted_editor = \Drupal::service('ckeditor5.admin_ui')->getSubmittedEditorAndFilterFormat($form_state->getCompleteFormState());
assert($submitted_editor instanceof EditorInterface);
// @codingStandardsIgnoreStart
// @todo After https://www.drupal.org/project/drupal/issues/2164373 lands,
// we should be able to simplify this to
// $violations = $submitted_editor->getTypedData()->validate();
// @codingStandardsIgnoreEnd
$typed_config_manager = \Drupal::getContainer()->get('config.typed');
// Validate the text editor config entity prior to saving.
$typed_config = $typed_config_manager->createFromNameAndData(
$submitted_editor->getConfigDependencyName(),
$submitted_editor->toArray(),
);
$violations = $typed_config->validate();
foreach ($violations as $violation) {
$form_item_name = static::mapViolationPropertyPathsToFormNames($violation->getPropertyPath());
$form_state->setError(NestedArray::getValue($form, explode('][', $form_item_name)), $violation->getMessage());
// @todo Remove the above line in favor of the below line when https://www.drupal.org/project/drupal/issues/3217124 is fixed.
// $form_state->setErrorByName($form_item_name, $violation->getMessage());
}
// Validate the text editor + text format pair.
$violations = CKEditor5::validatePair($submitted_editor, $submitted_editor->getFilterFormat());
foreach ($violations as $violation) {
$form_item_name = static::mapPairViolationPropertyPathsToFormNames($violation->getPropertyPath());
$form_state->getCompleteFormState()->setErrorByName($form_item_name, $violation->getMessage());
}
}
/**
* Maps Text Editor config object property paths to form names.
*
* @param string $property_path
* A config object property path.
*
* @return string
* The corresponding form name in the subform.
*/
protected static function mapViolationPropertyPathsToFormNames(string $property_path) : string {
// All of the toolbar item configuration is powered by a small JavaScript
// application which writes its results as a JSON blob in a text area at the
// location toolbar][items.
if (preg_match('/^settings\.toolbar\.items.*/', $property_path)) {
return 'toolbar][items';
}
return implode('][', array_slice(explode('.', $property_path), 1));
}
/**
* Maps Text Editor + Text Format pair property paths to form names.
*
* @param string $property_path
* A config object property path.
*
* @return string
* The corresponding form name in the complete form.
*/
protected static function mapPairViolationPropertyPathsToFormNames(string $property_path) : string {
// Filters are top-level.
if (preg_match('/^filters\..*/', $property_path)) {
return implode('][', array_merge(explode('.', $property_path), ['settings']));
}
// Everything else is in the subform.
return 'editor][' . static::mapViolationPropertyPathsToFormNames($property_path);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$form_state->setValue(
['toolbar', 'items'],
Json::decode($form_state->getValue(['toolbar', 'items']))
);
// Remove the plugin vertical tab state.
$form_state->unsetValue('plugin_settings');
parent::submitConfigurationForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function getJSSettings(Editor $editor) {
return $this->ckeditor5PluginManager->getCKEditorPluginSettings($editor);
}
/**
* {@inheritdoc}
*/
public function getLibraries(Editor $editor) {
return $this->ckeditor5PluginManager->getEnabledLibraries($editor);
}
}
