Время прочтения — 10 минут

Как подключить EditorJS в admiral?

Содержание
Всем привет! Напоминаем, что мы разрабатываем Admiral — решение с открытым исходным кодом на React, которое позволяет быстро разрабатывать красивые CRUDы в админ-панелях, но при желании создавать полностью кастомные интерфейсы. От разработчиков для разработчиков с любовью, как говорится.
Сегодня на примере Editor JS мы покажем, как легко и быстро подключить редактор в проект, созданный в Admiral. Это блочный редактор с универсальным выводом JSON, парсинг которого легко настроить на свой вкус. По-умолчанию Editor JS выглядит достаточно ограниченным, но его фишка — в легком расширении функционала с помощью плагинов, которые можно написать самостоятельно.
• Официально поддерживаемые плагины есть здесь.
• Живую демонстрацию смотрите на официальном сайте.
• Demo всего процесса, который я опишу ниже, можно посмотреть тут.
Поехали.

Необходимые зависимости

Устанавливаем сам редактор и несколько плагинов:
1
@editorjs/editorjs
2
@editorjs/header
3
@editorjs/image
4
@editorjs/paragraph

Типизация

Если вы будете использовать TS в проекте, потребуется установить описание типов для каждого плагина, однако существуют они не все.
Пакеты с типизацией называются @types/editorjs__image и устанавливаются как yarn add -D @types/editorjs__image или npm i -D @types/editorjs__image
Если пакета с типизацией нет, либо вам просто лень устанавливать дополнительные пакеты, необходимо вручную декларировать пакет. Для этого в корне проекта создайте файл global.d.ts со следующим содержимым:

declare module '@editorjs/header'
declare module '@editorjs/image'
declare module '@editorjs/paragraph'

Интеграция в проект с admiral

Осталось добавить адаптированные под admiral компоненты с интеграцией Editor JS.
Файловая структура компонента выглядит так:
Контейнер над самим редактором для взаимодействия с хуком useForm(), который используем внутри admiral — EditorJSContainer:

import React, { memo, useCallback } from 'react'
import EditorJS, { EditorConfig, OutputData } from '@editorjs/editorjs'
import { Form, FormItemProps, useForm } from '@devfamily/admiral'
import '@/assets/editor.scss'
import EditorJSInput from '../EditorJSField'
type UploadResponseFormat = { success: 1 | 0; file: { url: string } }
interface EditorProps extends Omit<EditorConfig, 'onChange' | 'holder'> {
    isFetching: boolean
    value: OutputData
    holder?: string
    imageUploadUrl?: string
    imageUploadField?: string
    onImageUpload?: (file: Blob) => Promise<UploadResponseFormat>
    onChange: (value: OutputData) => void
}
type Props = Partial<Omit<EditorProps, 'value'≶> & FormItemProps & { name: string }
function EditorJSContainer({ name, label, required, columnSpan, ...rest }: Props) {
    const { values, errors, isFetching, setValues } = useForm()
    const value = values[name]
    const error = errors[name]?.[0]
    const onChange = (value: OutputData) => {
        setValues((values) => ({ ...values, [name]: value }))
    }
    // prevent reopen when close picker by clicking on label
    const onLabelClick = useCallback((e) => {
        e?.preventDefault()
    }, [])
    return (
        <Form.Item
            label={label}
            required={required}
            error={error}
            columnSpan={columnSpan}
            onLabelClick={onLabelClick}
        >
            <EditorJSInput value={value} onChange={onChange} isFetching={isFetching} {...rest} />
        </Form.Item>
    )
}
export default memo(EditorJSContainer)
Компонент, внутри которого инициализируется и настраивается сам редактор — EditorJSField.

import React, { memo, useCallback, useEffect, useRef } from 'react';
import EditorJS, { EditorConfig, OutputData } from '@editorjs/editorjs';
import { Form, FormItemProps, useForm } from '@devfamily/admiral';
import styles from './editorJsInput.module.scss';
import '@/assets/editor.scss';
import { EDITOR_TOOLS } from './EditorTools';
const defaultHolder = 'editorjs-container';
type UploadResponseFormat = { success: 1 | 0; file: { url: string } };
interface EditorProps extends Omit<EditorConfig, 'onChange' | 'holder'> {
  isFetching: boolean;
  value: OutputData;
  onChange: (value: OutputData) => void;
  onImageUpload?: (file: Blob) => Promise<UploadResponseFormat>
  holder?: string;
  imageUploadUrl?: string;
  imageUploadField?: string;
}
function EditorJSField({
  isFetching,
  value,
  holder = defaultHolder,
  minHeight = 300,
  onChange,
  imageUploadUrl,
  imageUploadField,
  onImageUpload,
  tools,
  ...rest
}: EditorProps) {
  const ref = useRef(null);
  useEffect(() => {
    if (!ref.current && !isFetching) {
      const editor = new EditorJS({
        holder,
        tools: tools ?? {
          ...EDITOR_TOOLS,
          image: {
            ...EDITOR_TOOLS.image,
            config: {
              endpoints: {
                byFile: imageUploadUrl,
              },
              field: imageUploadField,
              uploader: {
                uploadByFile: onImageUpload,
              },
            },
          },
        },
        data: value,
        minHeight,
        async onChange(api) {
          const data = await api.saver.save();
          onChange(data);
        },
        ...rest,
      });
      ref.current = editor;
    }

    return () => {
      ref.current?.destroy();
      ref.current = null;
    };
  }, [isFetching]);

  return (
    <section className={styles.section}>
      <div id={holder} />
    </section>
  )
}
export default EditorJSField
Стили можно создавать в случае необходимости по собственному усмотрению.
Использование компонента выглядит так:

<EditorJSInput
       required
       imageUploadUrl={apiUrl + '/editor-upload'}
       label="Контент"
       columnSpan={2}
       name="content"
/>
На данном этапе интеграция редактора в админку завершена.

Обработка конечных данных на клиенте

Осталось разобраться, как обрабатывать получаемые из редактора данные.
Конечные данные имеют следующую структуру:

{
   "time": 1550476186479,
   "blocks": [
      {
         "id": "oUq2g_tl8y",
         "type": "header",
         "data": {
            "text": "Editor.js",
            "level": 2
         }
      },
      {
         "id": "zbGZFPM-iI",
         "type": "paragraph",
         "data": {
            "text": "Hey. Meet the new Editor.."
         }
      },
      {
         "id": "XV87kJS_H1",
         "type": "list",
         "data": {
            "style": "unordered",
            "items": [
               "It is a block-styled editor",
               "It returns clean data output in JSON",
               "Designed to be extendable and pluggable with a simple API"
            ]
         }
      },
   ],
   "version": "2.8.1"
}
Вы можете парсить данные по собственному усмотрению. Мы используем библиотеку html-react-parser. Вот, как выглядит тогда компонент (в наиболее простом варианте):

import parse from 'html-react-parser';
import styles from './EditorJSParser.module.scss';

type TBlock = { id: string; type: string; data: T };
export type EditorJSData = {
  blocks: TBlock[];
  time: string;
  version: string;
};
type EditorJsProps = { data: EditorJSData };
const EditorJsParser = ({ data }: EditorJsProps) => {
  return 
{parse(data)}</div> }; export default EditorJsParser;
Применение компонента:

<EditorJsParser data={editorData} />
В SCSS/CSS файле можно задать любые стили, которые будут применяться к итоговому html.

Подводим итоги

Когда вы установите все необходимые зависимости и добавите несколько простых компонентов в свой проект, легко сможете подключить современный редактор в своё приложение и пользоваться всеми его преимуществами. Задавать любые стили, подключать доступные плагины и расширять функционал. А еще можно написать собственные плагины и внести вклад в развитие аутсорс продукта — попробуйте, это приятно :)