View source
<?php
declare (strict_types=1);
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
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\Asset\AttachedAssetsInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Symfony\Component\Validator\Constraints\Choice;
function ckeditor5_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.ckeditor5':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . 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 .= '<h3>' . t('Uses') . '</h3>';
$output .= '<dl>';
$output .= '<dt>' . t('Enabling CKEditor 5 for individual text formats') . '</dt>';
$output .= '<dd>' . t('CKEditor 5 has to be enabled 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>' . t('Configuring the toolbar') . '</dt>';
$output .= '<dd>' . 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>' . t('Filtering HTML content') . '</dt>';
$output .= '<dd>' . 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>' . t('Toggling between formatted text and HTML source') . '</dt>';
$output .= '<dd>' . 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>' . t('Developing CKEditor 5 plugins in Drupal') . '</dt>';
$output .= '<dd>' . 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>' . t('Accessibility features') . '</dt>';
$output .= '<dd>' . 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>' . t('Generating accessible content') . '</dt>';
$output .= '<dd>';
$output .= '<ul>';
$output .= '<li>' . t('Semantic HTML5 figure/figcaption are available to add captions to images.') . '</li>';
$output .= '<li>' . 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">' . t('Migrating an Existing Text Format to CKEditor 5') . '</h3>';
$output .= '<p>' . 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>' . 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>' . 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>' . t('Additional migration considerations for text formats with restricted HTML') . '</h4>';
$output .= '<dl>';
$output .= '<dt>' . t('The “Allowed HTML tags" field in the “Limit allowed HTML tags and correct Faulty HTML" filter is now read-only') . '</dt>';
$output .= '<dd>' . 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 <u> tag to “Allowed HTML tags".') . '</dd>';
$output .= '<dt id="required-tags">' . t('The <p> and <br > tags will be automatically added to your text format.') . '</dt>';
$output .= '<dd>' . t('CKEditor 5 requires the <p> and <br > 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">' . t('Tags/attributes that are not explicitly supported by any plugin are supported by Source Editing') . '</dt>';
$output .= '<dd>' . 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;
}
}
function ckeditor5_theme() {
return [
'ckeditor5_settings_toolbar' => [
'render element' => 'form',
],
];
}
function ckeditor5_module_implements_alter(&$implementations, $hook) {
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);
}
}
function ckeditor5_form_filter_format_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
$editor = $form_state
->get('editor');
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',
];
$filter_allowed_html['#attributes']['readonly'] = TRUE;
$filter_allowed_html['#wrapper_attributes']['class'][] = 'form-disabled';
$filter_allowed_html['#description'] = 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.');
$key = array_search('media_filter_format_edit_form_validate', $form['#validate']);
if ($key !== FALSE) {
unset($form['#validate'][$key]);
}
}
}
$form['editor']['editor']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => [
'name' => 'editor_configure',
],
];
$form['editor']['configure']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
];
$form['editor']['settings']['subform']['toolbar']['items']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'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' => '_update_ckeditor5_html_filter',
'trigger_as' => [
'name' => 'editor_configure',
],
'event' => 'change',
'ckeditor5_only' => 'true',
];
}
if (!function_exists('_add_ajax_listeners_to_plugin_inputs')) {
function _add_ajax_listeners_to_plugin_inputs(array &$plugins_config_form) : 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' => '_update_ckeditor5_html_filter',
'trigger_as' => [
'name' => 'editor_configure',
],
'event' => 'change',
'ckeditor5_only' => 'true',
];
}
foreach ($plugins_config_form as $key => &$value) {
if (is_array($value) && strpos((string) $key, '#') === FALSE) {
_add_ajax_listeners_to_plugin_inputs($value);
}
}
}
}
if (isset($form['editor']['settings']['subform']['plugins'])) {
_add_ajax_listeners_to_plugin_inputs($form['editor']['settings']['subform']['plugins']);
}
$form['filter_settings']['#wrapper_attributes']['id'] = 'filter-settings-wrapper';
if (!empty($form['editor']['settings']['subform']['plugins'])) {
$form['editor']['settings']['subform']['plugin_settings']['#wrapper_attributes']['id'] = 'plugin-settings-wrapper';
}
else {
$form['editor']['settings']['subform']['plugin_settings'] = [
'#type' => 'container',
'#attributes' => [
'id' => 'plugin-settings-wrapper',
],
];
}
$form['#after_build'][] = [
CKEditor5::class,
'assessActiveTextEditorAfterBuild',
];
$form['#validate'][] = [
CKEditor5::class,
'validateSwitchingToCKEditor5',
];
array_unshift($form['actions']['submit']['#submit'], 'ckeditor5_filter_format_edit_form_submit');
}
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)) {
$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);
}
}
function _update_ckeditor5_html_filter(array $form, FormStateInterface $form_state) {
$response = new AjaxResponse();
$renderer = \Drupal::service('renderer');
$renderedField = $renderer
->render($form['editor']['settings']);
if ($form_state
->get('ckeditor5_is_active') && $form_state
->get('ckeditor5_is_selected')) {
$response
->addCommand(new ReplaceCommand('#plugin-settings-wrapper', $form['editor']['settings']['subform']['plugin_settings']['#markup']));
}
else {
$response
->addCommand(new ReplaceCommand('#editor-settings-wrapper', $renderedField));
}
if ($form_state
->get('ckeditor5_is_active')) {
$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 {
$response
->addCommand(new PrependCommand('#editor-settings-wrapper', [
'#type' => 'status_messages',
]));
}
if ($form_state
->get('ckeditor5_is_active') || $form_state
->get('ckeditor5_is_selected') && !$form_state
->getError($form['editor']['editor'])) {
$renderedSettings = $renderer
->render($form['filter_settings']);
$response
->addCommand(new ReplaceCommand('#filter-settings-wrapper', $renderedSettings));
}
$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,
]));
if (!function_exists('_add_attachments_to_editor_update_response')) {
function _add_attachments_to_editor_update_response(array $form, AjaxResponse &$response) : void {
foreach ($form as $key => $value) {
if ($key === "#attached") {
$response
->addAttachments(array_diff_key($value, [
'placeholders' => '',
]));
}
elseif (is_array($value) && strpos((string) $key, '#') === FALSE) {
_add_attachments_to_editor_update_response($value, $response);
}
}
}
}
_add_attachments_to_editor_update_response($form, $response);
return $response;
}
function _ckeditor5_get_langcode_mapping($lang = FALSE) {
$langcode_cache = \Drupal::cache()
->get('ckeditor5.langcodes');
if (!empty($langcode_cache)) {
$langcodes = $langcode_cache->data;
}
if (empty($langcodes)) {
$langcodes = [];
$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);
}
$language_mappings = \Drupal::moduleHandler()
->moduleExists('language') ? language_get_browser_drupal_langcode_mappings() : [];
foreach ($langcodes as $langcode) {
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;
}
function ckeditor5_library_info_alter(&$libraries, $extension) {
if ($extension === 'filter') {
$libraries['drupal.filter.admin']['dependencies'][] = 'ckeditor5/drupal.ckeditor5.filter.admin';
}
$moduleHandler = \Drupal::moduleHandler();
if ($extension === 'ckeditor5') {
$css = _ckeditor5_theme_css();
$libraries['drupal.ckeditor5.stylesheets'] = [
'css' => [
'theme' => array_fill_keys(array_values($css), []),
],
];
}
if ($extension === 'core') {
$libraries['drupal.dialog']['css']['component']['modules/ckeditor5/css/ckeditor5.dialog.fix.css'] = [];
$libraries['drupal.dialog']['js']['modules/ckeditor5/js/ckeditor5.dialog.fix.js'] = [];
}
if (!$moduleHandler
->moduleExists('locale')) {
return;
}
$ckeditor_langcodes = array_values(_ckeditor5_get_langcode_mapping());
if ($extension === 'core') {
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',
],
];
}
}
if ($extension === 'core') {
$path = 'core';
}
else {
if ($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) {
if (empty($library['js']) || empty($library['dependencies']) || !in_array('core/ckeditor5.translations', $library['dependencies'])) {
continue;
}
foreach ($library['js'] as $file => $options) {
if (!empty($options['type']) && $options['type'] === 'external') {
continue;
}
$dirname = dirname($file);
$dir = $path . '/' . $dirname;
if (str_starts_with($dirname, '//')) {
continue;
}
$files = scandir("{$dir}/translations");
foreach ($files as $file) {
if (str_ends_with($file, '.js')) {
$langcode = basename($file, '.js');
if (in_array($langcode, $ckeditor_langcodes)) {
$library['js']["{$dirname}/translations/{$langcode}.js"] = [
'ckeditor5_langcode' => $langcode,
'minified' => TRUE,
'preprocess' => TRUE,
];
}
}
}
}
}
}
function ckeditor5_js_alter(&$javascript, AttachedAssetsInterface $assets) {
$placeholder_file = 'core/assets/vendor/ckeditor5/translation.js';
$ckeditor_dll_file = 'core/assets/vendor/ckeditor5/ckeditor5-dll/ckeditor5-dll.js';
if (isset($javascript[$placeholder_file])) {
$default_weight = $javascript[$placeholder_file]['weight'];
if (isset($javascript[$ckeditor_dll_file])) {
$default_weight = $javascript[$ckeditor_dll_file]['weight'];
}
unset($javascript[$placeholder_file]);
if (!\Drupal::moduleHandler()
->moduleExists('locale')) {
return;
}
$language_interface = \Drupal::languageManager()
->getCurrentLanguage()
->getId();
$ckeditor5_language = _ckeditor5_get_langcode_mapping($language_interface);
foreach ($javascript as $index => &$item) {
if (empty($item['ckeditor5_langcode'])) {
continue;
}
if ($item['ckeditor5_langcode'] === $ckeditor5_language) {
$item['weight'] = $default_weight;
}
else {
unset($javascript[$index]);
}
}
}
}
function ckeditor5_validation_constraint_alter(array &$definitions) {
if (!isset($definitions['Choice'])) {
$definitions['Choice'] = [
'label' => 'Choice',
'class' => Choice::class,
'type' => FALSE,
'provider' => 'core',
'id' => 'Choice',
];
}
}
function ckeditor5_config_schema_info_alter(&$definitions) {
if (!isset($definitions['ckeditor5_valid_pair__format_and_editor'])) {
return;
}
$definitions['ckeditor5_valid_pair__format_and_editor']['mapping']['filters'] = $definitions['filter.format.*']['mapping']['filters'];
$definitions['ckeditor5_valid_pair__format_and_editor']['mapping']['image_upload'] = $definitions['editor.editor.*']['mapping']['image_upload'];
}
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'])) {
$css = $info['ckeditor5-stylesheets'];
foreach ($css as $key => $url) {
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;
}