You are here

editor.module in Zircon Profile 8.0

Same filename and directory in other branches
  1. 8 core/modules/editor/editor.module

Adds bindings for client-side "text editors" to text formats.

File

core/modules/editor/editor.module
View source
<?php

/**
 * @file
 * Adds bindings for client-side "text editors" to text formats.
 */
use Drupal\Component\Utility\Html;
use Drupal\editor\Entity\Editor;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Entity\EntityInterface;
use Drupal\filter\FilterFormatInterface;
use Drupal\filter\Plugin\FilterInterface;

/**
 * Implements hook_help().
 */
function editor_help($route_name, RouteMatchInterface $route_match) {
  switch ($route_name) {
    case 'help.page.editor':
      $output = '';
      $output .= '<h3>' . t('About') . '</h3>';
      $output .= '<p>' . t('The Text Editor module provides a framework that other modules (such as <a href=":ckeditor">CKEditor module</a>) can use to provide toolbars and other functionality that allow users to format text more easily than typing HTML tags directly. For more information, see the <a href=":documentation">online documentation for the Text Editor module</a>.', array(
        ':documentation' => 'https://www.drupal.org/documentation/modules/editor',
        ':ckeditor' => \Drupal::moduleHandler()
          ->moduleExists('ckeditor') ? \Drupal::url('help.page', array(
          'name' => 'ckeditor',
        )) : '#',
      )) . '</p>';
      $output .= '<h3>' . t('Uses') . '</h3>';
      $output .= '<dl>';
      $output .= '<dt>' . t('Installing text editors') . '</dt>';
      $output .= '<dd>' . t('The Text Editor module provides a framework for managing editors. To use it, you also need to enable a text editor. This can either be the core <a href=":ckeditor">CKEditor module</a>, which can be enabled on the <a href=":extend">Extend page</a>, or a contributed module for any other text editor. When installing a contributed text editor module, be sure to check the installation instructions, because you will most likely need to download and install an external library as well as the Drupal module.', array(
        ':ckeditor' => \Drupal::moduleHandler()
          ->moduleExists('ckeditor') ? \Drupal::url('help.page', array(
          'name' => 'ckeditor',
        )) : '#',
        ':extend' => \Drupal::url('system.modules_list'),
      )) . '</dd>';
      $output .= '<dt>' . t('Enabling a text editor for a text format') . '</dt>';
      $output .= '<dd>' . t('On the <a href=":formats">Text formats and editors page</a> you can see which text editor is associated with each text format. You can change this by clicking on the <em>Configure</em> link, and then choosing a text editor or <em>none</em> from the <em>Text editor</em> drop-down list. The text editor will then be displayed with any text field for which this text format is chosen.', array(
        ':formats' => \Drupal::url('filter.admin_overview'),
      )) . '</dd>';
      $output .= '<dt>' . t('Configuring a text editor') . '</dt>';
      $output .= '<dd>' . t('Once a text editor is associated with a text format, you can configure it by clicking on the <em>Configure</em> link for this format. Depending on the specific text editor, you can configure it for example by adding buttons to its toolbar. Typically these buttons provide formatting or editing tools, and they often insert HTML tags into the field source. For details, see the help page of the specific text editor.') . '</dd>';
      $output .= '<dt>' . t('Using different text editors and formats') . '</dt>';
      $output .= '<dd>' . t('If you change the text format on a text field, the text editor will change as well because the text editor configuration is associated with the individual text format. This allows the use of the same text editor with different options for different text formats. It also allows users to choose between text formats with different text editors if they are installed.') . '</dd>';
      $output .= '</dl>';
      return $output;
  }
}

/**
 * Implements hook_menu_links_discovered_alter().
 *
 * Rewrites the menu entries for filter module that relate to the configuration
 * of text editors.
 */
function editor_menu_links_discovered_alter(array &$links) {
  $links['filter.admin_overview']['title'] = new TranslatableMarkup('Text formats and editors');
  $links['filter.admin_overview']['description'] = new TranslatableMarkup('Select and configure text editors, and how content is filtered when displayed.');
}

/**
 * Implements hook_element_info_alter().
 *
 * Extends the functionality of text_format elements (provided by Filter
 * module), so that selecting a text format notifies a client-side text editor
 * when it should be enabled or disabled.
 *
 * @see \Drupal\filter\Element\TextFormat
 */
function editor_element_info_alter(&$types) {
  $types['text_format']['#pre_render'][] = 'element.editor:preRenderTextFormat';
}

/**
 * Implements hook_form_FORM_ID_alter().
 */
function editor_form_filter_admin_overview_alter(&$form, FormStateInterface $form_state) {

  // @todo Cleanup column injection: https://www.drupal.org/node/1876718.
  // Splice in the column for "Text editor" into the header.
  $position = array_search('name', $form['formats']['#header']) + 1;
  $start = array_splice($form['formats']['#header'], 0, $position, array(
    'editor' => t('Text editor'),
  ));
  $form['formats']['#header'] = array_merge($start, $form['formats']['#header']);

  // Then splice in the name of each text editor for each text format.
  $editors = \Drupal::service('plugin.manager.editor')
    ->getDefinitions();
  foreach (Element::children($form['formats']) as $format_id) {
    $editor = editor_load($format_id);
    $editor_name = $editor && isset($editors[$editor
      ->getEditor()]) ? $editors[$editor
      ->getEditor()]['label'] : '—';
    $editor_column['editor'] = array(
      '#markup' => $editor_name,
    );
    $position = array_search('name', array_keys($form['formats'][$format_id])) + 1;
    $start = array_splice($form['formats'][$format_id], 0, $position, $editor_column);
    $form['formats'][$format_id] = array_merge($start, $form['formats'][$format_id]);
  }
}

/**
 * Implements hook_form_BASE_FORM_ID_alter() for 'filter_format_form'.
 */
function editor_form_filter_format_form_alter(&$form, FormStateInterface $form_state) {
  $editor = $form_state
    ->get('editor');
  if ($editor === NULL) {
    $format = $form_state
      ->getFormObject()
      ->getEntity();
    $format_id = $format
      ->isNew() ? NULL : $format
      ->id();
    $editor = editor_load($format_id);
    $form_state
      ->set('editor', $editor);
  }

  // Associate a text editor with this text format.
  $manager = \Drupal::service('plugin.manager.editor');
  $editor_options = $manager
    ->listOptions();
  $form['editor'] = array(
    // Position the editor selection before the filter settings (weight of 0),
    // but after the filter label and name (weight of -20).
    '#weight' => -9,
  );
  $form['editor']['editor'] = array(
    '#type' => 'select',
    '#title' => t('Text editor'),
    '#options' => $editor_options,
    '#empty_option' => t('None'),
    '#default_value' => $editor ? $editor
      ->getEditor() : '',
    '#ajax' => array(
      'trigger_as' => array(
        'name' => 'editor_configure',
      ),
      'callback' => 'editor_form_filter_admin_form_ajax',
      'wrapper' => 'editor-settings-wrapper',
    ),
    '#weight' => -10,
  );
  $form['editor']['configure'] = array(
    '#type' => 'submit',
    '#name' => 'editor_configure',
    '#value' => t('Configure'),
    '#limit_validation_errors' => array(
      array(
        'editor',
      ),
    ),
    '#submit' => array(
      'editor_form_filter_admin_format_editor_configure',
    ),
    '#ajax' => array(
      'callback' => 'editor_form_filter_admin_form_ajax',
      'wrapper' => 'editor-settings-wrapper',
    ),
    '#weight' => -10,
    '#attributes' => array(
      'class' => array(
        'js-hide',
      ),
    ),
  );

  // If there aren't any options (other than "None"), disable the select list.
  if (empty($editor_options)) {
    $form['editor']['editor']['#disabled'] = TRUE;
    $form['editor']['editor']['#description'] = t('This option is disabled because no modules that provide a text editor are currently enabled.');
  }
  $form['editor']['settings'] = array(
    '#tree' => TRUE,
    '#weight' => -8,
    '#type' => 'container',
    '#id' => 'editor-settings-wrapper',
    '#attached' => array(
      'library' => array(
        'editor/drupal.editor.admin',
      ),
    ),
  );

  // Add editor-specific validation and submit handlers.
  if ($editor) {
    $plugin = $manager
      ->createInstance($editor
      ->getEditor());
    $settings_form = array();
    $settings_form['#element_validate'][] = array(
      $plugin,
      'settingsFormValidate',
    );
    $form['editor']['settings']['subform'] = $plugin
      ->settingsForm($settings_form, $form_state, $editor);
    $form['editor']['settings']['subform']['#parents'] = array(
      'editor',
      'settings',
    );
    $form['actions']['submit']['#submit'][] = array(
      $plugin,
      'settingsFormSubmit',
    );
  }
  $form['#validate'][] = 'editor_form_filter_admin_format_validate';
  $form['actions']['submit']['#submit'][] = 'editor_form_filter_admin_format_submit';
}

/**
 * Button submit handler for filter_format_form()'s 'editor_configure' button.
 */
function editor_form_filter_admin_format_editor_configure($form, FormStateInterface $form_state) {
  $editor = $form_state
    ->get('editor');
  $editor_value = $form_state
    ->getValue(array(
    'editor',
    'editor',
  ));
  if ($editor_value !== NULL) {
    if ($editor_value === '') {
      $form_state
        ->set('editor', FALSE);
    }
    elseif (empty($editor) || $editor_value !== $editor
      ->getEditor()) {
      $format = $form_state
        ->getFormObject()
        ->getEntity();
      $editor = entity_create('editor', array(
        'format' => $format
          ->isNew() ? NULL : $format
          ->id(),
        'editor' => $editor_value,
      ));
      $form_state
        ->set('editor', $editor);
    }
  }
  $form_state
    ->setRebuild();
}

/**
 * AJAX callback handler for filter_format_form().
 */
function editor_form_filter_admin_form_ajax($form, FormStateInterface $form_state) {
  return $form['editor']['settings'];
}

/**
 * Additional validate handler for filter_format_form().
 */
function editor_form_filter_admin_format_validate($form, FormStateInterface $form_state) {

  // This validate handler is not applicable when using the 'Configure' button.
  if ($form_state
    ->getTriggeringElement()['#name'] === 'editor_configure') {
    return;
  }

  // When using this form with JavaScript disabled in the browser, the
  // 'Configure' button won't be clicked automatically. So, when the user has
  // selected a text editor and has then clicked 'Save configuration', we should
  // point out that the user must still configure the text editor.
  if ($form_state
    ->getValue([
    'editor',
    'editor',
  ]) !== '' && !$form_state
    ->get('editor')) {
    $form_state
      ->setErrorByName('editor][editor', t('You must configure the selected text editor.'));
  }
}

/**
 * Additional submit handler for filter_format_form().
 */
function editor_form_filter_admin_format_submit($form, FormStateInterface $form_state) {

  // Delete the existing editor if disabling or switching between editors.
  $format = $form_state
    ->getFormObject()
    ->getEntity();
  $format_id = $format
    ->isNew() ? NULL : $format
    ->id();
  $original_editor = editor_load($format_id);
  if ($original_editor && $original_editor
    ->getEditor() != $form_state
    ->getValue(array(
    'editor',
    'editor',
  ))) {
    $original_editor
      ->delete();
  }

  // Create a new editor or update the existing editor.
  if ($editor = $form_state
    ->get('editor')) {

    // Ensure the text format is set: when creating a new text format, this
    // would equal the empty string.
    $editor
      ->set('format', $format_id);
    $editor
      ->setSettings($form_state
      ->getValue([
      'editor',
      'settings',
    ]));
    $editor
      ->save();
  }
}

/**
 * Loads an individual configured text editor based on text format ID.
 *
 * @return \Drupal\editor\Entity\Editor|null
 *   A text editor object, or NULL.
 */
function editor_load($format_id) {

  // Load all the editors at once here, assuming that either no editors or more
  // than one editor will be needed on a page (such as having multiple text
  // formats for administrators). Loading a small number of editors all at once
  // is more efficient than loading multiple editors individually.
  $editors = entity_load_multiple('editor');
  return isset($editors[$format_id]) ? $editors[$format_id] : NULL;
}

/**
 * Applies text editor XSS filtering.
 *
 * @param string $html
 *   The HTML string that will be passed to the text editor.
 * @param \Drupal\filter\FilterFormatInterface|null $format
 *   The text format whose text editor will be used or NULL if the previously
 *   defined text format is now disabled.
 * @param \Drupal\filter\FilterFormatInterface $original_format|null
 *   (optional) The original text format (i.e. when switching text formats,
 *   $format is the text format that is going to be used, $original_format is
 *   the one that was being used initially, the one that is stored in the
 *   database when editing).
 *
 * @return string|false
 *   The XSS filtered string or FALSE when no XSS filtering needs to be applied,
 *   because one of the next conditions might occur:
 *   - No text editor is associated with the text format,
 *   - The previously defined text format is now disabled,
 *   - The text editor is safe from XSS,
 *   - The text format does not use any XSS protection filters.
 *
 * @see https://www.drupal.org/node/2099741
 */
function editor_filter_xss($html, FilterFormatInterface $format = NULL, FilterFormatInterface $original_format = NULL) {
  $editor = $format ? editor_load($format
    ->id()) : NULL;

  // If no text editor is associated with this text format or the previously
  // defined text format is now disabled, then we don't need text editor XSS
  // filtering either.
  if (!isset($editor)) {
    return FALSE;
  }

  // If the text editor associated with this text format guarantees security,
  // then we also don't need text editor XSS filtering.
  $definition = \Drupal::service('plugin.manager.editor')
    ->getDefinition($editor
    ->getEditor());
  if ($definition['is_xss_safe'] === TRUE) {
    return FALSE;
  }

  // If there is no filter preventing XSS attacks in the text format being used,
  // then no text editor XSS filtering is needed either. (Because then the
  // editing user can already be attacked by merely viewing the content.)
  // e.g.: an admin user creates content in Full HTML and then edits it, no text
  // format switching happens; in this case, no text editor XSS filtering is
  // desirable, because it would strip style attributes, amongst others.
  $current_filter_types = $format
    ->getFilterTypes();
  if (!in_array(FilterInterface::TYPE_HTML_RESTRICTOR, $current_filter_types, TRUE)) {
    if ($original_format === NULL) {
      return FALSE;
    }
    else {
      $original_filter_types = $original_format
        ->getFilterTypes();
      if (!in_array(FilterInterface::TYPE_HTML_RESTRICTOR, $original_filter_types, TRUE)) {
        return FALSE;
      }
    }
  }

  // Otherwise, apply the text editor XSS filter. We use the default one unless
  // a module tells us to use a different one.
  $editor_xss_filter_class = '\\Drupal\\editor\\EditorXssFilter\\Standard';
  \Drupal::moduleHandler()
    ->alter('editor_xss_filter', $editor_xss_filter_class, $format, $original_format);
  return call_user_func($editor_xss_filter_class . '::filterXss', $html, $format, $original_format);
}

/**
 * Implements hook_entity_insert().
 */
function editor_entity_insert(EntityInterface $entity) {

  // Only act on content entities.
  if (!$entity instanceof FieldableEntityInterface) {
    return;
  }
  $referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
  foreach ($referenced_files_by_field as $field => $uuids) {
    _editor_record_file_usage($uuids, $entity);
  }
}

/**
 * Implements hook_entity_update().
 */
function editor_entity_update(EntityInterface $entity) {

  // Only act on content entities.
  if (!$entity instanceof FieldableEntityInterface) {
    return;
  }

  // On new revisions, all files are considered to be a new usage and no
  // deletion of previous file usages are necessary.
  if (!empty($entity->original) && $entity
    ->getRevisionId() != $entity->original
    ->getRevisionId()) {
    $referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
    foreach ($referenced_files_by_field as $field => $uuids) {
      _editor_record_file_usage($uuids, $entity);
    }
  }
  else {
    $original_uuids_by_field = _editor_get_file_uuids_by_field($entity->original);
    $uuids_by_field = _editor_get_file_uuids_by_field($entity);

    // Detect file usages that should be incremented.
    foreach ($uuids_by_field as $field => $uuids) {
      $added_files = array_diff($uuids_by_field[$field], $original_uuids_by_field[$field]);
      _editor_record_file_usage($added_files, $entity);
    }

    // Detect file usages that should be decremented.
    foreach ($original_uuids_by_field as $field => $uuids) {
      $removed_files = array_diff($original_uuids_by_field[$field], $uuids_by_field[$field]);
      _editor_delete_file_usage($removed_files, $entity, 1);
    }
  }
}

/**
 * Implements hook_entity_delete().
 */
function editor_entity_delete(EntityInterface $entity) {

  // Only act on content entities.
  if (!$entity instanceof FieldableEntityInterface) {
    return;
  }
  $referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
  foreach ($referenced_files_by_field as $field => $uuids) {
    _editor_delete_file_usage($uuids, $entity, 0);
  }
}

/**
 * Implements hook_entity_revision_delete().
 */
function editor_entity_revision_delete(EntityInterface $entity) {

  // Only act on content entities.
  if (!$entity instanceof FieldableEntityInterface) {
    return;
  }
  $referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
  foreach ($referenced_files_by_field as $field => $uuids) {
    _editor_delete_file_usage($uuids, $entity, 1);
  }
}

/**
 * Records file usage of files referenced by formatted text fields.
 *
 * Every referenced file that does not yet have the FILE_STATUS_PERMANENT state,
 * will be given that state.
 *
 * @param array $uuids
 *   An array of file entity UUIDs.
 * @param EntityInterface $entity
 *   An entity whose fields to inspect for file references.
 */
function _editor_record_file_usage(array $uuids, EntityInterface $entity) {
  foreach ($uuids as $uuid) {
    if ($file = \Drupal::entityManager()
      ->loadEntityByUuid('file', $uuid)) {
      if ($file->status !== FILE_STATUS_PERMANENT) {
        $file->status = FILE_STATUS_PERMANENT;
        $file
          ->save();
      }
      \Drupal::service('file.usage')
        ->add($file, 'editor', $entity
        ->getEntityTypeId(), $entity
        ->id());
    }
  }
}

/**
 * Deletes file usage of files referenced by formatted text fields.
 *
 * @param array $uuids
 *   An array of file entity UUIDs.
 * @param EntityInterface $entity
 *   An entity whose fields to inspect for file references.
 * @param $count
 *   The number of references to delete. Should be 1 when deleting a single
 *   revision and 0 when deleting an entity entirely.
 *
 * @see \Drupal\file\FileUsage\FileUsageInterface::delete()
 */
function _editor_delete_file_usage(array $uuids, EntityInterface $entity, $count) {
  foreach ($uuids as $uuid) {
    if ($file = \Drupal::entityManager()
      ->loadEntityByUuid('file', $uuid)) {
      \Drupal::service('file.usage')
        ->delete($file, 'editor', $entity
        ->getEntityTypeId(), $entity
        ->id(), $count);
    }
  }
}

/**
 * Finds all files referenced (data-entity-uuid) by formatted text fields.
 *
 * @param EntityInterface $entity
 *   An entity whose fields to analyze.
 *
 * @return array
 *   An array of file entity UUIDs.
 */
function _editor_get_file_uuids_by_field(EntityInterface $entity) {
  $uuids = array();
  $formatted_text_fields = _editor_get_formatted_text_fields($entity);
  foreach ($formatted_text_fields as $formatted_text_field) {
    $text = '';
    $field_items = $entity
      ->get($formatted_text_field);
    foreach ($field_items as $field_item) {
      $text .= $field_item->value;
    }
    $uuids[$formatted_text_field] = _editor_parse_file_uuids($text);
  }
  return $uuids;
}

/**
 * Determines the formatted text fields on an entity.
 *
 * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
 *   An entity whose fields to analyze.
 *
 * @return array
 *   The names of the fields on this entity that support formatted text.
 */
function _editor_get_formatted_text_fields(FieldableEntityInterface $entity) {
  $field_definitions = $entity
    ->getFieldDefinitions();
  if (empty($field_definitions)) {
    return array();
  }

  // Only return formatted text fields.
  return array_keys(array_filter($field_definitions, function (FieldDefinitionInterface $definition) {
    return in_array($definition
      ->getType(), array(
      'text',
      'text_long',
      'text_with_summary',
    ), TRUE);
  }));
}

/**
 * Parse an HTML snippet for any linked file with data-entity-uuid attributes.
 *
 * @param string $text
 *   The partial (X)HTML snippet to load. Invalid markup will be corrected on
 *   import.
 *
 * @return array
 *   An array of all found UUIDs.
 */
function _editor_parse_file_uuids($text) {
  $dom = Html::load($text);
  $xpath = new \DOMXPath($dom);
  $uuids = array();
  foreach ($xpath
    ->query('//*[@data-entity-type="file" and @data-entity-uuid]') as $node) {
    $uuids[] = $node
      ->getAttribute('data-entity-uuid');
  }
  return $uuids;
}

/**
 * Implements hook_ENTITY_TYPE_presave().
 *
 * Synchronizes the editor status to its paired text format status.
 */
function editor_filter_format_presave(FilterFormatInterface $format) {

  // The text format being created cannot have a text editor yet.
  if ($format
    ->isNew()) {
    return;
  }

  /** @var \Drupal\filter\FilterFormatInterface $original */
  $original = \Drupal::entityManager()
    ->getStorage('filter_format')
    ->loadUnchanged($format
    ->getOriginalId());

  // If the text format status is the same, return early.
  if (($status = $format
    ->status()) === $original
    ->status()) {
    return;
  }

  /** @var \Drupal\editor\EditorInterface $editor */
  if ($editor = Editor::load($format
    ->id())) {
    $editor
      ->setStatus($status)
      ->save();
  }
}

Functions

Namesort descending Description
editor_element_info_alter Implements hook_element_info_alter().
editor_entity_delete Implements hook_entity_delete().
editor_entity_insert Implements hook_entity_insert().
editor_entity_revision_delete Implements hook_entity_revision_delete().
editor_entity_update Implements hook_entity_update().
editor_filter_format_presave Implements hook_ENTITY_TYPE_presave().
editor_filter_xss Applies text editor XSS filtering.
editor_form_filter_admin_format_editor_configure Button submit handler for filter_format_form()'s 'editor_configure' button.
editor_form_filter_admin_format_submit Additional submit handler for filter_format_form().
editor_form_filter_admin_format_validate Additional validate handler for filter_format_form().
editor_form_filter_admin_form_ajax AJAX callback handler for filter_format_form().
editor_form_filter_admin_overview_alter Implements hook_form_FORM_ID_alter().
editor_form_filter_format_form_alter Implements hook_form_BASE_FORM_ID_alter() for 'filter_format_form'.
editor_help Implements hook_help().
editor_load Loads an individual configured text editor based on text format ID.
editor_menu_links_discovered_alter Implements hook_menu_links_discovered_alter().
_editor_delete_file_usage Deletes file usage of files referenced by formatted text fields.
_editor_get_file_uuids_by_field Finds all files referenced (data-entity-uuid) by formatted text fields.
_editor_get_formatted_text_fields Determines the formatted text fields on an entity.
_editor_parse_file_uuids Parse an HTML snippet for any linked file with data-entity-uuid attributes.
_editor_record_file_usage Records file usage of files referenced by formatted text fields.