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; } // CSS URL is relative to theme. else { $css[$key] = '/' . $theme_path . '/' . $url; } } } if (isset($info['base theme'])) { $css = array_merge(_ckeditor5_theme_css($info['base theme']), $css); } } return $css; }