class Ckeditor5Hooks

Same name and namespace in other branches
  1. 11.x core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php \Drupal\ckeditor5\Hook\Ckeditor5Hooks

Hook implementations for ckeditor5.

Hierarchy

Expanded class hierarchy of Ckeditor5Hooks

3 files declare their use of Ckeditor5Hooks
ckeditor5.module in core/modules/ckeditor5/ckeditor5.module
CKEditor5StylesheetsTest.php in core/modules/ckeditor5/tests/src/Kernel/CKEditor5StylesheetsTest.php
CKEditor5Test.php in core/modules/ckeditor5/tests/src/Unit/CKEditor5Test.php

File

core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php, line 36

Namespace

Drupal\ckeditor5\Hook
View source
class Ckeditor5Hooks {
  use StringTranslationTrait;
  public function __construct(protected LanguageMapper $languageMapper, protected ModuleHandlerInterface $moduleHandler, protected LibraryDependencyResolverInterface $libraryDependencyResolver, protected RendererInterface $renderer, protected ConfigFactoryInterface $configFactory, protected ThemeExtensionList $themeExtensionList, protected MessengerInterface $messenger) {
  }
  
  /**
   * Implements 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().
   */
  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.
   */
  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.');
      }
    }
    // Override the AJAX callbacks for changing editors, so multiple areas of
    // the form can be updated on change.
    $form['editor']['editor']['#ajax'] = [
      'callback' => static::class . ':updateCkeditor5HtmlFilter',
      'trigger_as' => [
        'name' => 'editor_configure',
      ],
    ];
    $form['editor']['configure']['#ajax'] = [
      'callback' => static::class . ':updateCkeditor5HtmlFilter',
    ];
    $form['editor']['settings']['subform']['toolbar']['items']['#ajax'] = [
      'callback' => static::class . ':updateCkeditor5HtmlFilter',
      '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' => static::class . ':updateCkeditor5HtmlFilter',
        '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' => static::class . ':updateCkeditor5HtmlFilter',
          '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'], static::class . ':filterFormatEditFormSubmit');
  }
  
  /**
   * Implements hook_library_info_alter().
   */
  public function libraryInfoAlter(&$libraries, $extension) : void {
    if ($extension === 'filter') {
      $libraries['drupal.filter.admin']['dependencies'][] = 'ckeditor5/internal.drupal.ckeditor5.filter.admin';
    }
    if ($extension === 'ckeditor5') {
      // Add paths to stylesheets specified by a theme's ckeditor5-stylesheets
      // config property.
      $libraries['internal.drupal.ckeditor5.stylesheets'] = [
        'css' => [
          'theme' => array_fill_keys(array_values($this->themeCss()), []),
        ],
      ];
    }
    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 (!$this->moduleHandler
      ->moduleExists('locale')) {
      return;
    }
    // All possibles CKEditor 5 languages that can be used by Drupal.
    $ckeditor_langcodes = array_values($this->languageMapper
      ->getMappings());
    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 ($this->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().
   */
  public function jsAlter(&$javascript, AttachedAssetsInterface $assets, LanguageInterface $language) : void {
    $placeholder_file = 'core/assets/vendor/ckeditor5/translation.js';
    // When the locale module isn't installed there are no translations.
    if (!$this->moduleHandler
      ->moduleExists('locale')) {
      unset($javascript[$placeholder_file]);
      return;
    }
    $translations_library = 'core/ckeditor5.translations';
    if (in_array($translations_library, $this->libraryDependencyResolver
      ->getLibrariesWithDependencies($assets->getLibraries()), TRUE) || in_array($translations_library, $this->libraryDependencyResolver
      ->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()), TRUE)) {
      // 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';
      // Use the placeholder file weight to set all the translations files
      // weights so they can be aggregated together as expected. Account for
      // requests where the library is not loaded such as when during an AJAX
      // request when it was already loaded via the main request. In these cases
      // it is unlikely that multiple JavaScript aggregates will be created
      // anyway since AJAX requests generally result in very few libraries being
      // loaded.
      $default_weight = $javascript[$placeholder_file]['weight'] ?? 0;
      if (isset($javascript[$ckeditor_dll_file])) {
        $default_weight = $javascript[$ckeditor_dll_file]['weight'];
      }
      $ckeditor5_language = $this->languageMapper
        ->getMapping($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]);
        }
      }
    }
    // The placeholder file is not a real file, remove it from the list.
    unset($javascript[$placeholder_file]);
  }
  
  /**
   * Implements 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'];
  }
  
  /**
   * Implements hook_field_widget_single_element_form_alter().
   */
  public function fieldWidgetSingleElementFormAlter(&$element, FormStateInterface $form_state, $context) : void {
    // Add an attribute so that CKEditor 5 plugins can vary their behavior based
    // on host entity type, host entity bundle and host entity language.
    if (!empty($element['#type']) && $element['#type'] == 'text_format') {
      $items = $context['items'];
      assert($items instanceof FieldItemListInterface);
      $host_entity = $items->getEntity();
      $element['#attributes']['data-ckeditor5-host-entity-type'] = $host_entity->getEntityTypeId();
      $element['#attributes']['data-ckeditor5-host-entity-bundle'] = $host_entity->bundle();
      $element['#attributes']['data-ckeditor5-host-entity-langcode'] = $host_entity->language()
        ->getId();
    }
  }
  
  /**
   * Implements hook_entity_bundle_info_alter().
   */
  public function entityBundleInfoAlter(array &$bundles) : void {
    if (isset($bundles['node'])) {
      foreach ($bundles['node'] as $key => $bundle) {
        $bundles['node'][$key]['ckeditor5_link_suggestions'] = TRUE;
      }
    }
  }
  
  /**
   * Implements hook_ENTITY_TYPE_presave() for editor entities.
   */
  public function editorPresave(EditorInterface $editor) : void {
    if ($editor->getEditor() === 'ckeditor5') {
      $settings = $editor->getSettings();
      // @see ckeditor5_post_update_list_type()
      if (array_key_exists('ckeditor5_list', $settings['plugins']) && array_key_exists('ckeditor5_sourceEditing', $settings['plugins'])) {
        $source_edited = HTMLRestrictions::fromString(implode(' ', $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']));
        $format_restrictions = HTMLRestrictions::fromTextFormat($editor->getFilterFormat());
        // If neither <ol type> or <ul type> are allowed through Source Editing
        // (the only way it could possibly be supported until now), and it is
        // not an unrestricted text format (such as "Full HTML"), then set the
        // new "styles" setting for the List plugin to false.
        $ol_type = HTMLRestrictions::fromString('<ol type>');
        $ul_type = HTMLRestrictions::fromString('<ul type>');
        if (!array_key_exists('styles', $settings['plugins']['ckeditor5_list']['properties'])) {
          $settings['plugins']['ckeditor5_list']['properties']['styles'] = $ol_type->diff($source_edited)
            ->allowsNothing() || $ul_type->diff($source_edited)
            ->allowsNothing() || $format_restrictions->isUnrestricted();
        }
        // Update the Source Editing configuration too.
        $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = $source_edited->diff($ol_type)
          ->diff($ul_type)
          ->toCKEditor5ElementsArray();
      }
      elseif (array_key_exists('ckeditor5_list', $settings['plugins']) && !array_key_exists('styles', $settings['plugins']['ckeditor5_list']['properties'])) {
        $settings['plugins']['ckeditor5_list']['properties']['styles'] = FALSE;
      }
      $editor->setSettings($settings);
    }
  }
  
  /**
   * Form submission handler for filter format forms.
   *
   * @param array $form
   *   The form render array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state object.
   */
  public function filterFormatEditFormSubmit(array $form, FormStateInterface $form_state) : void {
    $limit_allowed_html_tags = isset($form['filters']['settings']['filter_html']['allowed_html']);
    $manually_editable_tags = $form_state->getValue([
      'editor',
      'settings',
      'plugins',
      'ckeditor5_sourceEditing',
      'allowed_tags',
    ]);
    $styles = $form_state->getValue([
      'editor',
      'settings',
      'plugins',
      'ckeditor5_style',
      'styles',
    ]);
    if ($limit_allowed_html_tags && is_array($manually_editable_tags) || is_array($styles)) {
      // When "Manually editable tags", "Style" and "limit allowed HTML tags"
      // are all configured, the latter is dependent on the others. This
      // dependent value is typically updated via AJAX, but it's possible for
      // "Manually editable tags" to update without triggering the AJAX rebuild.
      // That value is recalculated here on save to ensure it happens even if
      // the AJAX rebuild doesn't happen.
      $manually_editable_tags_restrictions = HTMLRestrictions::fromString(implode($manually_editable_tags ?? []));
      $styles_restrictions = HTMLRestrictions::fromString(implode($styles ? array_column($styles, 'element') : []));
      $format = $form_state->get('ckeditor5_validated_pair')
        ->getFilterFormat();
      $allowed_html = HTMLRestrictions::fromTextFormat($format);
      $combined_tags_string = $allowed_html->merge($manually_editable_tags_restrictions)
        ->merge($styles_restrictions)
        ->toFilterHtmlAllowedTagsString();
      $form_state->setValue([
        'filters',
        'filter_html',
        'settings',
        'allowed_html',
      ], $combined_tags_string);
    }
  }
  
  /**
   * AJAX callback handler for filter_format_form().
   *
   * Used instead of editor_form_filter_admin_form_ajax from the editor module.
   *
   * @param array $form
   *   The form render array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state interface.
   *
   * @return \Drupal\Core\Ajax\AjaxResponse
   *   The Ajax response.
   */
  public function updateCkeditor5HtmlFilter(array $form, FormStateInterface $form_state) : AjaxResponse {
    $response = new AjaxResponse();
    // Replace the editor settings with the settings for the currently selected
    // editor. This is the default behavior of editor.module. Except when using
    // CKEditor 5: then we only want CKEditor 5's plugin settings to be updated:
    // the client side-rendered admin UI would otherwise be dependent on network
    // latency.
    $renderedField = $this->renderer
      ->render($form['editor']['settings']);
    if ($form_state->get('ckeditor5_is_active') && $form_state->get('ckeditor5_is_selected')) {
      $plugin_settings_markup = $form['editor']['settings']['subform']['plugin_settings']['#markup'];
      // If no configurable plugins are enabled, render an empty container with
      // the same ID instead. Otherwise it'll be impossible to render plugin
      // settings vertical tabs in the correct location when such a plugin is
      // enabled.
      // @see \Drupal\Core\Render\Element\VerticalTabs::preRenderVerticalTabs
      $markup = $plugin_settings_markup ?? [
        '#type' => 'container',
        '#attributes' => [
          'id' => 'plugin-settings-wrapper',
        ],
      ];
      $response->addCommand(new ReplaceCommand('#plugin-settings-wrapper', $markup));
    }
    else {
      $response->addCommand(new ReplaceCommand('#editor-settings-wrapper', $renderedField));
    }
    if ($form_state->get('ckeditor5_is_active')) {
      // Delete all existing validation messages, replace them with the current
      // set.
      $response->addCommand(new RemoveCommand('#ckeditor5-realtime-validation-messages-container > *'));
      $messages = $this->messenger
        ->deleteAll();
      foreach ($messages as $type => $messages_by_type) {
        foreach ($messages_by_type as $message) {
          $response->addCommand(new MessageCommand($message, '#ckeditor5-realtime-validation-messages-container', [
            'type' => $type,
          ], FALSE));
        }
      }
    }
    else {
      // If switching to CKEditor 5 triggers a validation error, the real-time
      // validation messages container will not exist, because CKEditor 5's
      // configuration form will not be rendered. In this case, render it into
      // the (empty) editor settings wrapper. When the validation error is
      // addressed, CKEditor 5's configuration form will get rendered and will
      // overwrite those validation error messages.
      $response->addCommand(new PrependCommand('#editor-settings-wrapper', [
        '#type' => 'status_messages',
      ]));
    }
    // Rebuild filter_settings form item when one of the following is true:
    // - Switching to CKEditor 5 from another text editor, and the current
    //   configuration triggers no fundamental compatibility errors.
    // - Switching from CKEditor 5 to a different editor.
    // - The editor is not being switched and is currently CKEditor 5.
    if ($form_state->get('ckeditor5_is_active') || $form_state->get('ckeditor5_is_selected') && !$form_state->getError($form['editor']['editor'])) {
      // Replace the filter settings with the settings for the currently
      // selected editor.
      $renderedSettings = $this->renderer
        ->render($form['filter_settings']);
      $response->addCommand(new ReplaceCommand('#filter-settings-wrapper', $renderedSettings));
    }
    // If switching to CKEditor 5 from another editor and there are errors in
    // that switch, add an error class and attribute to the editor select,
    // otherwise remove.
    $ckeditor5_selected_but_errors = !$form_state->get('ckeditor5_is_active') && $form_state->get('ckeditor5_is_selected') && !empty($form_state->getErrors());
    $response->addCommand(new InvokeCommand('[data-drupal-selector="edit-editor-editor"]', $ckeditor5_selected_but_errors ? 'addClass' : 'removeClass', [
      'error',
    ]));
    $response->addCommand(new InvokeCommand('[data-drupal-selector="edit-editor-editor"]', $ckeditor5_selected_but_errors ? 'attr' : 'removeAttr', [
      'data-error-switching-to-ckeditor5',
      TRUE,
    ]));
    
    /*
     * Recursively find #attach items in the form and add as attachments to the
     * AJAX response.
     *
     * @param array $form
     *   A form array.
     * @param \Drupal\Core\Ajax\AjaxResponse $response
     *   The AJAX response attachments will be added to.
     */
    $attach = function (array $form, AjaxResponse &$response) use (&$attach) : void {
      foreach ($form as $key => $value) {
        if ($key === "#attached") {
          $response->addAttachments(array_diff_key($value, [
            'placeholders' => '',
          ]));
        }
        elseif (is_array($value) && !str_contains((string) $key, '#')) {
          $attach($value, $response);
        }
      }
    };
    $attach($form, $response);
    return $response;
  }
  
  /**
   * Retrieves the default theme's CKEditor 5 stylesheets.
   *
   * Themes may specify CSS files for use within CKEditor 5 by including a
   * "ckeditor5-stylesheets" key in their .info.yml file.
   *
   * @code
   * ckeditor5-stylesheets:
   *   - css/ckeditor.css
   * @endcode
   *
   * @param string|null $theme
   *   (optional) The theme. If omitted, the default theme is considered.
   *
   * @return string[]
   *   A list of paths to CSS files.
   */
  protected function themeCss(?string $theme = NULL) : array {
    $css = [];
    if (!isset($theme)) {
      $theme = $this->configFactory
        ->get('system.theme')
        ->get('default');
    }
    if (isset($theme) && $theme_path = $this->themeExtensionList
      ->getPath($theme)) {
      $info = $this->themeExtensionList
        ->getExtensionInfo($theme);
      if (isset($info['ckeditor5-stylesheets']) && $info['ckeditor5-stylesheets'] !== FALSE) {
        $css = $info['ckeditor5-stylesheets'];
        foreach ($css as $key => $url) {
          // CSS URL is external or relative to Drupal root.
          if (UrlHelper::isExternal($url) || $url[0] === '/') {
            $css[$key] = $url;
          }
          else {
            $css[$key] = '/' . $theme_path . '/' . $url;
          }
        }
      }
      if (isset($info['base theme'])) {
        $css = array_merge($this->themeCss($info['base theme']), $css);
      }
    }
    return $css;
  }

}

Members

Title Sort descending Modifiers Object type Summary
Ckeditor5Hooks::configSchemaInfoAlter public function Implements hook_config_schema_info_alter().
Ckeditor5Hooks::editorPresave public function Implements hook_ENTITY_TYPE_presave() for editor entities.
Ckeditor5Hooks::entityBundleInfoAlter public function Implements hook_entity_bundle_info_alter().
Ckeditor5Hooks::fieldWidgetSingleElementFormAlter public function Implements hook_field_widget_single_element_form_alter().
Ckeditor5Hooks::filterFormatEditFormSubmit public function Form submission handler for filter format forms.
Ckeditor5Hooks::formFilterFormatFormAlter public function Implements hook_form_FORM_ID_alter().
Ckeditor5Hooks::help public function Implements hook_help().
Ckeditor5Hooks::jsAlter public function Implements hook_js_alter().
Ckeditor5Hooks::libraryInfoAlter public function Implements hook_library_info_alter().
Ckeditor5Hooks::theme public function Implements hook_theme().
Ckeditor5Hooks::themeCss protected function Retrieves the default theme&#039;s CKEditor 5 stylesheets.
Ckeditor5Hooks::updateCkeditor5HtmlFilter public function AJAX callback handler for filter_format_form().
Ckeditor5Hooks::__construct public function
StringTranslationTrait::$stringTranslation protected property The string translation service.
StringTranslationTrait::formatPlural protected function Formats a string containing a count of items.
StringTranslationTrait::getNumberOfPlurals protected function Returns the number of plurals supported by a given language.
StringTranslationTrait::getStringTranslation protected function Gets the string translation service.
StringTranslationTrait::setStringTranslation public function Sets the string translation service to use.
StringTranslationTrait::t protected function Translates a string to the current language or to a given language.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.