ckeditor5.module
Same filename in other branches
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.