Розробка 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 році — поганий вибір. Браузерні особливості, IME-введення, виділення тексту — все це вирішено в готових системах. Реальний вибір стоїть між:

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

Для блочних редакторів (як 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 дорогий — використовуйте immutable структури даних (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 блоків вище/нижче viewport
  • react-window або @tanstack/virtual — готові рішення
  • Блоки за межами viewport замінені плейсхолдером, що зберігає висоту

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