ckeditor5.module

Same filename in other branches
  1. 9 core/modules/ckeditor5/ckeditor5.module
  2. 10 core/modules/ckeditor5/ckeditor5.module

File

core/modules/ckeditor5/ckeditor5.module

View source
<?php


/**
 * @file
 * Implements hooks for the CKEditor 5 module.
 */
declare (strict_types=1);
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Ajax\PrependCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Ajax\RemoveCommand;
use Drupal\Core\Form\FormStateInterface;

/**
 * Implements hook_module_implements_alter().
 */
function ckeditor5_module_implements_alter(&$implementations, $hook) {
    // 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.
    if ($hook === 'form_alter' && isset($implementations['ckeditor5']) && isset($implementations['editor'])) {
        $group = $implementations['ckeditor5'];
        unset($implementations['ckeditor5']);
        $offset = array_search('editor', array_keys($implementations)) + 1;
        if (array_key_exists('media', $implementations)) {
            $media_offset = array_search('media', array_keys($implementations)) + 1;
            $offset = max([
                $offset,
                $media_offset,
            ]);
        }
        $implementations = array_slice($implementations, 0, $offset, TRUE) + [
            'ckeditor5' => $group,
        ] + array_slice($implementations, $offset, NULL, TRUE);
    }
}

/**
 * Form submission handler for filter format forms.
 */
function ckeditor5_filter_format_edit_form_submit(array $form, FormStateInterface $form_state) {
    $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.
 */
function _update_ckeditor5_html_filter(array $form, FormStateInterface $form_state) {
    $response = new AjaxResponse();
    $renderer = \Drupal::service('renderer');
    // 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 = $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 = \Drupal::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 = $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;
}

/**
 * Returns a list of language codes supported by CKEditor 5.
 *
 * @param string|bool $lang
 *   The Drupal langcode to match.
 *
 * @return array|mixed|string
 *   The associated CKEditor 5 langcode.
 */
function _ckeditor5_get_langcode_mapping($lang = FALSE) {
    // Cache the file system based language list calculation because this would
    // be expensive to calculate all the time. The cache is cleared on core
    // upgrades which is the only situation the CKEditor file listing should
    // change.
    $langcode_cache = \Drupal::cache()->get('ckeditor5.langcodes');
    if (!empty($langcode_cache)) {
        $langcodes = $langcode_cache->data;
    }
    if (empty($langcodes)) {
        $langcodes = [];
        // Collect languages included with CKEditor 5 based on file listing.
        $files = scandir('core/assets/vendor/ckeditor5/ckeditor5-dll/translations');
        foreach ($files as $file) {
            if (str_ends_with($file, '.js')) {
                $langcode = basename($file, '.js');
                $langcodes[$langcode] = $langcode;
            }
        }
        \Drupal::cache()->set('ckeditor5.langcodes', $langcodes);
    }
    // Get language mapping if available to map to Drupal language codes.
    // This is configurable in the user interface and not expensive to get, so
    // we don't include it in the cached language list.
    $language_mappings = \Drupal::moduleHandler()->moduleExists('language') ? language_get_browser_drupal_langcode_mappings() : [];
    foreach ($langcodes as $langcode) {
        // If this language code is available in a Drupal mapping, use that to
        // compute a possibility for matching from the Drupal langcode to the
        // CKEditor langcode.
        // For instance, CKEditor uses the langcode 'no' for Norwegian, Drupal
        // uses 'nb'. This would then remove the 'no' => 'no' mapping and
        // replace it with 'nb' => 'no'. Now Drupal knows which CKEditor
        // translation to load.
        if (isset($language_mappings[$langcode]) && !isset($langcodes[$language_mappings[$langcode]])) {
            $langcodes[$language_mappings[$langcode]] = $langcode;
            unset($langcodes[$langcode]);
        }
    }
    if ($lang) {
        return $langcodes[$lang] ?? 'en';
    }
    return $langcodes;
}

/**
 * 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
 *
 * @return string[]
 *   A list of paths to CSS files.
 */
function _ckeditor5_theme_css($theme = NULL) : array {
    $css = [];
    if (!isset($theme)) {
        $theme = \Drupal::config('system.theme')->get('default');
    }
    if (isset($theme) && ($theme_path = \Drupal::service('extension.list.theme')->getPath($theme))) {
        $info = \Drupal::service('extension.list.theme')->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(_ckeditor5_theme_css($info['base theme']), $css);
        }
    }
    return $css;
}

Functions

Title Deprecated Summary
ckeditor5_filter_format_edit_form_submit Form submission handler for filter format forms.
ckeditor5_module_implements_alter Implements hook_module_implements_alter().
_ckeditor5_get_langcode_mapping Returns a list of language codes supported by CKEditor 5.
_ckeditor5_theme_css Retrieves the default theme's CKEditor 5 stylesheets.
_update_ckeditor5_html_filter AJAX callback handler for filter_format_form().

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