Создаем блок редактора Gutenberg

Создаем блок редактора Gutenberg

Редактор Gutenberg блочный редактор wordpress для создания контентной части страницы. Он пример того, как плагин стал частью движка. В этой статье я буду не обозревать редактор, а выполнять разработку блока для расширения возможностей редактора.

Содержание

Постановка задачи

  • Задача: разработать плагин, добавляющий блок последних записей в редактор.
  • В блоке можно редактировать:
    • Количество выводимых постов;
    • Категорию постов(из существующих, не пустых)
    • Статус отображения изображения записи

Я уверен, что подобные плагины существуют и, что подобный блок можно собрать используя другие плагины, на подобии ACF. Но данная задача – это реальное тестовое задание, выполнением которого я хотел бы поделится.

Теоретические выкладки

Начнем из далека – нужно же статье придать объёма!

Редактор Gutenberg – это новый(относительно, частью wp он стал в версии движка 5, до этого был плагином) визуальный редактор WordPress для записей и страниц. Проект назван именем Иогана Гутерберга, презентовавшего Европе печатный станок и начавшего печатную революцию. Его работа сделала знание и информацию доступнее и запустила социальную революцию. Аналогично этому, разработчики хотят сделать доступным создание продвинутый макетов страниц для всех пользователей WordPress(источник).

Основная особенность редактора Gutenberg – это преставление всего контента в виде блоков и определение макета записи прямо в редакторе.

Ну все, хватит копи-паста, мой внутренний борец с плагиатом не позволит более. Да, я знаю, что все мои статейки – это, всего лишь, пере озвучка документаций и других источников… но, иногда, пишу и свои наблюдения.

В общем, каждый блок редактора – скрипт написанный на javascript с использованием библиотеки react. Все, что нужно для создания блока – это зарегистрировать скрипт и стили(коль нужны); в скрипте определить зависимости и функцию(с методами и свойствами).

Опишу свои мысли о том, что я буду делать – дабы выполнить поставленную задачу. Первым делом, учитывая, что по правилам хорошего тона react общается с приложениями по средствам REST API, и нужного пути вордпресс по умолчанию не имеет – создам этот путь в WP REST API. Второе – я знаю, что в таблицу БД движок сохраняет запись о блоке; поскольку, наш блок во фронте должен быть динамичен – то запись должна хранить элемент, который вордпресс ассоциирует с функцией. Кто ранее знакомился с этим движком, понял о чем я – о шоркоде, который мне также предстоит создать. И последнее, несколько слов о javascript – внутри функции регистрации блока нужно будет определить 4 вещи: мета инфу о блоке, его атрибуты, сохраняемую строку и вид в редакторе.

Установка рабочей среды

Это действие нужно чисто для удобства и скорости разработки. Но, в принципе, код можно писать и в блокноте – но все используют специальные редакторы. Так-что, советую эти операции проделать тоже…

Первое, при любой разработке на PHP(а язык бэка wordpress именно php) понадобится локальный сервер. Я использую OpenServer: просто скачай с официального сайта и установи его.

Второе – это компилятор JSX в JS, SCSS в CSS. В качестве такого компилятора, я использую NodeJS с пакетами; далее, подробно об установке.

С официального сайта грузим и устанавливаем NodeJS. После успешной установки, зашел в командную строку, у нас появиться менеджер пакетов npm. Первое, что нужно сделать – это сформировать package.json запуском команды

npm init

и дальнейшими ответами на поставленные вопросы. После чего, в командной строке пишем

npm install --save-dev @babel/core @babel/cli

это установи транскриптор babel в зависимости для ноды. И, для полного понимания при компиляции, нужен пресет @wordpress/babel-preset-default. Запустим

npm install @wordpress/babel-preset-default --save-dev

Среда для компиляции JSX готова. Немного о нахождении компилируемого файла и самого запуска процесса компиляции. Пусть компилируемый файл называется script.js и скомпилированный script-compiled.js; как я понял наблюдением, файл script.js должен лежать на одном уровне с package.json и папкой node_modules, так как ругается на указанные пути(по крайней мере, так было у меня). Сам процесс компиляции запускается так

npx babel --presets @wordpress/default script.js --watch --out-file script-compiled.js

После окончания компиляции, будет создан script-compiled.js

Backend блока

Бэк блока, как было ранее сказано, заключается в подключении скрипта и стилей, а также, нового rest-пути и шорткода. Делается это следующим кодом

<?php
function blocks_scripts()
{
    wp_enqueue_script(
        'gutenberg-latest-posts',
        plugins_url('/block/js/gutenberg-latest-posts-block.js'),
        ['wp-blocks', 'wp-element', 'wp-editor', 'wp-i18n'],
        true
    );
}
add_action('enqueue_block_editor_assets', 'blocks_scripts');

но, по условию, мне нужно выбрать категорию постов. Для этого передадим список категорий и путь, по которому блок будет брать информацию о постах – конечный PHP скрипт в этой части

<?php
function blocks_scripts()
{
    $categories = get_categories();

    if (!empty($categories)) {
        foreach ($categories as $key => $value) {
            $cats[$value->term_id] = $value->name;
        }
    }
    // скрываем блок если нет категорий
    if (!empty($cats)) {
        // подключаем блок
        wp_enqueue_script(
            'gutenberg-latest-posts',
            plugins_url('/block/js/gutenberg-latest-posts-block.js'),
            ['wp-blocks', 'wp-element', 'wp-editor', 'wp-i18n'],
            true
        );
        // создаем обьект glp, содержащий
        // список категорий и ссылку на WP REST API постов
        wp_localize_script(
            'gutenberg-latest-posts',
            'glp',
            [
            'categories' => $cats,
            'restURL' => esc_url(get_rest_url(null, 'block/v1/latest-posts'))
          ]
        );
    }
}
add_action('enqueue_block_editor_assets', 'blocks_scripts');

немного о зависимостях – скрипт wp-blocks содержит функции, необходимые для регистрации блока и образует js объект wp.blocks; wp-element содержит функции react с ворпрессовской “прокладкой”, объект wp.element; wp-i18n функции для создания translate ready, wp.18n. Зависимость wp-editor можно пропустить, но она содержит готовые функции для размещения элементов управления атрибутами блока.

Следующий шаг – создание пути ‘wp/v2/latest-posts‘ – ведь его еще не существует. Этот код его зарегистрирует в системе

<?php
add_action('rest_api_init', 'blocks_rest_endpoint');
function blocks_rest_endpoint()
{
  register_rest_route('block/v1', '/latest-posts', [
    'method' => 'GET',
    'callback' => 'blocks_return_data'
  ]);
}

Теперь то, что он будет выводить(имеется в виду, что получит перешедший по этому пути)

<?php
function blocks_return_data()
{
    $category = isset($_GET['cat']) ? absint($_GET['cat']) : 0;
    $count = isset($_GET['count']) ? absint($_GET['count']) : 5;

    return json_encode(blocks_get_latest_posts($category, $count));
}

function blocks_get_latest_posts($category, $count)
{
    $result = [];
    $posts = get_posts([
      'numberposts' => $count,
      'category' => $category
    ]);

    foreach ($posts as $key => $value) {
        $result[] = [
          'title' => get_the_title($value->ID),
          'excerpt' => get_the_excerpt($value->ID),
          'link' => get_permalink($value->ID),
          'src' => get_the_post_thumbnail_url($value->ID, 'full'),
          'id'  => $value->ID
        ];
    }

    return $result;
}

и в конце, зарегистрируем шорткод

<?php
add_shortcode('gutenberg-block-latest-posts', 'blocks_shortcode');
function blocks_shortcode($atts)
{
    $atts = shortcode_atts([
      'count' => 5,
      'cat' => array_shift(array_keys(blocks_get_categories())),
      'thumb' => 1
    ], $atts);

    $posts = blocks_get_latest_posts(absint($atts['cat']), absint($atts['count']));

    ob_start(); ?>
    <div class="latest-posts">
      <?php if (!empty($posts)): ?>
        <?php foreach ($posts as $key => $value): ?>
          <div class="post-<?php echo $value['id']; ?> row">
            <?php if ($atts['thumb'] && !empty($value['src'])): ?>
              <div class="thumbnail">
                <img src="<?php echo $value['src'] ?>" alt="<?php echo $value['title'] ?>">
              </div>
            <?php endif; ?>
            <div class="post-content">
              <a href="<?php echo $value['link'] ?>">
                <h2><?php echo $value['title'] ?></h2>
              </a>
              <p><?php echo $value['excerpt'] ?></p>
            </div>
          </div>
        <?php endforeach; ?>
      <?php endif; ?>
    </div>
    <?php
    wp_reset_postdata();

    return ob_get_clean();
}

Вот и весь бэк-энд блока

Frontend блока

Создадим js-файл по ранее указанному пути и, во избежание конфликтов с другими, сделаем замыкание. Замыкание выглядит как анонимная само исполняющаяся функция

(function(blocks, element, i18n, editor, data) {
    // code
})(
  window.wp.blocks,
  window.wp.element,
  window.wp.i18n,
  window.wp.editor,
  window.glp
);

В нижних скобках указаны те объекты, которые нужно “впустить”, сверху – под какими именами их использовать в порядке соответствия. Далее, для удобства(поймете после компиляции файла), нужно “вытянуть” некоторые методы с объектов

  const { createElement } = element;
  const { __ } = i18n;
  const { registerBlockType } = blocks;
  const { InspectorControls } = editor;
  const { BlockControls } = editor;

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

  registerBlockType('block/gutenberg-latest-posts', {
      title: __('Latest Posts'),
      icon: 'dashicons-excerpt-view',
      category: 'common',
      attributes: {
        thumb: {
          type: 'integer',
          default: 1
        },
        count: {
          type: 'integer',
          default: 5
        },
        category: {
          type: 'integer',
          default: Object.keys(data.categories)[0]
        },
        checked: {
          type: 'bool',
          default: true
        },
        posts:  {
          type: 'array',
          default: []
        }
      }
  }
});

поля title и icon интуитивно понятные(есть еще и другие, необязательные), скажу только, что category это блок, где будет отображен блок(блоки по умолчанию ). Чтобы создать свою категорию добавь следующий PHP-код

<?php
function my_plugin_block_categories( $categories, $post ) {
    if ( $post->post_type !== 'post' ) {
        return $categories;
    }
    return array_merge(
        $categories,
        array(
            array(
                'slug' => 'my-category',
                'title' => __( 'My category', 'my-plugin' ),
                'icon'  => 'wordpress',
            )
    );
}
add_filter( 'block_categories', 'my_plugin_block_categories', 10, 2 );

Отдельно упомяну об атрибутах – из-за строгого отношения к типам данных, чтобы булевое значение не сохранить в БД; поэтому, приходится булевое ставить в зависимость от других типов, значение которых можно сохранить. Так у меня, выбор отображения изображения поста происходит чекбоксом(атрибут checked), состояние которого возвращается в типе bool. Чтобы сохранять это состояние, я ввел доп.атрибут в типе integer(thumb), значение которого в полной зависимостти от checked.

Осталось определить 2 метода, которые должны возвращать HTML-разметку имеющую по контейнеру. Первый(edit) отвечает за разметку в редакторе блоков, второй(save) – за то, что будет сохранено в БД. Начнем с первого(полный код, написанный мною)

      edit: ({attributes, setAttributes}) => {
        function Item(props) {
          return (
            <div class={ 'row post-' + props.props.id }>
              { (() => {
                if (attributes.thumb && props.props.src.length) {
                  return (
                    <div class="thumbnail">
                      <img src={ props.props.src } alt={ props.props.title }/>
                    </div>
                  );
                }
              })() }
              <div class="post-excerpt">
                <a href={ props.props.link } onClick={ abort }><h3>{ props.props.title }</h3></a>
                <p>{ props.props.excerpt }</p>
              </div>
            </div>
          );
        }
        function setCount(event) {
          setAttributes({count:parseInt(event.target.value)});
          setPosts(attributes.category, event.target.value);
        }
        function setCategory(event) {
          setAttributes({category:parseInt(event.target.value)});
          setPosts(event.target.value, attributes.count);
        }
        function setThumb(event) {
          if (event.target.checked) {
            setAttributes({
              thumb: 1,
              checked: true
            });
          } else {
            setAttributes({
              thumb: 0,
              checked: false
            });
          }
        }
        function abort(event) {
          event.preventDefault();
        }
        function setPosts(category, count) {
          var xhr = new XMLHttpRequest();
          xhr.open('GET', data.restURL + '?cat=' + category + '&count=' + count);

          xhr.onload = function () {
            setAttributes({posts:JSON.parse(xhr.responseText)});
          };

          xhr.send();
        }
        return (
          <div className={ attributes.className }>
            <BlockControls>
              <h3>{ __('Latest Posts') }</h3>
            </BlockControls>
            <InspectorControls>
              <p>
                <label>{ __('Posts Count') }</label><br/>
                <input type="number" value={ attributes.count } onChange={ setCount } min="1"/>
              </p>
              <p>
                <label>{ __('Posts Category') }</label><br/>
                  { (() => {
                    let option = Object.keys(data.categories).map((i) => {
                      return (
                        <option value={ i }>{ data.categories[i] }</option>
                      );
                    });
                    return (
                      <select value={ attributes.category } onChange={ setCategory }>
                        { option }
                      </select>
                    )
                  })() }
              </p>
              <p>
                <label>
                  <input type="checkbox" checked={ attributes.checked } onChange={ setThumb }/>
                  { __('Show Thumbnail') }
                </label>
              </p>
            </InspectorControls>
              { (() => {
                if (!attributes.posts.length) {
                  setPosts(attributes.category, attributes.count);
                }
                let items = attributes.posts.map((post) => {
                  return (
                    <Item props={ post }/>
                  );
                });
                return (
                  <div class="preview">
                    { items }
                  </div>
                );
              })() }
          </div>
        );
      }

немного о написанном: функции setCount, setCategory и setThumb – для изменения состояния атрибутов; Item и ее вызов кодом <Item /> – чисто реактовский прикол; setPosts получает список постов по нашему маршруту, и устанавливает его в атрибут posts. BlockControls и InspectorControls – методы объекта wp.editor: первый размещает внутреннюю разметку в верхней панели(toolbar) блока, второй – в боковой панели.

И, наконец-то, edit. Он очень прост, если бы разрабатываемый блок был стационарен – он возвращал бы такую же разметку, как и основное поле блока

      save: ({attributes}) => {
        return (
          <div class="latest-posts-block">
            [gutenberg-block-latest-posts cat="{ attributes.category }" count="{ attributes.count }" thumb="{ attributes.thumb }"]
          </div>
        );
      }

но поскольку он динамичен – содержит мой шорткод. Обратите внимание, поскольку результатом должна быть HTML-разметка – я обернул шорткод в тег.

Заключение

После компиляции js был получен код регистрации нового блока. Забыл сказать в основном теле статьи – особенностью react есть то, что он рендерит по фреймам, а не по клику. Из этого следует, что любое действие следует “запирать” в функцию. Весь код выполненного тестового лежит здесь.

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

Ваш адрес email не будет опубликован.