/sites/default/files/2023-09/token_0.jpg

В процессе работы над нашим Хэлпдеском потребовалось загружать файлы изображений в приватное хранилище с разделением по папкам в соответствии со свойствами сохраняемых сущностей. Для изображений, загружаемых посредством полей, проблем не возникло, а вот с изображениями, вставляемыми посредством текстового редактора, возникло. Используемый подавляющим большинством сайтов на Drupal текстовый редактор CKEditor не позволяет использовать токены для указания каталога загрузки изображений.

На странице соответствующей проблемы на Drupal.org есть решения, которые реализуют такую возможность. Однако в базовом варианте редактор не видит контекста, в котором был загружен, и в нём доступны только глобальные токены вроде имени сайта, текущего времени и т.п. Контекст в виде сущности, в которой осуществляется редактирование, недоступен и мы не можем, например, положить файл в папку с её названием или идентификатором.

Попробуем реализовать эту возможность и передать данные о редактируемой сущности в текстовый редактор.

Для начала сделаем это для предыдущей версии CKEditor, характерной для Drupal 8.

Первым делом применяем к ядру патч. Как это делать, читаем, например, здесь.

Как уже говорилось, объект редактора не имеет в своём составе никакой информации о текущей странице. Получить её из объекта текущего роута также не получится, поскольку форма изображения загружается через ajax и текущий роут там уже другой. Поэтому мы передадим нужные параметры через ссылку иконки загрузки изображения, которые можно добавить через hook_editor_js_settings_alter().

Сделаем допущение, что в редактируемой сущности уже есть поле для загрузки файлов, в настройках которого указан токен для формирования пути загрузки (в нашем случае используется filefield_paths). Их мы и будем использовать. В противном случае можно, например, создать свою страницу настроек.

 

/**
 * Implements hook_editor_js_settings_alter().
 */
function MY_MODULE_editor_js_settings_alter(array &$settings) {
  $current_route = \Drupal::routeMatch();
  // Обходим параметры роута в поиске сущности.
  foreach ($current_route->getParameters() as $page_entity) {
    // Нас интересует сущность с полями.
    if ($page_entity instanceof FieldableEntityInterface) {
      $extra_query = '?entity_type_id=' . $page_entity->getEntityTypeId();
      $extra_query .= '&entity_bundle=' . $page_entity->bundle();
      $extra_query .= '&entity_id=' . $page_entity->id();
      foreach ($settings['editor']['formats'] as $format_name => $format_info) {
        if (isset($settings['editor']['formats'][$format_name]['format'])) {
          $settings['editor']['formats'][$format_name]['format'] .= $extra_query;
        }
      }
      break;
    }
  }
}

 

Здесь стоит отметить, что такое решение будет работать только для существующих сущностей. При создании же новой (например, /node/add/[node_type]) в параметры роута придёт сущность типа NodeType или аналогичная для другого типа сущностей. Можно обработать и такой вариант, пробросив параметры entity_type_id и entity_bundle, однако здесь мы не будем иметь доступ к многим полям, поэтому такой случай мы обработаем немного с другого конца.

Теперь создадим кастомный токен, который будем использовать для генерации пути.

/**
 * Implements hook_token_info().
 */
function MY_MODULE_token_info() {
  $tokens['inline_image_path'] = [
    'name' => t('Editor inline image path'),
    'description' => t('Path where images uploaded via text editor are loaded.'),
  ];
  return [
    'types' => [
      'custom' => [
        'name' => 'Custom',
        'description' => 'Custom tokens.',
      ],
    ],
    'tokens' => ['custom' => $tokens],
  ];
}
/**
 * Implements hook_tokens().
 */
function MY_MODULE_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
  $replacements = [];
  if ($type !== 'custom') {
    return $replacements;
  }
  foreach ($tokens as $name => $original) {
    switch ($name) {
      case 'inline_image_path':
        // Получаем переданные параметры.
        $query_parameters = Drupal::request()->query->all();
        $keys = array_keys($query_parameters);
        // В случае реализации под CKEditor5 ключи параметров по какой-то причине 
        //приходят с кодированным символом амперсанта (&). Правим это поведение.
        array_walk($keys, function (&$item) {
          $item = str_replace('amp;', '', $item);
        });
 
        // При отсутствии необходимых параметров возвращаем значение по умолчанию.
        $query_parameters = array_combine($keys, $query_parameters);
        if (!isset($query_parameters['entity_type_id']) || !isset($query_parameters['entity_bundle']) || !isset($query_parameters['entity_id'])) {
          $replacements[$original] = 'inline-images';
          break;
        }
        $path = MY_MODULE_get_image_upload_path($query_parameters['parent_entity_type_id'], $query_parameters['parent_entity_bundle'], $query_parameters['parent_entity_id']);
        // Присваиваем значение по умолчанию, если по какой-то причине путь не сформирован.
        if (is_null($path) || $path == $original) {
          $path = 'inline-images';
        }
        $replacements[$original] = $path;
        break;
    }
  }
  return $replacements;
}
/**
 * Generate uploaded inline image path.
 *
 * @param string $entity_type_id
 *   The entity type id the image is uploaded to.
 * @param string $bundle
 *   The entity bundle the image is uploaded to.
 * @param \Drupal\Core\Entity\EntityInterface|string $entity
 *   The entity object or id the image is uploaded to.
 *
 * @return string|null
 *   The image path.
 */
function MY_MODULE_get_image_upload_path($entity_type_id, $bundle, $entity) {
  $field_definitions = Drupal::service('entity_field.manager')->getFieldDefinitions($entity_type_id, $bundle);
  $path = NULL;
  if (is_string($entity)) {
    $entity = Drupal::entityTypeManager()->getStorage($entity_type_id)->load($entity);
  }
  $token_data = [
    'file' => NULL,
    $entity_type_id => $entity,
  ];
 
  foreach ($field_definitions as $field_definition) {
    if ($field_definition instanceof FieldConfig && $field_definition->getType() == 'file') {
      $filefield_settings = $field_definition->getThirdPartySettings('filefield_paths');
      // Обрабатываем новые сущности, ещё не получившие идентификатор в базе данных.
      // Фактически получаем первый неиспользованный идентификатор в базовой таблице типа сущности.
      if (!$entity->id()) {
        $base_table = $entity->getEntityType()->getBaseTable();
        $id_key = $entity->getEntityType()->getKey('id');
        $last_insert_id_query = Drupal::database()->select($base_table, 't')
          ->fields('t', [$id_key])
          ->orderBy($id_key, 'DESC')
          ->range(0, 1);
        $last_insert_id = $last_insert_id_query->execute()->fetchField();
        $entity->set('id', ++$last_insert_id);
      }
      if (!isset($filefield_settings['file_path']['options']['context'])) {
        $filefield_settings['file_path']['options']['context'] = '';
      }
      $path = filefield_paths_process_string($filefield_settings['file_path']['value'], $token_data, $filefield_settings['file_path']['options']);
      break;
    }
  }
 
  // Предварительно подготавливаем каталог для сохранения. В теории это должно происходить при перемещении файла, 
  // однако на практике с этим возникают сложности. Схему private или public, конечно, можно получить из настроек 
  // текстового формата, но не будем переусложнять.
  $destination = 'private://' . $path;
  Drupal::service('file_system')->prepareDirectory($destination, 1);
  return $path;
}

Устанавливаем токен в настройках загрузки изображений редактора (например, /admin/config/content/formats/manage/full_html) и наслаждаемся результатом.

В случае CKEditor5 применяем соответствующий патч.

Токен для CKEditor5 будет выглядеть так же, однако проброс параметров для него реализуется иначе, поскольку ссылка с иконки загрузки формируется там более ортодоксальным способом и не позволяет нам изменить её привычными методами. Воспользуемся возможностями OutboundPathProcessor.

Регистрируем сервис в файле MY_MODULE.services.yml:

services:
  MY_MODULE.path_procesor_ckeditor_five:
    class: Drupal\MY_MODULE\PathProcessor\CkeditorFiveOutboundPathProcessor
    arguments:
      - '@current_route_match'
    tags:
      - { name: path_processor_outbound }

И описываем логику в файле MY_MODULE_PATH/src/PathProcessor\CkeditorFiveOutboundPathProcessor.php.

namespace Drupal\MY_MODULE\PathProcessor;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\PathProcessor\OutboundPathProcessorInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Symfony\Component\HttpFoundation\Request;
/**
 * Alters CKEditor image upload url, adding data about current entity.
 */
class CkeditorFiveOutboundPathProcessor implements OutboundPathProcessorInterface {
  /**
   * The currently active route match object.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $routeMatch;
  /**
   * Constructs the CkeditorFiveOutboundPathProcessor object.
   */
  public function __construct($route_match) {
    $this->routeMatch = $route_match;
  }
  /**
   * {@inheritdoc}
   */
  public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) : string {
    if (str_contains($path, '/ckeditor5/upload-image')) {
      foreach ($this->routeMatch->getParameters() as $page_entity) {
        if ($page_entity instanceof FieldableEntityInterface) {
          $options['query']['entity_type_id'] = $page_entity->getEntityTypeId();
          $options['query']['entity_bundle'] = $page_entity->bundle();
          $options['query']['entity_id'] = $page_entity->id();
          break;
        }
      }
    }
    return $path;
  }
}

Теперь решим проблему загрузки изображения для вновь создаваемой сущности. В принципе, его можно применять и для существующих сущностей, не хитря с пробрасыванием параметров в редактор, но на мой взгляд нижестоящий код попахивает деревянными устройствами для вспоможения перемещению при нарушениях опорно-двигательного аппарата. Хотя свои плюсы и минусы есть в каждом варианте.

/**
 * Implements hook_entity_presave().
 */
function MY_MODULE_entity_presave(EntityInterface $entity) {
  if (!$entity instanceof EditorialContentEntityBase || !$entity->isNew()) {
    return;
  }
  // Ищем текстовые поля для обработки.
  $field_definitions = Drupal::service('entity_field.manager')->getFieldDefinitions($entity->getEntityTypeId(), $entity->bundle());
  foreach ($field_definitions as $field_name => $field_definition) {
    if (!$field_definition instanceof FieldConfig) {
      continue;
    }
    if (in_array($field_definition->getType(), [
      'text_long',
      'text_with_summary',
    ])) {
      // Получаем подключённый к полю текстовый редактор.
      $format_id = $entity->{$field_name}->format;
      if (!$format_id) {
        continue;
      }
      /** @var Drupal\editor\Entity\Editor $editor */
      $editor = Drupal::entityTypeManager()->getStorage('editor')->load($format_id);
      if (!$editor) {
        continue;
      }
      $image_upload_settings = $editor->getImageUploadSettings();
      if ($image_upload_settings['status']) {
        $field_value = $entity->{$field_name}->value;
        // При включённой загрузке изображений обходим текст в поиске тегов img.
        foreach (Html::load($field_value)->getElementsByTagName('img') as $image) {
          // Получаем значение атрибута data-entity-uuid и загружаем соответствующую сущность файла.
          $uuid = $image->getAttribute('data-entity-uuid');
          $original_src = $image->getAttribute('src');
          $files = Drupal::entityTypeManager()->getStorage('file')->loadByProperties(['uuid' => $uuid]);
          /** @var \Drupal\file\Entity\File$file */
          $file = reset($files);
          /** @var \Drupal\file\FileRepositoryInterface $file_repository */
          $file_repository = Drupal::service('file.repository');
          // Получаем каталог для сохранения.
          $target_folder = MY_MODULE_get_image_upload_path($entity->getEntityTypeId(), $entity->bundle(), $entity);
          if (is_null($target_folder)) {
            $target_folder = 'inline-images';
          }
          try {
            // Перемещаем файл в новое место.
            $destination = $image_upload_settings['scheme'] . '://' . $target_folder . '/' . $file->getFilename();
            $moved_file = $file_repository->move($file, $destination);
            $destination_path = $moved_file->createFileUrl();
            // Заменяем значение атрибута src. Без этого шага изображение будет корректно 
            // отображаться при просмотре сущности, но при редактировании мы увидим пустую картинку.
            $field_value = str_replace($original_src, $destination_path, $field_value);
            $entity->{$field_name}->value = $field_value;
          }
          catch (FileException $e) {
            // Пишем ошибку, если таковая имеется, в лог.
            watchdog_exception('MY_MODULE', $e);
          }
        }
      }
    }
  }
}

Сбрасываем кэш и пользуемся результатом.

Добавить комментарий