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







