Разработка 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— готовые решения - Блоки за пределами вьюпорта заменяются плейсхолдером с сохранением высоты
Полная кастомная разработка редактора оправдана, когда проект имеет специфические требования, которые готовые решения не закрывают без значительного оверрайда их внутренностей.







