editor.module 22.2 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\Core\Entity\ContentEntityInterface;
9
use Drupal\editor\Entity\Editor;
10
use Drupal\Component\Utility\NestedArray;
11 12
use Drupal\Core\Entity\EntityInterface;
use Drupal\field\Field;
13 14 15 16 17 18 19 20 21

/**
 * Implements hook_help().
 */
function editor_help($path, $arg) {
  switch ($path) {
    case 'admin/help#editor':
      $output = '';
      $output .= '<h3>' . t('About') . '</h3>';
22
      $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' => url('admin/help/ckeditor'))) . '</p>';
23 24
      $output .= '<h3>' . t('Uses') . '</h3>';
      $output .= '<dl>';
25 26 27 28 29 30 31 32 33
      $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' => url('admin/help/ckeditor'), '@extend' => url('admin/modules'))) . '</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' => url('admin/config/content/formats'))) . '</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>';
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
      $output .= '</dl>';
      return $output;
  }
}

/**
 * Implements hook_menu_alter().
 *
 * Rewrites the menu entries for filter module that relate to the configuration
 * of text editors.
 */
function editor_menu_alter(&$items) {
  $items['admin/config/content/formats']['title'] = 'Text formats and editors';
  $items['admin/config/content/formats']['description'] = 'Configure how user-contributed content is filtered and formatted, as well as the text editor user interface (WYSIWYGs or toolbars).';
}

/**
 * Implements hook_element_info().
 *
 * 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()
 */
function editor_element_info() {
  $type['text_format'] = array(
    '#pre_render' => array('editor_pre_render_format'),
  );
  return $type;
}

/**
 * Implements hook_library_info().
 */
function editor_library_info() {
  $path = drupal_get_path('module', 'editor');
71 72 73

  $libraries['drupal.editor.admin'] = array(
    'title' => 'Text Editor',
74
    'version' => \Drupal::VERSION,
75 76 77 78 79 80 81 82
    'js' => array(
      $path . '/js/editor.admin.js' => array(),
    ),
    'dependencies' => array(
      array('system', 'jquery'),
      array('system', 'drupal'),
    ),
  );
83 84
  $libraries['drupal.editor'] = array(
    'title' => 'Text Editor',
85
    'version' => \Drupal::VERSION,
86 87 88
    'js' => array(
      $path . '/js/editor.js' => array(),
    ),
89 90 91
    'css' => array(
      $path . '/css/editor.css' => array(),
    ),
92 93 94 95 96 97 98
    'dependencies' => array(
      array('system', 'jquery'),
      array('system', 'drupal'),
      array('system', 'drupalSettings'),
      array('system', 'jquery.once'),
    ),
  );
99

100 101
  $libraries['drupal.editor.dialog'] = array(
    'title' => 'Text Editor Dialog',
102
    'version' => \Drupal::VERSION,
103 104 105 106 107 108 109 110 111 112 113
    'js' => array(
      $path . '/js/editor.dialog.js' => array('weight' => 2),
    ),
    'dependencies' => array(
      array('system', 'jquery'),
      array('system', 'drupal.dialog'),
      array('system', 'drupal.ajax'),
      array('system', 'drupalSettings'),
    ),
  );

114 115
  $libraries['edit.formattedTextEditor.editor'] = array(
    'title' => 'Formatted text editor',
116
    'version' => \Drupal::VERSION,
117
    'js' => array(
118
      $path . '/js/editor.formattedTextEditor.js' => array(
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137
        'scope' => 'footer',
        'attributes' => array('defer' => TRUE),
      ),
      array(
        'type' => 'setting',
        'data' => array(
          'editor' => array(
            'getUntransformedTextURL' => url('editor/!entity_type/!id/!field_name/!langcode/!view_mode'),
          )
        )
      ),
    ),
    'dependencies' => array(
      array('edit', 'edit'),
      array('editor', 'drupal.editor'),
      array('system', 'drupal.ajax'),
      array('system', 'drupalSettings'),
    ),
  );
138 139 140 141

  return $libraries;
}

142
/**
143
 * Implements hook_menu().
144
 */
145 146 147 148
function editor_menu() {
  // @todo Remove this menu item in http://drupal.org/node/1954892 when theme
  //   callbacks are replaced with something else.
  $items['editor/%/%/%/%/%'] = array(
149
    'route_name' => 'editor.field_untransformed_text',
150
    'theme callback' => 'ajax_base_page_theme',
151
    'type' => MENU_CALLBACK,
152 153 154
  );

  return $items;
155 156
}

157 158 159 160 161 162 163 164 165 166 167
/**
 * Implements hook_form_FORM_ID_alter().
 */
function editor_form_filter_admin_overview_alter(&$form, $form_state) {
  // @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.
168
  $editors = \Drupal::service('plugin.manager.editor')->getDefinitions();
169 170 171 172 173 174 175 176 177 178 179
  foreach (element_children($form['formats']) as $format_id) {
    $editor = editor_load($format_id);
    $editor_name = ($editor && isset($editors[$editor->editor])) ? $editors[$editor->editor]['label'] : drupal_placeholder('—');
    $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]);
  }
}

/**
180
 * Implements hook_form_BASE_FORM_ID_alter() for 'filter_format_form'.
181
 */
182
function editor_form_filter_format_form_alter(&$form, &$form_state) {
183
  if (!isset($form_state['editor'])) {
184
    $format_id = $form_state['controller']->getEntity()->id();
185
    $form_state['editor'] = editor_load($format_id);
186
    $form_state['editor_manager'] = \Drupal::service('plugin.manager.editor');
187 188 189 190 191 192 193
  }
  $editor = $form_state['editor'];
  $manager = $form_state['editor_manager'];

  // Associate a text editor with this text format.
  $editor_options = $manager->listOptions();
  $form['editor'] = array(
194 195 196 197 198
    // 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(
199 200 201 202 203 204 205 206 207 208
    '#type' => 'select',
    '#title' => t('Text editor'),
    '#options' => $editor_options,
    '#empty_option' => t('None'),
    '#default_value' => $editor ? $editor->editor : '',
    '#ajax' => array(
      'trigger_as' => array('name' => 'editor_configure'),
      'callback' => 'editor_form_filter_admin_form_ajax',
      'wrapper' => 'editor-settings-wrapper',
    ),
209
    '#weight' => -10,
210
  );
211
  $form['editor']['configure'] = array(
212 213 214 215 216 217 218 219 220
    '#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',
    ),
221
    '#weight' => -10,
222 223 224 225 226
    '#attributes' => array('class' => array('js-hide')),
  );

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

231
  $form['editor']['settings'] = array(
232 233 234 235
    '#tree' => TRUE,
    '#weight' => -8,
    '#type' => 'container',
    '#id' => 'editor-settings-wrapper',
236 237 238 239 240
    '#attached' => array(
      'library' => array(
        array('editor', 'drupal.editor.admin'),
      ),
    ),
241 242 243 244 245 246 247
  );

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

253
  $form['#validate'][] = 'editor_form_filter_admin_format_validate';
254
  $form['actions']['submit']['#submit'][] = 'editor_form_filter_admin_format_submit';
255 256 257
}

/**
258
 * Button submit handler for filter_format_form()'s 'editor_configure' button.
259 260 261
 */
function editor_form_filter_admin_format_editor_configure($form, &$form_state) {
  $editor = $form_state['editor'];
262 263
  if (isset($form_state['values']['editor']['editor'])) {
    if ($form_state['values']['editor']['editor'] === '') {
264 265
      $form_state['editor'] = FALSE;
    }
266
    elseif (empty($editor) || $form_state['values']['editor']['editor'] !== $editor->editor) {
267
      $editor = entity_create('editor', array(
268
        'format' => $form_state['controller']->getEntity()->id(),
269
        'editor' => $form_state['values']['editor']['editor'],
270 271 272 273 274 275 276 277
      ));
      $form_state['editor'] = $editor;
    }
  }
  $form_state['rebuild'] = TRUE;
}

/**
278
 * AJAX callback handler for filter_format_form().
279 280
 */
function editor_form_filter_admin_form_ajax($form, &$form_state) {
281
  return $form['editor']['settings'];
282 283
}

284
/**
285
 * Additional validate handler for filter_format_form().
286 287 288 289 290 291 292 293 294 295 296
 */
function editor_form_filter_admin_format_validate($form, &$form_state) {
  // This validate handler is not applicable when using the 'Configure' button.
  if ($form_state['triggering_element']['#name'] === 'editor_configure') {
    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.
297
  if ($form_state['values']['editor']['editor'] !== '' && empty($form_state['editor'])) {
298 299 300 301
    form_set_error('editor][editor', t('You must configure the selected text editor.'));
  }
}

302
/**
303
 * Additional submit handler for filter_format_form().
304 305 306
 */
function editor_form_filter_admin_format_submit($form, &$form_state) {
  // Delete the existing editor if disabling or switching between editors.
307
  $format_id = $form_state['controller']->getEntity()->id();
308 309 310 311 312 313
  $original_editor = editor_load($format_id);
  if ($original_editor && $original_editor->editor != $form_state['values']['editor']) {
    $original_editor->delete();
  }

  // Create a new editor or update the existing editor.
314
  if (!empty($form_state['editor'])) {
315 316
    // Ensure the text format is set: when creating a new text format, this
    // would equal the empty string.
317
    $form_state['editor']->format = $format_id;
318
    $form_state['editor']->settings = $form_state['values']['editor']['settings'];
319 320 321 322 323 324 325
    $form_state['editor']->save();
  }
}

/**
 * Loads an individual configured text editor based on text format ID.
 *
326
 * @return \Drupal\editor\Entity\Editor|null
327
 *   A text editor object, or NULL.
328 329 330 331 332 333 334
 */
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');
335
  return isset($editors[$format_id]) ? $editors[$format_id] : NULL;
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
}

/**
 * Additional #pre_render callback for 'text_format' elements.
 */
function editor_pre_render_format($element) {
  // Allow modules to programmatically enforce no client-side editor by setting
  // the #editor property to FALSE.
  if (isset($element['#editor']) && !$element['#editor']) {
    return $element;
  }

  // filter_process_format() copies properties to the expanded 'value' child
  // element. Skip this text format widget, if it contains no 'format' or when
  // the current user does not have access to edit the value.
  if (!isset($element['format']) || !empty($element['value']['#disabled'])) {
    return $element;
  }
  $format_ids = array_keys($element['format']['format']['#options']);

  // Early-return if no text editor is associated with any of the text formats.
357 358
  $editors = entity_load_multiple('editor', $format_ids);
  if (count($editors) === 0) {
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382
    return $element;
  }

  // Use a hidden element for a single text format.
  $field_id = $element['value']['#id'];
  if (!$element['format']['format']['#access']) {
    // Use the first (and only) available text format.
    $format_id = $format_ids[0];
    $element['format']['editor'] = array(
      '#type' => 'hidden',
      '#name' => $element['format']['format']['#name'],
      '#value' => $format_id,
      '#attributes' => array(
        'class' => array('editor'),
        'data-editor-for' => $field_id,
      ),
    );
  }
  // Otherwise, attach to text format selector.
  else {
    $element['format']['format']['#attributes']['class'][] = 'editor';
    $element['format']['format']['#attributes']['data-editor-for'] = $field_id;
  }

383 384 385 386 387 388
  // Hide the text format's filters' guidelines of those text formats that have
  // a text editor associated: they're rather useless when using a text editor.
  foreach ($editors as $format_id => $editor) {
    $element['format']['guidelines'][$format_id]['#access'] = FALSE;
  }

389 390 391 392
  // Attach Text Editor module's (this module) library.
  $element['#attached']['library'][] = array('editor', 'drupal.editor');

  // Attach attachments for all available editors.
393
  $manager = \Drupal::service('plugin.manager.editor');
394 395 396 397
  $element['#attached'] = NestedArray::mergeDeep($element['#attached'], $manager->getAttachments($format_ids));

  return $element;
}
398 399 400 401 402

/**
 * Implements hook_entity_insert().
 */
function editor_entity_insert(EntityInterface $entity) {
403 404 405 406
  // Only act on content entities.
  if (!($entity instanceof ContentEntityInterface)) {
    return;
  }
407 408 409 410 411 412 413 414 415 416
  $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) {
417 418 419 420 421
  // Only act on content entities.
  if (!($entity instanceof ContentEntityInterface)) {
    return;
  }

422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455
  // 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) {
456 457 458 459
  // Only act on content entities.
  if (!($entity instanceof ContentEntityInterface)) {
    return;
  }
460 461 462 463 464 465 466 467 468 469
  $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) {
470 471 472 473
  // Only act on content entities.
  if (!($entity instanceof ContentEntityInterface)) {
    return;
  }
474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512
  $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 processed 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) {
    $file = entity_load_by_uuid('file', $uuid);
    if ($file->status !== FILE_STATUS_PERMANENT) {
      $file->status = FILE_STATUS_PERMANENT;
      $file->save();
    }
    file_usage()->add($file, 'editor', $entity->entityType(), $entity->id());
  }
}

/**
 * Deletes file usage of files referenced by processed 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.
 *
513
 * @see \Drupal\file\FileUsage\FileUsageInterface::delete()
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550
 */
function _editor_delete_file_usage(array $uuids, EntityInterface $entity, $count) {
  foreach ($uuids as $uuid) {
    $file = entity_load_by_uuid('file', $uuid);
    file_usage()->delete($file, 'editor', $entity->entityType(), $entity->id(), $count);
  }
}

/**
 * Finds all files referenced (data-editor-file-uuid) by processed 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();

  $processed_text_fields = _editor_get_processed_text_fields($entity);
  foreach ($processed_text_fields as $processed_text_field) {
    $text = $entity->get($processed_text_field)->value;
    $uuids[$processed_text_field] = _editor_parse_file_uuids($text);
  }
  return $uuids;
}

/**
 * Determines the text fields on an entity that have text processing enabled.
 *
 * @param EntityInterface $entity
 *   An entity whose fields to analyze.
 *
 * @return array
 *   The names of the fields on this entity that have text processing enabled.
 */
551
function _editor_get_processed_text_fields(ContentEntityInterface $entity) {
552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593
  $properties = $entity->getPropertyDefinitions();
  if (empty($properties)) {
    return array();
  }

  // Find all configurable fields, because only they could have a
  // text_processing setting.
  $configurable_fields = array_keys(array_filter($properties, function ($definition) {
    return isset($definition['configurable']) && $definition['configurable'] === TRUE;
  }));
  if (empty($configurable_fields)) {
    return array();
  }

  // Only return fields that have text processing enabled.
  return array_filter($configurable_fields, function ($field) use ($entity) {
    $settings = Field::fieldInfo()
      ->getInstance($entity->entityType(), $entity->bundle(), $field)
      ->getFieldSettings();
    return isset($settings['text_processing']) && $settings['text_processing'] === '1';
  });
}

/**
 * Parse an HTML snippet for any data-editor-file-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 = filter_dom_load($text);
  $xpath = new \DOMXPath($dom);
  $uuids = array();
  foreach ($xpath->query('//*[@data-editor-file-uuid]') as $node) {
    $uuids[] = $node->getAttribute('data-editor-file-uuid');
  }
  return $uuids;
}