editor.module 21 KB
Newer Older
1 2 3 4 5 6 7
<?php

/**
 * @file
 * Adds bindings for client-side "text editors" to text formats.
 */

8
use Drupal\Component\Utility\Html;
9
use Drupal\Core\Entity\FieldableEntityInterface;
10
use Drupal\Core\Field\FieldDefinitionInterface;
11
use Drupal\Core\Form\FormStateInterface;
12
use Drupal\Core\Render\Element;
13
use Drupal\Core\Routing\RouteMatchInterface;
14
use Drupal\Component\Utility\NestedArray;
15
use Drupal\Core\Entity\EntityInterface;
16 17
use Drupal\filter\FilterFormatInterface;
use Drupal\filter\Plugin\FilterInterface;
18 19 20 21

/**
 * Implements hook_help().
 */
22
function editor_help($route_name, RouteMatchInterface $route_match) {
23 24
  switch ($route_name) {
    case 'help.page.editor':
25 26
      $output = '';
      $output .= '<h3>' . t('About') . '</h3>';
27
      $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://drupal.org/documentation/modules/editor', '!ckeditor' => \Drupal::url('help.page', array('name' => 'ckeditor')))) . '</p>';
28 29
      $output .= '<h3>' . t('Uses') . '</h3>';
      $output .= '<dl>';
30
      $output .= '<dt>' . t('Installing text editors') . '</dt>';
31
      $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::url('help.page', array('name' => 'ckeditor')), '!extend' => \Drupal::url('system.modules_list'))) . '</dd>';
32
      $output .= '<dt>' . t('Enabling a text editor for a text format') . '</dt>';
33
      $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>';
34 35 36 37
      $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>';
38 39 40 41 42 43
      $output .= '</dl>';
      return $output;
  }
}

/**
44
 * Implements hook_menu_links_discovered_alter().
45 46 47 48
 *
 * Rewrites the menu entries for filter module that relate to the configuration
 * of text editors.
 */
49
function editor_menu_links_discovered_alter(array &$links) {
50
  $links['filter.admin_overview']['title'] = 'Text formats and editors';
51
  $links['filter.admin_overview']['description'] = 'Configure how user-contributed content is filtered and formatted, as well as the text editor user interface (WYSIWYGs or toolbars).';
52 53 54
}

/**
55
 * Implements hook_element_info_alter().
56 57 58 59 60 61 62
 *
 * 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 filter_element_info()
 */
63 64
function editor_element_info_alter(&$types) {
  $types['text_format']['#pre_render'][] = 'element.editor:preRenderTextFormat';
65 66 67 68 69
}

/**
 * Implements hook_form_FORM_ID_alter().
 */
70
function editor_form_filter_admin_overview_alter(&$form, FormStateInterface $form_state) {
71 72 73 74 75 76 77
  // @todo Cleanup column injection: http://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.
78
  $editors = \Drupal::service('plugin.manager.editor')->getDefinitions();
79
  foreach (Element::children($form['formats']) as $format_id) {
80
    $editor = editor_load($format_id);
81
    $editor_name = ($editor && isset($editors[$editor->getEditor()])) ? $editors[$editor->getEditor()]['label'] : drupal_placeholder('—');
82 83 84 85 86 87 88 89
    $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]);
  }
}

/**
90
 * Implements hook_form_BASE_FORM_ID_alter() for 'filter_format_form'.
91
 */
92
function editor_form_filter_format_form_alter(&$form, FormStateInterface $form_state) {
93 94
  $editor = $form_state->get('editor');
  if ($editor === NULL) {
95
    $format_id = $form_state->getFormObject()->getEntity()->id();
96 97
    $editor = editor_load($format_id);
    $form_state->set('editor', $editor);
98 99 100
  }

  // Associate a text editor with this text format.
101
  $manager = \Drupal::service('plugin.manager.editor');
102 103
  $editor_options = $manager->listOptions();
  $form['editor'] = array(
104 105 106 107 108
    // 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(
109 110 111 112
    '#type' => 'select',
    '#title' => t('Text editor'),
    '#options' => $editor_options,
    '#empty_option' => t('None'),
113
    '#default_value' => $editor ? $editor->getEditor() : '',
114 115 116 117 118
    '#ajax' => array(
      'trigger_as' => array('name' => 'editor_configure'),
      'callback' => 'editor_form_filter_admin_form_ajax',
      'wrapper' => 'editor-settings-wrapper',
    ),
119
    '#weight' => -10,
120
  );
121
  $form['editor']['configure'] = array(
122 123 124 125 126 127 128 129 130
    '#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',
    ),
131
    '#weight' => -10,
132 133 134 135 136
    '#attributes' => array('class' => array('js-hide')),
  );

  // If there aren't any options (other than "None"), disable the select list.
  if (empty($editor_options)) {
137 138
    $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.');
139 140
  }

141
  $form['editor']['settings'] = array(
142 143 144 145
    '#tree' => TRUE,
    '#weight' => -8,
    '#type' => 'container',
    '#id' => 'editor-settings-wrapper',
146 147
    '#attached' => array(
      'library' => array(
148
        'editor/drupal.editor.admin',
149 150
      ),
    ),
151 152 153 154
  );

  // Add editor-specific validation and submit handlers.
  if ($editor) {
155
    $plugin = $manager->createInstance($editor->getEditor());
156 157
    $settings_form = array();
    $settings_form['#element_validate'][] = array($plugin, 'settingsFormValidate');
158 159
    $form['editor']['settings']['subform'] = $plugin->settingsForm($settings_form, $form_state, $editor);
    $form['editor']['settings']['subform']['#parents'] = array('editor', 'settings');
160
    $form['actions']['submit']['#submit'][] = array($plugin, 'settingsFormSubmit');
161 162
  }

163
  $form['#validate'][] = 'editor_form_filter_admin_format_validate';
164
  $form['actions']['submit']['#submit'][] = 'editor_form_filter_admin_format_submit';
165 166 167
}

/**
168
 * Button submit handler for filter_format_form()'s 'editor_configure' button.
169
 */
170
function editor_form_filter_admin_format_editor_configure($form, FormStateInterface $form_state) {
171
  $editor = $form_state->get('editor');
172 173
  $editor_value = $form_state->getValue(array('editor', 'editor'));
  if ($editor_value !== NULL) {
174
    if ($editor_value === '') {
175
      $form_state->set('editor', FALSE);
176
    }
177
    elseif (empty($editor) || $editor_value !== $editor->getEditor()) {
178
      $editor = entity_create('editor', array(
179
        'format' => $form_state->getFormObject()->getEntity()->id(),
180
        'editor' => $editor_value,
181
      ));
182
      $form_state->set('editor', $editor);
183 184
    }
  }
185
  $form_state->setRebuild();
186 187 188
}

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

195
/**
196
 * Additional validate handler for filter_format_form().
197
 */
198
function editor_form_filter_admin_format_validate($form, FormStateInterface $form_state) {
199
  // This validate handler is not applicable when using the 'Configure' button.
200
  if ($form_state->getTriggeringElement()['#name'] === 'editor_configure') {
201 202 203 204 205 206 207
    return;
  }

  // When using this form with JavaScript disabled in the browser, the 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.
208
  if ($form_state->getValue(['editor', 'editor']) !== '' && !$form_state->get('editor')) {
209
    $form_state->setErrorByName('editor][editor', t('You must configure the selected text editor.'));
210 211 212
  }
}

213
/**
214
 * Additional submit handler for filter_format_form().
215
 */
216
function editor_form_filter_admin_format_submit($form, FormStateInterface $form_state) {
217
  // Delete the existing editor if disabling or switching between editors.
218
  $format_id = $form_state->getFormObject()->getEntity()->id();
219
  $original_editor = editor_load($format_id);
220
  if ($original_editor && $original_editor->getEditor() != $form_state->getValue(array('editor', 'editor'))) {
221 222 223 224
    $original_editor->delete();
  }

  // Create a new editor or update the existing editor.
225
  if ($editor = $form_state->get('editor')) {
226 227
    // Ensure the text format is set: when creating a new text format, this
    // would equal the empty string.
228 229 230
    $editor->set('format', $format_id);
    $editor->setSettings($form_state->getValue(['editor', 'settings']));
    $editor->save();
231 232 233 234 235 236
  }
}

/**
 * Loads an individual configured text editor based on text format ID.
 *
237
 * @return \Drupal\editor\Entity\Editor|null
238
 *   A text editor object, or NULL.
239 240 241 242 243 244 245
 */
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');
246
  return isset($editors[$format_id]) ? $editors[$format_id] : NULL;
247 248
}

249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280
/**
 * Applies text editor XSS filtering.
 *
 * @param string $html
 *   The HTML string that will be passed to the text editor.
 * @param \Drupal\filter\FilterFormatInterface $format
 *   The text format whose text editor will be used.
 * @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
 *   FALSE when no XSS filtering needs to be applied (either because no text
 *   editor is associated with the text format, or because the text editor is
 *   safe from XSS attacks, or because the text format does not use any XSS
 *   protection filters), otherwise the XSS filtered string.
 *
 * @see https://drupal.org/node/2099741
 */
function editor_filter_xss($html, FilterFormatInterface $format, FilterFormatInterface $original_format = NULL) {
  $editor = editor_load($format->id());

  // If no text editor is associated with this text format, 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.
281
  $definition = \Drupal::service('plugin.manager.editor')->getDefinition($editor->getEditor());
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319
  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;
    }
    // Unless we are switching from another text format, in which case we must
    // first check whether a filter preventing XSS attacks is used in that text
    // format, and if so, we must still apply XSS filtering.
    // e.g.: an anonymous user creates content in Restricted HTML, an admin user
    // edits it (then no XSS filtering is applied because no text editor is
    // used), and switches to Full HTML (for which a text editor is used). Then
    // we must apply XSS filtering to protect the admin user.
    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);
}

320 321 322 323
/**
 * Implements hook_entity_insert().
 */
function editor_entity_insert(EntityInterface $entity) {
324
  // Only act on content entities.
325
  if (!($entity instanceof FieldableEntityInterface)) {
326 327
    return;
  }
328 329 330 331 332 333 334 335 336 337
  $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) {
338
  // Only act on content entities.
339
  if (!($entity instanceof FieldableEntityInterface)) {
340 341 342
    return;
  }

343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376
  // 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);
    }
  }
  // On modified revisions, detect which file references have been added (and
  // record their usage) and which ones have been removed (delete their usage).
  // File references that existed both in the previous version of the revision
  // and in the new one don't need their usage to be updated.
  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) {
377
  // Only act on content entities.
378
  if (!($entity instanceof FieldableEntityInterface)) {
379 380
    return;
  }
381 382 383 384 385 386 387 388 389 390
  $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) {
391
  // Only act on content entities.
392
  if (!($entity instanceof FieldableEntityInterface)) {
393 394
    return;
  }
395 396 397 398 399 400 401
  $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);
  }
}

/**
402
 * Records file usage of files referenced by formatted text fields.
403 404 405 406 407 408 409 410 411 412 413
 *
 * 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) {
414 415 416 417 418 419
    if ($file = entity_load_by_uuid('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());
420 421 422 423 424
    }
  }
}

/**
425
 * Deletes file usage of files referenced by formatted text fields.
426 427 428 429 430 431 432 433 434
 *
 * @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.
 *
435
 * @see \Drupal\file\FileUsage\FileUsageInterface::delete()
436 437 438
 */
function _editor_delete_file_usage(array $uuids, EntityInterface $entity, $count) {
  foreach ($uuids as $uuid) {
439 440 441
    if ($file = entity_load_by_uuid('file', $uuid)) {
      \Drupal::service('file.usage')->delete($file, 'editor', $entity->getEntityTypeId(), $entity->id(), $count);
    }
442 443 444 445
  }
}

/**
446
 * Finds all files referenced (data-entity-uuid) by formatted text fields.
447 448 449 450 451 452 453 454 455 456
 *
 * @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();

457 458 459 460
  $formatted_text_fields = _editor_get_formatted_text_fields($entity);
  foreach ($formatted_text_fields as $formatted_text_field) {
    $text = $entity->get($formatted_text_field)->value;
    $uuids[$formatted_text_field] = _editor_parse_file_uuids($text);
461 462 463 464 465
  }
  return $uuids;
}

/**
466
 * Determines the formatted text fields on an entity.
467
 *
468
 * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
469 470 471
 *   An entity whose fields to analyze.
 *
 * @return array
472
 *   The names of the fields on this entity that support formatted text.
473
 */
474
function _editor_get_formatted_text_fields(FieldableEntityInterface $entity) {
475 476
  $field_definitions = $entity->getFieldDefinitions();
  if (empty($field_definitions)) {
477 478 479
    return array();
  }

480 481 482
  // 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);
483
  }));
484 485 486
}

/**
487
 * Parse an HTML snippet for any linked file with data-entity-uuid attributes.
488 489 490 491 492 493 494 495 496
 *
 * @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) {
497
  $dom = Html::load($text);
498 499
  $xpath = new \DOMXPath($dom);
  $uuids = array();
500 501
  foreach ($xpath->query('//*[@data-entity-type="file" and @data-entity-uuid]') as $node) {
    $uuids[] = $node->getAttribute('data-entity-uuid');
502 503 504
  }
  return $uuids;
}