ckeditor5_paste_filter-1.0.0-alpha1/src/Plugin/CKEditor5Plugin/PasteFilter.php

src/Plugin/CKEditor5Plugin/PasteFilter.php
<?php

declare(strict_types=1);

namespace Drupal\ckeditor5_paste_filter\Plugin\CKEditor5Plugin;

use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\editor\EditorInterface;

/**
 * CKEditor 5 PasteFilter plugin.
 */
class PasteFilter extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface {

  use CKEditor5PluginConfigurableTrait;

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration(): array {
    return [
      'enabled' => FALSE,
      'filters' => [
        [
          'enabled' => TRUE,
          'weight' => -10,
          'search' => '<o:p><\/o:p>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => -9,
          'search' => '(<[^>]*) (style="[^"]*")',
          'replace' => '$1',
        ],
        [
          'enabled' => TRUE,
          'weight' => -8,
          'search' => '(<[^>]*) (face="[^"]*")',
          'replace' => '$1',
        ],
        [
          'enabled' => TRUE,
          'weight' => -7,
          'search' => '(<[^>]*) (class="[^"]*")',
          'replace' => '$1',
        ],
        [
          'enabled' => TRUE,
          'weight' => -6,
          'search' => '(<[^>]*) (valign="[^"]*")',
          'replace' => '$1',
        ],
        [
          'enabled' => TRUE,
          'weight' => -5,
          'search' => '<font[^>]*>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => -4,
          'search' => '<\/font>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => -3,
          'search' => '<span[^>]*>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => -2,
          'search' => '<\/span>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => -1,
          'search' => '<p>&nbsp;<\/p>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => 0,
          'search' => '<p><\/p>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => 1,
          'search' => '<b><\/b>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => 2,
          'search' => '<i><\/i>',
          'replace' => '',
        ],
        [
          'enabled' => TRUE,
          'weight' => 3,
          'search' => '<a name="OLE_LINK[^"]*">(.*?)<\/a>',
          'replace' => '$1',
        ],
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function setConfiguration(array $configuration): void {
    if (empty($this->configuration)) {
      $this->configuration = $this->defaultConfiguration();
    }
    else {
      $this->configuration = $configuration;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $form['enabled'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Filter pasted content'),
      '#default_value' => $this->configuration['enabled'],
    ];

    $form['help'] = [
      '#type' => 'details',
      '#title' => $this->t('Help'),
      '#access' => $this->configuration['enabled'],
    ];
    $form['help']['info']['#markup'] = '';
    $form['help']['info']['#markup'] .= '<p>' . $this->t('Content pasted into the editor will be processed through the configured filters.') . '</p>';
    $form['help']['info']['items'] = [
      '#theme' => 'item_list',
      '#items' => [
        $this->t('Each filter consists of a <a href="@regex">JavaScript regular expression</a> and a replacement string.', ['@regex' => 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions']),
        $this->t('Filters run in the order specified below.'),
        $this->t('In the search expression, any <a href="@regex_cheat_sheet">special characters</a> that you want to match literally must be escaped with a backslash. For example, since the forward slash is a special character, a closing paragraph tag must be written as <code><\/p></code>.', ['@regex_cheat_sheet' => 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet']),
        $this->t('Each search expression automatically has the flags <code>gimsu</code> (<a href="@regex_flags">global, ignoreCase, multiline, dotAll, and unicode</a>) applied. Do not enter delimiters around your search expression.', ['@regex_flags' => 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#advanced_searching_with_flags']),
        $this->t('All matches to the search expression are replaced with the replacement string (can be empty).'),
        $this->t('The replacement string can <a href="@regex_replace">reference capture groups from the search expression</a>, such as <code>$1</code>, <code>$2</code>, etc.', ['@regex_replace' => 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement']),
      ],
    ];

    $wrapper_id = 'paste-filters-wrapper';
    $form['filters'] = [
      '#type' => 'table',
      '#header' => [
        $this->t('Enabled'),
        $this->t('Filter'),
        $this->t('Weight'),
      ],
      '#title' => $this->t('Paste Filters'),
      '#tabledrag' => [
        [
          'action' => 'order',
          'relationship' => 'sibling',
          'group' => 'paste-filter-weight',
        ],
      ],
      // We add this via prefix and suffix rather than #attributes so that we
      // can enclose related elements such as the tabledrag toggle weight
      // button.
      '#prefix' => '<div id="' . $wrapper_id . '">',
      '#suffix' => '</div>',
      '#after_build' => [[static::class, 'sortFilterRows']],
      '#access' => $this->configuration['enabled'],
    ];

    $filters = $this->configuration['filters'];

    // Store number of rows (used for "Add another filter" functionality).
    $storage = $form_state->getStorage();
    if (empty($storage['paste-filter-rows'])) {
      $count = count($filters);
      // If there are no configured filters, add an empty row to provide a
      // starting point.
      if ($count === 0) {
        $count = 1;
      }

      $storage['paste-filter-rows'] = $count;
      $form_state->setStorage($storage);
    }
    // Build rows for the filter config table.
    for ($i = 0; $i < $storage['paste-filter-rows']; $i++) {
      $form['filters'][$i] = $this->buildFilterConfigurationRow($filters, $i, $storage['paste-filter-rows']);
    }

    $form['actions'] = [
      '#type' => 'actions',
      '#access' => $this->configuration['enabled'],
    ];

    $form['actions']['add-row'] = [
      '#type' => 'submit',
      '#value' => $this->t('Add another filter'),
      '#submit' => [[static::class, 'addExtraRow']],
      '#ajax' => [
        'callback' => [static::class, 'addExtraRowAjaxCallback'],
        'wrapper' => $wrapper_id,
      ],
    ];

    return $form;
  }

  /**
   * Build a single row of the paste filter configuration table.
   *
   * @param array $filter_config
   *   Filter configuration.
   * @param int $key
   *   Row number.
   * @param int $total_rows
   *   Total number of rows.
   *
   * @return array
   *   One row for the table of paste filter configurations.
   */
  private function buildFilterConfigurationRow(array $filter_config, $key, $total_rows) {
    if (!empty($filter_config[$key])) {
      $filter = $filter_config[$key];
    }
    else {
      $filter = [
        'enabled' => TRUE,
        'search' => '',
        'replace' => '',
        'weight' => NULL,
      ];
    }

    $row = [];
    $row['#attributes']['class'][] = 'draggable';
    $row['enabled'] = [
      '#type' => 'checkbox',
      '#default_value' => $filter['enabled'],
      // See ckeditor5_form_filter_format_form_alter().
      '#ajax' => FALSE,
    ];

    // @todo Consider adding a 'description' field.
    // @todo Try to put search and replace visually next to each other.
    $row['filter']['search'] = [
      '#type' => 'textarea',
      '#rows' => 1,
      '#title' => $this->t('Search expression'),
      '#default_value' => $filter['search'],
      // See ckeditor5_form_filter_format_form_alter().
      '#ajax' => FALSE,
    ];
    $row['filter']['replace'] = [
      '#type' => 'textarea',
      '#rows' => 1,
      '#title' => $this->t('Replacement'),
      '#default_value' => $filter['replace'],
      // See ckeditor5_form_filter_format_form_alter().
      '#ajax' => FALSE,
    ];

    $row['weight']['weight'] = [
      '#type' => 'weight',
      '#title' => $this->t('Weight for filter replacing @title', ['@title' => $filter['search']]),
      '#title_display' => 'invisible',
      '#value' => $filter['weight'],
      // @todo Better handling for when the delta gets inflated, for example
      //   adding many rows and then removing most rows. The best thing in this
      //   case may be to re-weight all rows in sortFilterRows().
      '#delta' => $total_rows + 1,
      '#attributes' => [
        'class' => ['paste-filter-weight'],
      ],
    ];

    $row['weight']['config_order'] = [
      '#type' => 'value',
      '#value' => $key,
    ];

    return $row;
  }

  /**
   * Sort and set weights of filter rows.
   *
   * #after_build callback.
   */
  public static function sortFilterRows($element, FormStateInterface $form_state) {
    $values = $form_state->getValues();
    // @todo Could this be made less fragile? What if upstream changes form
    //   structure?
    $filters = $values['editor']['settings']['plugins']['ckeditor5_paste_filter_pasteFilter']['filters'];

    if (!$filters) {
      return $element;
    }

    // Reorder table rows based on the filter weights.
    $rows = [];
    $filter_weights = array_column(array_column($filters, 'weight'), 'weight');

    foreach (Element::children($element) as $key) {
      // Fill in missing weights (anything without a weight should go to the
      // end of the list).
      if (!isset($filter_weights[$key])) {
        $filter_weights[$key] = max($filter_weights) + 1;
      }

      // Build out separate $rows variable and remove row from $element.
      $rows[$key] = $element[$key];
      unset($element[$key]);
    }

    uksort($rows, function ($a, $b) use ($filter_weights) {
      return $filter_weights[$a] - $filter_weights[$b];
    });

    // Add weight to any rows that do not have a weight set from
    // buildFilterConfigurationRow().
    foreach ($rows as $key => &$row) {
      if (!isset($row['weight']['weight']['#value'])) {
        $row['weight']['weight']['#value'] = $filter_weights[$key];
      }
    }

    // Return element including the re-sorted and updated rows.
    return $element + $rows;
  }

  /**
   * Submit handler to add extra row.
   *
   * @param array $form
   *   Drupal form array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   Drupal form state object.
   */
  public static function addExtraRow(array $form, FormStateInterface $form_state) {
    $storage = $form_state->getStorage();
    $storage['paste-filter-rows']++;
    $form_state->setStorage($storage);
    $form_state->setRebuild(TRUE);
  }

  /**
   * Callback for Ajax functionality.
   *
   * @param array $form
   *   Drupal form array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   Drupal form state object.
   *
   * @return mixed
   *   Form element for ajax response to replace the table with an updated
   *   version.
   */
  public static function addExtraRowAjaxCallback(array $form, FormStateInterface $form_state) {
    $button = $form_state->getTriggeringElement();

    // Go two levels up in the form array, to get to the correct part of the
    // subform.
    $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -2));

    return $element['filters'];
  }

  /**
   * {@inheritdoc}
   */
  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
    $filters = $form_state->getValue('filters');
    foreach ($filters as $row => $filter) {
      if (empty($filter['filter']['search']) && !empty($filter['filter']['replace'])) {
        $form_state->setError($form['filters'][$row]['filter']['search'], $this->t('The Search expression field is required. To remove the filter, empty both the Search expression and Replacement fields.'));
      }
    }

    // Sort filters by config_order, so that when manipulating filter weights
    // we don't reorder the config items.
    array_multisort(array_column(array_column($filters, 'weight'), 'config_order'), $filters);

    // Set up correct data types to match configuration schema.
    $validated_config = [];
    foreach ($filters as $filter) {
      $validated_config[] = [
        'enabled' => (bool) $filter['enabled'],
        'search' => (string) $filter['filter']['search'],
        'replace' => (string) $filter['filter']['replace'],
        'weight' => (int) $filter['weight']['weight'],
      ];
    }

    $form_state->setValue('enabled', (bool) $form_state->getValue('enabled'));
    $form_state->setValue('filters', $validated_config);
  }

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
    // Remove any empty filter rows entirely from the final configuration. Since
    // the replacement string is allowed to be empty we only check the search
    // expression.
    // @todo Consider providing 'Remove' button for each row.
    // @todo Consider adding 'Remove all filters' button, particularly for the
    //   use case of discarding the defaults and starting fresh.
    $valid_filters = array_filter($form_state->getValue('filters'), function (array $filter): bool {
      return (bool) !empty($filter['search']);
    });
    $this->configuration = [
      'enabled' => $form_state->getValue('enabled'),
      // Re-number the array.
      'filters' => array_values($valid_filters),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
    // Return false if the plugin is not enabled, or there are no filters
    // configured.
    if (!$this->configuration['enabled'] || empty($this->configuration['filters'])) {
      return ['pasteFilter' => FALSE];
    }

    // Return only enabled filters, XSS filtered and sorted by weight.
    $enabled_filters = array_filter($this->configuration['filters'], function (array $filter): bool {
      return (bool) $filter['enabled'];
    });
    $filters = array_map([$this, 'xssFilterReplaceText'], $enabled_filters);
    usort($filters, [
      'Drupal\Component\Utility\SortArray',
      'sortByWeightElement',
    ]);

    return [
      'pasteFilter' => $filters,
    ];
  }

  /**
   * Callback for array_map(): XSS filter filter replacement text.
   */
  private function xssFilterReplaceText(array $filter): array {
    $filter['replace'] = Xss::filterAdmin($filter['replace']);

    return $filter;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc