Реалізація спільного редагування на сайті
Спільне редагування — синхронізація стану документа між кількома користувачами в реальному часі без конфліктів. Задача складніша, ніж «просто WebSocket»: два користувачі одночасно редагують один документ — чия зміна перемагає без втрати жодної?
Два підходи: OT проти CRDT
Operational Transformation (OT) — класичний підхід (Google Docs). Операції трансформуються відносно конкуруючих. Вимагає центрального сервера.
CRDT (Conflict-free Replicated Data Types) — структури, що математично гарантують eventual consistency без координатора. Yjs, Automerge — основні реалізації.
| OT | CRDT (Yjs) | |
|---|---|---|
| Центральний сервер | Обов'язковий | Опціонально (P2P можливий) |
| Офлайн-редагування | Складно | Вбудовано |
| Продуктивність | Висока | Висока (Yjs дуже ефективен) |
| Складність реалізації | Висока | Низька (бібліотека займається) |
| Популярні бібліотеки | ShareDB, ot.js | Yjs, Automerge |
Для більшості нових проектів — вибір Yjs.
Yjs: архітектура
Yjs надає shared types: Y.Text, Y.Map, Y.Array, Y.XmlFragment. Зміни автоматично синхронізуються через провайдер.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
'wss://your-yjs-server.com',
'document-room-id',
ydoc,
{ connect: true }
);
const ytext = ydoc.getText('quill-content');
const editor = new Quill('#editor', { theme: 'snow' });
const binding = new QuillBinding(ytext, editor, provider.awareness);
provider.awareness.setLocalStateField('user', {
name: 'Ivan',
color: '#ff6b6b',
});
Sync Server
y-websocket — стандартний сервер для Yjs. Може персистувати до Redis або LevelDB:
const { setupWSConnection } = require('y-websocket/bin/utils');
const http = require('http');
const WebSocket = require('ws');
const server = http.createServer();
const wss = new WebSocket.Server({ server });
wss.on('connection', (conn, req) => {
setupWSConnection(conn, req, {
docName: getDocNameFromUrl(req.url),
gc: true,
});
});
server.listen(1234);
Для production — Hocuspocus (офіційний TipTap/Yjs сервер з auth, persistence, hooks):
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
const server = Server.configure({
port: 1234,
extensions: [
new Database({
fetch: async ({ documentName }) => {
return await db.getDocument(documentName);
},
store: async ({ documentName, state }) => {
await db.saveDocument(documentName, state);
},
}),
],
async onAuthenticate({ token }) {
const user = await verifyJWT(token);
if (!user) throw new Error('Unauthorized');
return { user };
},
});
server.listen();
Інтеграція з TipTap
TipTap — найзрілійший rich-text редактор з нативною підтримкою Yjs через @tiptap/extension-collaboration:
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: 'wss://your-server.com',
name: `document-${docId}`,
document: ydoc,
token: authToken,
});
const editor = new Editor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: { name: currentUser.name, color: generateColor(currentUser.id) },
}),
],
});
Курсори та awareness
Awareness — це ефемерний стан (не зберігається): курсори, виділення, статус онлайн.
provider.awareness.on('change', ({ added, updated, removed }) => {
const states = provider.awareness.getStates();
renderRemoteCursors(states);
});
editor.on('selectionUpdate', ({ editor }) => {
const { from, to } = editor.state.selection;
provider.awareness.setLocalStateField('cursor', {
anchor: Y.createRelativePositionFromTypeIndex(ytext, from),
head: Y.createRelativePositionFromTypeIndex(ytext, to),
});
});
Відносні позиції автоматично коригуються при вставці контенту.
Офлайн та персистентність
y-indexeddb зберігає у IndexedDB — користувачі працюють офлайн, синхронізуються при переподключенні:
import { IndexeddbPersistence } from 'y-indexeddb';
const persistence = new IndexeddbPersistence(`doc-${docId}`, ydoc);
persistence.on('synced', () => {
console.log('Локальний контент завантажено з IndexedDB');
});
// Автоматично зберігається при кожній зміні, мержиться при переподключенні
Історія версій
Yjs зберігає історію операцій. Для UI "історія версій":
import { UndoManager } from 'yjs';
const undoManager = new UndoManager(ytext, {
captureTimeout: 500,
trackedOrigins: new Set([provider.awareness.clientID]),
});
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') undoManager.undo();
if (e.ctrlKey && e.key === 'y') undoManager.redo();
});
Снепшоти (іменовані версії):
const snapshot = Y.snapshot(ydoc);
const snapshotBinary = Y.encodeSnapshot(snapshot);
await saveSnapshot(docId, snapshotBinary);
const restoredDoc = Y.createDocFromSnapshot(ydoc, snapshot);
Контроль доступу
Hocuspocus розрізняє read/write:
async onAuthenticate({ token, documentName }) {
const user = await verifyToken(token);
const doc = await getDocumentMeta(documentName);
if (doc.ownerId === user.id) return { user, role: 'owner' };
if (doc.editors.includes(user.id)) return { user, role: 'editor' };
if (doc.viewers.includes(user.id)) return { user, role: 'viewer' };
throw new Error('Access denied');
},
async onChange({ context, update }) {
if (context.role === 'viewer') {
throw new Error('Read-only access');
}
}
Структурований контент
Не лише текст. Для форм, kanban, презентацій:
const ytasks = ydoc.getArray('tasks');
const task = new Y.Map();
task.set('id', generateId());
task.set('title', 'New task');
task.set('status', 'todo');
ytasks.push([task]);
ytasks.observe(event => {
renderBoard(ytasks.toArray());
});
Масштабування: Multi-Node
Hocuspocus підтримує Redis адаптер для розподіленого Yjs:
import { Redis } from '@hocuspocus/extension-redis';
const server = Server.configure({
extensions: [
new Redis({
host: 'redis://your-redis:6379',
// Всі екземпляри синхронізуються через Redis pub/sub
}),
],
});
Терміни
- Базовий спільний текстовий редактор (TipTap + Hocuspocus + PostgreSQL) — 5–7 днів
- Курсори інших користувачів, presence-індикатори — плюс 2–3 дні
- Офлайн-режим (IndexedDB persistence) — плюс 1–2 дні
- Історія версій з UI — плюс 3–4 дні
- Контроль доступу read/write — плюс 2–3 дні
- Редагування структурованого контенту (дошка, форми) — окрема оцінка







