ckeditor5-1.0.x-dev/src/Plugin/CKEditor5PluginManager.php
src/Plugin/CKEditor5PluginManager.php
<?php
namespace Drupal\ckeditor5\Plugin;
use Drupal\ckeditor5\Annotation\CKEditor5Plugin;
use Drupal\Component\Annotation\Plugin\Discovery\AnnotationBridgeDecorator;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Plugin\Discovery\AnnotatedClassDiscovery;
use Drupal\Core\Plugin\Discovery\YamlDiscoveryDecorator;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Masterminds\HTML5\Elements;
/**
* Provides a CKEditor5 plugin manager.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginBase
* @see \Drupal\ckeditor5\Annotation\CKEditor5Plugin
* @see plugin_api
*/
class CKEditor5PluginManager extends DefaultPluginManager {
use StringTranslationTrait;
/**
* Wildcard types, and the methods that return tags the wildcard represents.
*
* @var string[]
*/
private const WILDCARD_ELEMENT_METHODS = [
'$block' => 'getBlockElementList',
];
/**
* 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);
$this->alterInfo('ckeditor5_plugin_info');
$this->setCacheBackend($cache_backend, 'ckeditor5_plugins');
}
/**
* {@inheritdoc}
*/
protected function getDiscovery() {
if (!$this->discovery) {
$discovery = new AnnotatedClassDiscovery($this->subdir, $this->namespaces, $this->pluginDefinitionAnnotationName, $this->additionalAnnotationNamespaces);
$discovery = new YamlDiscoveryDecorator($discovery, 'ckeditor5', $this->moduleHandler->getModuleDirectories());
$discovery->addTranslatableProperty('label');
$discovery = new AnnotationBridgeDecorator($discovery, $this->pluginDefinitionAnnotationName);
$this->discovery = $discovery;
}
return $this->discovery;
}
/**
* {@inheritdoc}
*/
public function processDefinition(&$definition, $plugin_id) {
parent::processDefinition($definition, $plugin_id);
if (isset($definition['class']) && !in_array(CKEditor5PluginInterface::class, class_implements($definition['class']))) {
throw new \Exception('CKEditor 5 plugins must implement \Drupal\ckeditor5\Plugin\CKEditor5PluginInterface.');
}
}
/**
* Retrieves all CKEditor5 plugins.
*
* @return array
* A list of the CKEditor5 plugins, with the plugin IDs as keys.
*/
public function getPlugins() {
$plugin_ids = array_keys($this->getDefinitions());
$plugins = [];
foreach ($plugin_ids as $plugin_id) {
$plugins[$plugin_id] = $this->createInstance($plugin_id);
}
return $plugins;
}
/**
* Gets a list of all toolbar items.
*
* @return array
* List of all toolbar items provided by plugins.
*/
public function getToolbarItems() {
return $this->mergeDefinitions('toolbar_items', $this->getDefinitions());
}
/**
* Gets a list of all admin library names.
*
* @return array
* List of all admin libraries provided by plugins.
*/
public function getAdminLibraries() {
$list = $this->mergeDefinitions('admin_library', $this->getDefinitions());
// Include main admin library.
array_unshift($list, 'ckeditor5/admin');
return $list;
}
/**
* 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\Entity\Editor $editor
* A configured text editor object.
*
* @return array
* The list of enabled libraries.
*/
public function getEnabledLibraries(Editor $editor) {
$list = $this->mergeDefinitions('library', $this->getEnabledDefinitions($editor));
$list = array_unique($list);
// Include main library.
array_unshift($list, 'ckeditor5/drupal.ckeditor5');
sort($list);
return $list;
}
/**
* Gets a list of all toolbar items required for the editor.
*
* This list is filtered by enabled plugins because it is needed at runtime.
*
* @param \Drupal\editor\Entity\Editor $editor
* A configured text editor object.
*
* @return array
* List of all toolbar items provided by enabled plugins.
*/
public function getEnabledToolbarItems(Editor $editor) {
return $this->mergeDefinitions('toolbar_items', $this->getEnabledDefinitions($editor));
}
/**
* Filter list of definitions by enabled plugins only.
*
* @param \Drupal\editor\Entity\Editor $editor
* A configured text editor object.
*
* @return array
* Enabled plugin definitions.
*/
public function getEnabledDefinitions(Editor $editor) {
$definitions = $this->getDefinitions();
ksort($definitions);
foreach ($definitions as $plugin_id => $definition) {
$plugin = $this->createInstance($plugin_id);
// Remove definition when plugin has conditions and they are not met.
if (isset($definition['conditions'])) {
if ($this->isPluginDisabled($plugin, $editor)) {
unset($definitions[$plugin_id]);
}
}
// Otherwise, only remove the definition if the plugin has buttons and
// none of its buttons are active.
elseif (isset($definition['toolbar_items'])) {
if (empty(array_intersect($editor->getSettings()['toolbar']['items'], array_keys($definition['toolbar_items'])))) {
unset($definitions[$plugin_id]);
}
}
}
// Only enable the General HTML Support plugin on text formats with no HTML
// restrictions.
// @todo generalize.
// @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.htmlSupport']);
}
return $definitions;
}
/**
* Gets all plugin settings, excluding disabled plugins.
*
* These are the settings used to directly configure CKEditor 5.
*
* @param \Drupal\editor\Entity\Editor $editor
* A configured text editor object.
*
* @return array[]
* Combined plugin definitions.
*
* @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getJSSettings()
*/
public function getCKEditorPluginSettings(Editor $editor) {
$definitions = $this->getEnabledDefinitions($editor);
// Allow plugin to modify config, such as loading dynamic values.
$config = [];
foreach ($definitions as $plugin_id => $definition) {
$plugin = $this->createInstance($plugin_id);
$config[$plugin_id] = $plugin->getDynamicPluginConfig($definition['plugin_config'] ?? [], $editor);
}
$toolbar_items = $editor->getSettings()['toolbar']['items'];
return [
'plugins' => $this->mergeDefinitions('plugins', $definitions),
'config' => NestedArray::mergeDeepArray($config),
'toolbar' => [
'items' => $toolbar_items,
'shouldNotGroupWhenFull' => in_array('-', $toolbar_items, TRUE),
],
];
}
/**
* Determines whether the plugin settings form should be visible.
*
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface $plugin
* The configurable CKEditor 5 plugin to assess the visibility for.
* @param \Drupal\editor\Entity\Editor $editor
* A configured text editor object.
*
* @return bool
* Whether this configurable plugin's settings form should be visible.
*/
protected function shouldHaveVisiblePluginSettingsForm(CKEditor5PluginConfigurableInterface $plugin, Editor $editor) : bool {
$enabled_plugins = $this->getEnabledDefinitions($editor);
$plugin_id = $plugin->getPluginId();
// 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.
// 2 - A conditionally enabled plugin that does depend on a toolbar item,
// and that toolbar item is active.
$conditions = $plugin->getPluginDefinition()['conditions'] ?? [];
if (!empty($conditions)) {
if (!array_key_exists('toolbarItem', $conditions)) {
return TRUE;
}
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\Entity\Editor $editor
* A configured text editor object.
*/
public function injectPluginSettingsForm(array &$form, FormStateInterface $form_state, Editor $editor) {
$plugins = $this->getPlugins($editor);
foreach ($plugins as $plugin_id => $plugin) {
if ($plugin instanceof CKEditor5PluginConfigurableInterface && $this->shouldHaveVisiblePluginSettingsForm($plugin, $editor)) {
$definition = $plugin->getPluginDefinition();
$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->settingsForm($plugin_settings_form, $form_state, $editor);
}
}
}
/**
* Adds allowed attributes to the elements array.
*
* @param array $elements
* The elements array.
* @param string $tag
* The tag having its attributes configured.
* @param string $attribute
* The attribute being configured.
* @param array|bool $value
* The attribute config value.
*/
private static function providedElementsAttributes(array &$elements, string $tag, string $attribute, $value) : void {
$attribute_already_allows_all = isset($elements[$tag][$attribute]) && $elements[$tag][$attribute] === TRUE;
if ($value === TRUE) {
$elements[$tag][$attribute] = TRUE;
}
elseif (!$attribute_already_allows_all) {
foreach ($value as $attribute_value) {
$elements[$tag][$attribute][$attribute_value] = TRUE;
}
}
}
/**
* Create a list of elements with attributes declared for the CKEditor5 build.
*
* @return array
* A nested array with a structure as described in
* \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions().
*
* @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
*/
public function getProvidedElements(array $plugin_ids = []) : array {
$plugins = $this->getDefinitions();
if (!empty($plugin_ids)) {
$plugins = array_intersect_key($plugins, array_flip($plugin_ids));
}
$elements = [];
$processed_elements = [];
foreach ($plugins as $id => $definition) {
foreach ($definition['elements'] ?? [] as $element) {
$wildcard = FALSE;
if (in_array($element, $processed_elements)) {
continue;
}
$processed_elements[] = $element;
preg_match('/<(\$[A-Z,a-z]*)/', $element, $wildcard_matches);
if (!empty($wildcard_matches)) {
$wildcard = $wildcard_matches[1];
$element = str_replace($wildcard, 'div', $element);
}
$body_child_nodes = Html::load(str_replace('>', ' />', $element))->getElementsByTagName('body')->item(0)->childNodes;
foreach ($body_child_nodes as $node) {
if ($node->nodeType !== XML_ELEMENT_NODE) {
// Skip the empty text nodes inside tags.
continue;
}
$tag = $wildcard ? $wildcard : $node->tagName;
if ($node->hasAttributes()) {
foreach ($node->attributes as $attribute_name => $attribute) {
$value = empty($attribute->value) ? TRUE : explode(' ', $attribute->value);
self::providedElementsAttributes($elements, $tag, $attribute_name, $value);
}
}
else {
if (!isset($elements[$tag])) {
$elements[$tag] = FALSE;
}
}
}
}
}
foreach ($elements as $tag_name => $tag_config) {
if (substr($tag_name, 0, 1) === '$') {
$wildcard_element_method = self::WILDCARD_ELEMENT_METHODS[$tag_name];
$wildcard_tags = call_user_func([self::class, $wildcard_element_method]);
foreach ($wildcard_tags as $wildcard_tag) {
if (isset($elements[$wildcard_tag])) {
foreach ($tag_config as $attribute_name => $attribute_value) {
if (is_array($attribute_value)) {
$attribute_value = array_keys($attribute_value);
}
$element_already_allows_all_values = isset($elements[$wildcard_tag][$attribute_name]) && $elements[$wildcard_tag][$attribute_name] === TRUE;
if (!$element_already_allows_all_values) {
self::providedElementsAttributes($elements, $wildcard_tag, $attribute_name, $attribute_value);
}
}
}
}
unset($elements[$tag_name]);
}
}
return $elements;
}
/**
* Gets a list of block level elements.
*
* @return array
* An array of block level element tags.
*/
private static function getBlockElementList() : array {
return array_filter(array_keys(Elements::$html5), function (string $element) {
return Elements::isA($element, Elements::BLOCK_TAG);
});
}
/**
* Formats HTML elements for display.
*
* @param array $elements
* List of elements to format.
*
* @return string
* A formatted list; a string representation of the given HTML elements.
*/
public function getReadableElements(array $elements) : string {
$readable = [];
foreach ($elements as $tag => $attributes) {
$attribute_string = '';
if (is_array($attributes)) {
foreach ($attributes as $attribute_name => $attribute_values) {
if (is_array($attribute_values)) {
$attribute_values_string = implode(' ', array_keys($attribute_values));
$attribute_string .= "$attribute_name=\"$attribute_values_string\" ";
}
else {
$attribute_string .= "$attribute_name ";
}
}
}
$joined = '<' . $tag . (!empty($attribute_string) ? ' ' . trim($attribute_string) : '') . '>';
array_push($readable, $joined);
}
return implode(' ', $readable);
}
/**
* Return array of all the plugin definitions for the given setting type.
*
* @param string $setting_type
* The definition key.
* @param array $definitions
* All available plugin definitions.
*
* @return array
* List of all plugin definitions of one type, e.g. toolbar_items.
*/
protected function mergeDefinitions(string $setting_type, array $definitions) : array {
return array_reduce($definitions, function ($acc, $cur) use ($setting_type) {
if (isset($cur[$setting_type])) {
if (is_array($cur[$setting_type])) {
$acc = NestedArray::mergeDeep($acc, $cur[$setting_type]);
}
else {
$acc[] = $cur[$setting_type];
}
}
return $acc;
}, []);
}
/**
* 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 {
$definition = $plugin->getPluginDefinition();
$conditions = isset($definition['conditions']) ? $definition['conditions'] : [];
foreach ($conditions as $condition_type => $required_value) {
switch ($condition_type) {
case 'toolbarItem':
if (!in_array($required_value, $editor->getSettings()['toolbar']['items'])) {
return TRUE;
}
break;
case 'imageUploadStatus':
if ($editor->getImageUploadSettings()['status'] !== TRUE) {
return TRUE;
}
break;
case 'filter':
$filters = $editor->getFilterFormat()->filters();
if (!$filters->has($required_value) || !$filters->get($required_value)->status) {
return TRUE;
}
break;
}
}
return FALSE;
}
}
