Как создать кастомный Gutenberg-блок

Как создать кастомный Gutenberg-блок

Полгода назад передо мной стояла задача — создать уникальный блок для WordPress-сайта клиента, который бы отображал карточки товаров с анимацией при наведении. Стандартные блоки Gutenberg не давали нужной гибкости, а плагины добавляли лишний вес. Так началось моё погружение в разработку кастомных блоков, о котором хочу рассказать подробно.

Почему я выбрал именно этот путь

Многие разработчики боятся Gutenberg из-за React и сложности сборки. Признаюсь, первые пару дней я тоже путался в терминологии — attributes, InspectorControls, RichText. Но когда разобрался в логике, процесс создания блоков стал приносить удовольствие.

Подготовка окружения

Первое, что нужно сделать — установить Node.js и инициализировать проект с помощью официального инструмента от WordPress:

npx @wordpress/create-block product-card-block
cd product-card-block
npm start

Эта команда создаёт полноценную структуру плагина с настроенным webpack, babel и всеми необходимыми зависимостями. Раньше я настраивал сборку вручную, но @wordpress/scripts экономит часы времени.

Структура моего блока

После генерации я получил такую структуру файлов:

product-card-block/
├── src/
│   ├── block.json
│   ├── edit.js
│   ├── save.js
│   ├── index.js
│   ├── style.scss
│   └── editor.scss
├── build/
└── product-card-block.php

Настройка block.json

Этот файл — метаданные блока. Именно здесь я определяю атрибуты, поддержку и категорию:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "myplugin/product-card",
  "title": "Карточка товара",
  "category": "widgets",
  "icon": "cart",
  "description": "Блок для отображения карточки товара с анимацией",
  "keywords": ["товар", "карточка", "продукт"],
  "version": "1.0.0",
  "textdomain": "product-card-block",
  "attributes": {
    "title": {
      "type": "string",
      "source": "html",
      "selector": "h3"
    },
    "price": {
      "type": "string",
      "default": ""
    },
    "imageUrl": {
      "type": "string",
      "default": ""
    },
    "imageId": {
      "type": "number"
    },
    "buttonText": {
      "type": "string",
      "default": "Купить"
    }
  },
  "supports": {
    "html": false,
    "align": ["wide", "full"]
  },
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

Логика редактирования

Это самый интересный файл, где я собрал интерфейс редактирования блока edit.js:

import { __ } from '@wordpress/i18n';
import {
  useBlockProps,
  RichText,
  MediaUpload,
  MediaUploadCheck,
  InspectorControls,
} from '@wordpress/block-editor';
import { PanelBody, TextControl, Button } from '@wordpress/components';

export default function Edit({ attributes, setAttributes }) {
  const { title, price, imageUrl, imageId, buttonText } = attributes;
  const blockProps = useBlockProps({
    className: 'product-card-block',
  });

  const onSelectImage = (media) => {
    setAttributes({
      imageUrl: media.url,
      imageId: media.id,
    });
  };

  return (
    <>
      <InspectorControls>
        <PanelBody title={__('Настройки товара', 'product-card-block')}>
          <TextControl
            label={__('Цена', 'product-card-block')}
            value={price}
            onChange={(value) => setAttributes({ price: value })}
          />
          <TextControl
            label={__('Текст кнопки', 'product-card-block')}
            value={buttonText}
            onChange={(value) => setAttributes({ buttonText: value })}
          />
        </PanelBody>
      </InspectorControls>

      <div {...blockProps}>
        <MediaUploadCheck>
          <MediaUpload
            onSelect={onSelectImage}
            allowedTypes={['image']}
            value={imageId}
            render={({ open }) => (
              <div className="product-card-image" onClick={open}>
                {imageUrl ? (
                  <img src={imageUrl} alt={title} />
                ) : (
                  <Button variant="secondary">
                    {__('Загрузить изображение', 'product-card-block')}
                  </Button>
                )}
              </div>
            )}
          />
        </MediaUploadCheck>

        <RichText
          tagName="h3"
          value={title}
          onChange={(value) => setAttributes({ title: value })}
          placeholder={__('Название товара', 'product-card-block')}
        />

        <div className="product-price">{price} ₽</div>
        <button className="product-buy-btn">{buttonText}</button>
      </div>
    </>
  );
}

Сохранение — save.js

Здесь важно точно соответствовать разметке из block.json, иначе получите ошибку валидации:

import { useBlockProps, RichText } from '@wordpress/block-editor';

export default function save({ attributes }) {
  const { title, price, imageUrl, buttonText } = attributes;
  const blockProps = useBlockProps.save({
    className: 'product-card-block',
  });

  return (
    <div {...blockProps}>
      {imageUrl && (
        <div className="product-card-image">
          <img src={imageUrl} alt={title} />
        </div>
      )}
      <RichText.Content tagName="h3" value={title} />
      <div className="product-price">{price} ₽</div>
      <button className="product-buy-btn">{buttonText}</button>
    </div>
  );
}

Стилизация с анимацией

В style.scss я добавил ту самую анимацию при наведении, ради которой всё затевалось:

.product-card-block {
  border: 1px solid #e0e0e0;
  border-radius: 12px;
  padding: 20px;
  transition: all 0.3s ease;
  cursor: pointer;

  &:hover {
    transform: translateY(-8px);
    box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
  }

  .product-card-image img {
    width: 100%;
    height: 200px;
    object-fit: cover;
    border-radius: 8px;
  }

  .product-price {
    font-size: 24px;
    font-weight: bold;
    color: #2c3e50;
    margin: 12px 0;
  }

  .product-buy-btn {
    background: #3498db;
    color: white;
    padding: 10px 24px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    transition: background 0.2s;

    &:hover {
      background: #2980b9;
    }
  }
}

Регистрация плагина в PHP

Основной файл плагина остаётся простым благодаря автоматизации:

<?php
/**
 * Plugin Name: Product Card Block
 * Description: Кастомный блок карточки товара
 * Version: 1.0.0
 */

function register_product_card_block() {
    register_block_type(__DIR__ . '/build');
}
add_action('init', 'register_product_card_block');

Сборка и тестирование

Для финальной сборки я использую:

npm run build

Это создаёт минифицированные файлы в папке build, оптимизированные для продакшена. После активации плагина блок появляется в инсертере Gutenberg под категорией «Виджеты».

Подводные камни, с которыми я столкнулся

Валидация блока — главная головная боль новичков. Если разметка в save.js не совпадает с тем, что реально сохранено в базе данных, WordPress выдаёт ошибку и предлагает восстановить блок. Я решил эту проблему, добавив deprecated версии при изменении структуры атрибутов.

Второй момент — локализация. Функция __() работает только если правильно подключен textdomain через wp_set_script_translations().

Итоговые мысли

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