Разработка WYSIWYG-редактора для CMS сайта

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка WYSIWYG-редактора для CMS сайта
Сложная
от 1 недели до 3 месяцев
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Разработка WYSIWYG-редактора для CMS сайта

Собственный WYSIWYG-редактор пишут, когда ни один готовый инструмент не закрывает специфику проекта: нестандартные блоки, жёсткие требования к выходному HTML, глубокая интеграция с бэкендом CMS. Это серьёзная инженерная задача — от проектирования модели данных до drag-and-drop сортировки блоков. Минимальный MVP занимает 3–4 недели, полноценный редактор с кастомными блоками и историей — 8–12 недель.

Выбор базовой технологии

Писать с нуля браузерный contenteditable-редактор — плохая идея в 2024 году. Браузерные quirks, IME-ввод, выделение текста — всё это решено в готовых движках. Реальный выбор стоит между:

  • ProseMirror — нижний уровень, максимальная гибкость, крутая кривая обучения. Основа для Tiptap, Atlassian Editor, NY Times редактора
  • Slate.js — React-нативный, модель данных через JSON-дерево, хорошо для структурированного контента
  • Lexical (Meta) — производительный, хорошая поддержка concurrent mode, активно развивается

Для блочных редакторов (как Notion) — отдельная история: блоки независимы, переключаются по типу, нет вложенного rich text. Здесь можно строить на кастомной архитектуре без браузерного contenteditable.

Модель данных

Редактор должен работать с чётко определённой схемой контента. Два подхода:

Flat JSON (Editor.js-стиль):

{
  "blocks": [
    { "id": "abc123", "type": "header", "data": { "text": "Заголовок", "level": 2 } },
    { "id": "def456", "type": "paragraph", "data": { "text": "Текст параграфа" } },
    { "id": "ghi789", "type": "image", "data": { "url": "/uploads/photo.jpg", "caption": "Подпись" } }
  ],
  "version": "2.28.0"
}

Дерево (ProseMirror/Tiptap-стиль):

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 2 },
      "content": [{ "type": "text", "text": "Заголовок" }]
    },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Обычный " },
        { "type": "text", "marks": [{ "type": "bold" }], "text": "жирный" },
        { "type": "text", "text": " текст" }
      ]
    }
  ]
}

Flat JSON проще для хранения и API. Дерево лучше для сложного форматирования и трансформаций.

В PostgreSQL удобно хранить в jsonb:

ALTER TABLE pages ADD COLUMN content jsonb NOT NULL DEFAULT '{}';
CREATE INDEX idx_pages_content_gin ON pages USING GIN (content);

GIN-индекс позволит делать полнотекстовый поиск по содержимому редактора через jsonb_path_query.

Архитектура блоков

Каждый тип блока — это отдельная React-компонента с двумя режимами: отображение и редактирование.

interface BlockPlugin<T = Record<string, unknown>> {
  type: string;
  label: string;
  icon: React.ReactNode;
  defaultData: T;
  render: (data: T, ctx: RenderContext) => React.ReactNode;
  edit: (data: T, onChange: (data: T) => void) => React.ReactNode;
  validate?: (data: T) => ValidationError[];
  toHTML?: (data: T) => string;
}

// Регистрация блоков
const registry = new BlockRegistry();
registry.register(ParagraphBlock);
registry.register(HeadingBlock);
registry.register(ImageBlock);
registry.register(VideoEmbedBlock);
registry.register(CodeBlock);
registry.register(TableBlock);
registry.register(CalloutBlock);

Регистр блоков позволяет подключать новые типы без изменения ядра редактора — плагинная архитектура.

Toolbar и форматирование

Для rich text блоков toolbar должен появляться контекстно — при выделении текста. Фиксированный toolbar избыточен и мешает работе.

const FloatingToolbar: React.FC = () => {
  const { selection, commands } = useEditorState();
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!selection || selection.collapsed) {
      ref.current!.style.display = 'none';
      return;
    }
    const rect = getSelectionBoundingRect();
    ref.current!.style.display = 'flex';
    ref.current!.style.top = `${rect.top - 44}px`;
    ref.current!.style.left = `${rect.left + rect.width / 2}px`;
    ref.current!.style.transform = 'translateX(-50%)';
  }, [selection]);

  return (
    <div ref={ref} className="floating-toolbar">
      <ToolbarButton icon={<Bold />} action={() => commands.toggleMark('bold')} />
      <ToolbarButton icon={<Italic />} action={() => commands.toggleMark('italic')} />
      <ToolbarButton icon={<Link />} action={() => commands.setLink()} />
    </div>
  );
};

Slash-команды

Стандарт де-факто для блочных редакторов — ввод / вызывает меню вставки блока. Реализация:

const useSlashMenu = (editor: Editor) => {
  const [query, setQuery] = useState('');
  const [visible, setVisible] = useState(false);

  editor.on('keydown', (e) => {
    if (e.key === '/') {
      setVisible(true);
      setQuery('');
    }
  });

  editor.on('keyup', () => {
    if (visible) {
      const currentText = editor.getCurrentLineText();
      if (currentText.startsWith('/')) {
        setQuery(currentText.slice(1));
      } else {
        setVisible(false);
      }
    }
  });

  const filteredBlocks = registry.all().filter(b =>
    b.label.toLowerCase().includes(query.toLowerCase())
  );

  return { visible, filteredBlocks, setVisible };
};

Drag-and-drop сортировка блоков

Блоки должны перетаскиваться. Библиотека @dnd-kit/core — лучший выбор для React:

import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';

const BlockEditor: React.FC = ({ blocks, onChange }) => {
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (over && active.id !== over.id) {
      const oldIndex = blocks.findIndex(b => b.id === active.id);
      const newIndex = blocks.findIndex(b => b.id === over.id);
      onChange(arrayMove(blocks, oldIndex, newIndex));
    }
  };

  return (
    <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={blocks.map(b => b.id)} strategy={verticalListSortingStrategy}>
        {blocks.map(block => (
          <SortableBlock key={block.id} block={block} />
        ))}
      </SortableContext>
    </DndContext>
  );
};

История изменений (undo/redo)

Паттерн Command + стек истории:

class EditorHistory {
  private undoStack: EditorState[] = [];
  private redoStack: EditorState[] = [];
  private maxSize = 100;

  push(state: EditorState) {
    this.undoStack.push(structuredClone(state));
    if (this.undoStack.length > this.maxSize) {
      this.undoStack.shift();
    }
    this.redoStack = []; // Сбрасываем redo при новом действии
  }

  undo(current: EditorState): EditorState | null {
    if (this.undoStack.length === 0) return null;
    this.redoStack.push(structuredClone(current));
    return this.undoStack.pop()!;
  }

  redo(current: EditorState): EditorState | null {
    if (this.redoStack.length === 0) return null;
    this.undoStack.push(structuredClone(current));
    return this.redoStack.pop()!;
  }
}

Для больших документов structuredClone дорог — используют иммутабельные структуры данных (Immer, immutable.js) или delta-патчи.

Автосохранение

Редактор должен сохранять изменения без участия пользователя:

const useAutoSave = (content: BlockData[], pageId: number) => {
  const lastSaved = useRef<string>('');
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    const serialized = JSON.stringify(content);
    if (serialized === lastSaved.current) return;

    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(async () => {
      await api.patch(`/pages/${pageId}`, { content });
      lastSaved.current = serialized;
      setStatus('saved');
    }, 2000); // 2 секунды дебаунс

    return () => clearTimeout(timerRef.current);
  }, [content, pageId]);
};

Статус сохранения отображается в хедере: «Сохранено», «Сохранение...», «Есть несохранённые изменения».

Рендеринг на фронтенде сайта

JSON-контент редактора нужно рендерить на публичной части сайта. Два варианта: SSR-рендер через React-компоненты или генерация HTML на сервере.

// PHP-рендерер блоков (Laravel)
class BlockRenderer
{
    protected array $renderers = [];

    public function register(string $type, callable $renderer): void
    {
        $this->renderers[$type] = $renderer;
    }

    public function render(array $blocks): string
    {
        return collect($blocks)
            ->map(fn($block) => ($this->renderers[$block['type']] ?? fn() => '')($block['data']))
            ->implode("\n");
    }
}

// Регистрация рендерера
$renderer->register('paragraph', fn($data) =>
    '<p>' . e($data['text']) . '</p>'
);
$renderer->register('image', fn($data) =>
    '<figure><img src="' . e($data['url']) . '" alt="' . e($data['caption']) . '">
     <figcaption>' . e($data['caption']) . '</figcaption></figure>'
);

Работа с медиа

Редактор должен интегрироваться с медиа-библиотекой CMS. При вставке изображения открывается модальное окно со списком загруженных файлов — без перехода на другую страницу. Загрузка через drag-and-drop прямо в блок:

const handleImageDrop = async (file: File) => {
  const formData = new FormData();
  formData.append('file', file);

  const { data } = await api.post('/media', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (e) => setProgress(e.loaded / e.total! * 100),
  });

  insertBlock({ type: 'image', data: { url: data.url, caption: '' } });
};

Производительность с большими документами

Для статей с десятками изображений и сотнями блоков нужна виртуализация:

  • Рендерить только видимые блоки + буфер в 5–10 блоков выше/ниже вьюпорта
  • react-window или @tanstack/virtual — готовые решения
  • Блоки за пределами вьюпорта заменяются плейсхолдером с сохранением высоты

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