ckeditor5-1.0.x-dev/ckeditor5.module
ckeditor5.module
<?php
/**
* @file
* Implements hooks for the CKEditor 5 module.
*/
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\PrependCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Symfony\Component\Validator\Constraints\Choice;
/**
* Implements hook_theme().
*/
function ckeditor5_theme() {
return [
'ckeditor5_settings_toolbar' => [
'render element' => 'form',
],
];
}
/**
* 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;
$media_offset = array_search('media', array_keys($implementations)) + 1;
$max = max([$offset, $media_offset]);
$implementations = array_slice($implementations, 0, $max, TRUE) +
['ckeditor5' => $group] +
array_slice($implementations, $offset, NULL, TRUE);
}
}
/**
* Implements hook_form_FORM_ID_alter().
*/
function ckeditor5_form_filter_format_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
$form['#validate'][] = 'ckeditor5_form_filter_admin_format_validate';
$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. If there is no fundamental
// compatibility problem.
// @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::buildConfigurationForm()
if ($editor && $editor->getEditor() === 'ckeditor5' && $form_state->get('ckeditor5_fundamentally_compatible')) {
if (isset($form['filters']['settings']['filter_html']['allowed_html'])) {
$filter_allowed_html = &$form['filters']['settings']['filter_html']['allowed_html'];
// Get a string of the HTML elements that must be allowed based on the
// active CKEditor 5 plugins, and make that the value of the 'Allowed
// HTML tags' field.
$plugin_required_elements = \Drupal::service('ckeditor5.admin_ui')->getRequiredElements($form_state);
$filter_allowed_html['#value'] = $plugin_required_elements;
$filter_allowed_html['#default_value'] = $plugin_required_elements;
// 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'] = 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.');
// The media_filter_format_edit_form_validate validator is not needed
// with CKEditor 5 as it exists to enforce the inclusion of specific
// allowed tags that are added automatically by CKEditor 5. The
// validator is removed so it does not conflict with the automatic
// addition of those allowed tags.
$key = array_search('media_filter_format_edit_form_validate', $form['#validate']);
if ($key !== FALSE) {
unset($form['#validate'][$key]);
}
}
// Replace editor_form_filter_admin_format_validate with a validator that
// still invokes editor_form_filter_admin_format_validate, but does not run
// if the triggering element is 'editor_configure'.
$editor_validate_offset = array_search('editor_form_filter_admin_format_validate', $form['#validate']);
if ($editor_validate_offset !== FALSE) {
$form['#validate'][$editor_validate_offset] = '_ckeditor5_editor_form_filter_admin_format_validate';
}
// Although validation errors should be limited on AJAX rebuilds, this must
// be accomplished using something other than #limit_validation_errors. That
// approach removes necessary values from $form_state, used for
// functionality such as automatic syncing of settings between CKEditor 5
// and the format's filter settings. The validation errors on AJAX are
// suppressed in _ckeditor5_editor_form_filter_admin_format_validate()
// instead.
unset($form['editor']['configure']['#limit_validation_errors']);
}
// Override the AJAX callbacks for changing editors, so multiple areas of the
// form can be updated on change.
$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')) {
/**
* 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.
*/
function _add_ajax_listeners_to_plugin_inputs(array &$plugins_config_form) : void {
$field_types = [
'checkbox',
'select',
'radios',
];
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($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']);
}
// Add an ID to the filter settings vertical tabs wrapper to facilitate AJAX
// updates.
// @todo consider moving this to editor.module when this module is moved to
// Drupal core.
$form['filter_settings']['#wrapper_attributes']['id'] = 'filter-settings-wrapper';
// Add an ID to the editor settings vertical tabs wrapper so it can be easily
// targeted by JavaScript.
// @todo consider moving this to editor.module when this module is moved to
// Drupal core.
$form['editor']['settings']['subform']['plugin_settings']['#wrapper_attributes']['id'] = 'plugin-settings-wrapper';
$editor_name = $editor ? $editor->getEditor() : NULL;
$form_state->set('ckeditor5_configured_editor', $editor_name);
$form['#validate'][] = 'ckeditor5_validate_switching_from_different_editor';
}
/**
* Validator to inform the user of CKEditor 5 compatibility problems.
*/
function ckeditor5_validate_switching_from_different_editor(array $form, FormStateInterface $form_state) {
$switching_from_a_different_editor = \Drupal::service('ckeditor5.admin_ui')->isSwitchingToCkeditor5($form_state);
if ($switching_from_a_different_editor) {
$submitted_editor = \Drupal::service('ckeditor5.admin_ui')->getSubmittedEditorAndFilterFormat($form_state);
$fundamental_incompatibilities = CKEditor5::validatePair($submitted_editor, $submitted_editor->getFilterFormat(), FALSE);
foreach ($fundamental_incompatibilities as $violation) {
// @codingStandardsIgnoreLine
$form_state->setErrorByName('editor][editor', t($violation->getMessageTemplate(), $violation->getParameters()));
}
}
}
/**
* Validator that is called instead of editor_form_filter_admin_format_validate.
*
* In CKEditor 5, when the editor form is updated via AJAX submit using the
* 'editor_configure' button, #limit_validation_errors' prevents validation for
* all fields except 'editor'. The editor_form_filter_admin_format_validate()
* is largely configured to bypass validation on AJAX submits triggered by
* 'editor_configure', but it still validates the 'editor' field's subforms.
* CKEditor 5's plugin subform has stricter validation that can fail unhelpfully
* if triggered via an AJAX update. This validator immediately checks if the
* form was triggered via 'editor_configure'. If not, then the originally set
* validator is called: editor_form_filter_admin_format_validate().
*/
function _ckeditor5_editor_form_filter_admin_format_validate($form, FormStateInterface $form_state) {
if ($form_state->getTriggeringElement()['#name'] === 'editor_configure') {
// This validator is only added to $form when the configured editor is
// CKEditor 5. It is still necessary to check the configured editor as this
// validator will be called when switching a format's editor from CKEditor 5
// to a different editor.
if ($form_state->getValues()['editor']['editor'] === 'ckeditor5') {
// Calling clearErrors() effectively disables validation on AJAX rebuilds.
// This is used instead of #limit_validation_errors in order to preserve
// the values in $form_state, as these values inform how the form is
// rebuilt.
$form_state->clearErrors();
}
else {
// @todo this condition is only reached when switching from CKEditor 5
// to another editor. For editors other than CKEditor 5, the expectation
// is for all elements within $form['editor'] to be validated. It seems
// like a reasonable expectation for that validation to return as soon
// as the format switches to a non-CK5 editor. However, that results in
// applying CKEditor 5's strict validation criteria, and errors will
// prevent the editor from switching, even if it's a setting only
// relevant to CKEditor 5. This whole "else" should either be removed,
// or this should validate based on the criteria for the editor being
// switched to.
// @see https://drupal.org/node/3218985
}
return;
}
editor_form_filter_admin_format_validate($form, $form_state);
}
/**
* 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) {
$switching_from_a_different_editor = \Drupal::service('ckeditor5.admin_ui')->isSwitchingToCkeditor5($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.
$renderedField = $renderer->render($form['editor']['settings']);
$response->addCommand(new ReplaceCommand('#editor-settings-wrapper', $renderedField));
// AJAX validation errors should appear visually close to the text editor
// since this is a very long form: otherwise they would not be noticed.
$response->addCommand(new PrependCommand('#editor-settings-wrapper', ['#type' => 'status_messages']));
// Do not rebuild the filter settings if switching to CKEditor 5 from another
// editor and there are validation errors.
if (!$switching_from_a_different_editor && empty($form_state->getErrors())) {
// 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 to the editor select, otherwise remove.
$response->addCommand(new InvokeCommand('[data-drupal-selector="edit-editor-editor"]', $switching_from_a_different_editor && !empty($form_state->getErrors()) ? 'addClass' : 'removeClass', ['error']));
if (!function_exists('_add_attachments_to_editor_update_response')) {
/**
* 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.
*/
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($key, '#') === FALSE) {
_add_attachments_to_editor_update_response($value, $response);
}
}
}
}
_add_attachments_to_editor_update_response($form, $response);
return $response;
}
/**
* Validator to validate conditions for individual CKEditor 5 plugins.
*/
function ckeditor5_form_filter_admin_format_validate(array $form, FormStateInterface $form_state) {
// @todo do we need to run after editor_form_filter_admin_format_validate()?
if ($form_state->getTriggeringElement()['#name'] !== 'op') {
return;
}
$editor = $form_state->getValue(['editor', 'editor']);
if ($editor !== 'ckeditor5' || !$form_state->get('editor')) {
return;
}
\Drupal::service('ckeditor5.admin_ui')->validateConditions($form_state);
}
/**
* Implements hook_library_info_alter().
*/
function ckeditor5_library_info_alter(&$libraries, $extension) {
if ($extension === 'filter') {
$libraries['drupal.filter.admin']['dependencies'][] = 'ckeditor5/ie11.filter.warnings';
$libraries['drupal.filter.admin']['dependencies'][] = 'ckeditor5/drupal.ckeditor5.filter.admin';
}
// If the 'ckeditor5/ie11.user.warnings' library is added as a dependency of
// other Ckeditor 5 libraries, it won't reliably work as the CKEditor 5 assets
// are loaded via AJAX, and the IE11-incompatible syntax in CKEditor 5 can
// prevent the AJAX call from successfully loading the functionality in
// 'ckeditor5/ie11.user.warnings'. Adding this as a dependency of
// 'system/base', as excessive as it may seem, is the most reliable way to
// assure it is loaded as part of the page request.
if ($extension === 'system') {
$libraries['base']['dependencies'][] = 'ckeditor5/ie11.user.warnings';
}
}
/**
* Implements hook_validation_constraint_alter().
*/
function ckeditor5_validation_constraint_alter(array &$definitions) {
// Add the Symfony validation constraints that Drupal core does not add in
// \Drupal\Core\Validation\ConstraintManager::registerDefinitions() for
// unknown reasons. Do it defensively, to not break when this changes.
if (!isset($definitions['Choice'])) {
$definitions['Choice'] = [
'label' => 'Choice',
'class' => Choice::class,
'type' => FALSE,
'provider' => 'core',
'id' => 'Choice',
];
}
}
/**
* Implements hook_config_schema_info_alter().
*/
function ckeditor5_config_schema_info_alter(&$definitions) {
// @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'];
}
