Разработка онлайн-редактора документов
Онлайн-редактор с совместным редактированием — одна из технически сложных задач в веб-разработке. Google Docs строили годами. Но если не нужен полный паритет с Docs, а нужна конкретная функциональность (форматирование текста, комментарии, несколько авторов одновременно) — это реализуемо за разумный срок с правильными инструментами.
Выбор движка редактора
Три варианта со своими trade-off.
ProseMirror — низкоуровневый, максимальная гибкость, сложный порог входа. На нём построены Notion, Atlassian Confluence, GitLab. Подходит когда нужна нестандартная схема документа.
Tiptap — надстройка над ProseMirror, даёт удобный extension API, хорошую документацию, встроенную поддержку Y.js для коллаборации:
import { useEditor, EditorContent } from '@tiptap/react';
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 { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('wss://collab.example.com', documentId, ydoc);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }), // отключаем — Y.js сам управляет history
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: { name: currentUser.name, color: currentUser.color },
}),
],
});
Lexical (Meta) — новее, лучше производительность на больших документах, активная разработка. Меньше готовых расширений.
CRDT через Y.js
Operational Transformation (OT) — старый подход, который использует Google Docs. CRDT (Conflict-free Replicated Data Types) — современный, проще в реализации распределённой системы. Y.js — самая зрелая CRDT-библиотека для JavaScript.
Принцип: каждое изменение — это операция, которая может применяться в любом порядке и давать одинаковый результат. Нет центрального сервера, который должен сериализовать операции.
import * as Y from 'yjs';
const doc = new Y.Doc();
const ytext = doc.getText('content');
// Два пользователя редактируют оффлайн
const doc1 = new Y.Doc();
const doc2 = new Y.Doc();
const text1 = doc1.getText('content');
const text2 = doc2.getText('content');
// Оба начинают с одного состояния
const initialState = Y.encodeStateAsUpdate(doc);
Y.applyUpdate(doc1, initialState);
Y.applyUpdate(doc2, initialState);
// Пользователь 1 вставляет "Hello"
text1.insert(0, 'Hello');
// Пользователь 2 вставляет "World" — оффлайн
text2.insert(0, 'World');
// Синхронизация: применяем update от doc1 к doc2 и наоборот
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1));
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2));
// Оба документа сходятся к одному состоянию (порядок зависит от алгоритма)
console.log(text1.toString()); // "HelloWorld" или "WorldHello" — deterministically
console.log(text2.toString()); // то же самое
WebSocket-сервер для Y.js
y-websocket — референсная реализация, Node.js:
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils.js';
import { createClient } from 'redis';
const wss = new WebSocketServer({ port: 1234 });
// Persistence через Redis (вместо дефолтного in-memory)
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const persistence = {
provider: 'redis',
bindState: async (docName, ydoc) => {
const savedState = await redis.get(`ydoc:${docName}`);
if (savedState) {
Y.applyUpdate(ydoc, Buffer.from(savedState, 'base64'));
}
ydoc.on('update', async (update) => {
// Сохраняем полное состояние при каждом обновлении
const state = Y.encodeStateAsUpdate(ydoc);
await redis.set(
`ydoc:${docName}`,
Buffer.from(state).toString('base64'),
{ EX: 86400 * 30 } // 30 дней
);
});
},
writeState: async () => {},
};
wss.on('connection', (ws, req) => {
const docName = new URL(req.url, 'ws://x').pathname.slice(1);
setupWSConnection(ws, req, { docName, persistence });
});
Для production: hocuspocus (официальный бэкенд-сервер для Tiptap) или y-redis для персистенции.
Структура документа: схема базы данных
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL DEFAULT 'Untitled',
owner_id BIGINT REFERENCES users(id),
ydoc_state BYTEA, -- сериализованное состояние Y.Doc
snapshot_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE document_collaborators (
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id),
role TEXT CHECK (role IN ('viewer', 'commenter', 'editor', 'owner')),
invited_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (document_id, user_id)
);
-- История версий (снапшоты)
CREATE TABLE document_snapshots (
id BIGSERIAL PRIMARY KEY,
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
ydoc_state BYTEA NOT NULL,
created_by BIGINT REFERENCES users(id),
label TEXT, -- "перед публикацией", "версия для клиента"
created_at TIMESTAMPTZ DEFAULT NOW()
);
Комментарии и треки изменений
Комментарии в ProseMirror/Tiptap реализуются через marks с ID:
// Extension для комментариев
const Comment = Mark.create({
name: 'comment',
inclusive: false,
addAttributes() {
return {
commentId: { default: null },
resolved: { default: false },
};
},
parseHTML() {
return [{ tag: 'span[data-comment-id]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', {
...HTMLAttributes,
'data-comment-id': HTMLAttributes.commentId,
class: HTMLAttributes.resolved ? 'comment resolved' : 'comment',
}, 0];
},
});
// Добавление комментария к выделению
function addComment(editor: Editor, text: string) {
const commentId = crypto.randomUUID();
editor.chain().focus().setMark('comment', { commentId }).run();
// Сохраняем текст комментария в БД
saveComment({ commentId, text, documentId });
}
Экспорт документов
Экспорт в DOCX через docx (npm) или через pandoc на бэкенде:
// Конвертация ProseMirror JSON → HTML → DOCX через pandoc
async function exportToDocx(documentId: string): Promise<Buffer> {
const doc = await getDocument(documentId);
const html = prosemirrorToHtml(doc.content); // через prosemirror-to-html
// pandoc на бэкенде
const { stdout } = await exec(
`echo '${html.replace(/'/g, "'\\''")}' | pandoc -f html -t docx -o -`,
{ encoding: 'buffer' }
);
return stdout;
}
// Или нативно через docx npm package
import { Document, Paragraph, TextRun, Packer } from 'docx';
function generateDocx(nodes: ProseMirrorNode[]): Promise<Buffer> {
const paragraphs = nodes.map(node => {
const runs = node.content?.map(inline =>
new TextRun({
text: inline.text || '',
bold: inline.marks?.some(m => m.type === 'bold'),
italics: inline.marks?.some(m => m.type === 'italic'),
})
) ?? [];
return new Paragraph({ children: runs });
});
const doc = new Document({ sections: [{ children: paragraphs }] });
return Packer.toBuffer(doc);
}
Права доступа и sharing
Три уровня: просмотр, комментирование, редактирование. Публичные ссылки с опциональным паролем:
class DocumentShareController extends Controller
{
public function createShareLink(Request $request, string $docId): JsonResponse
{
$doc = Document::where('id', $docId)
->where('owner_id', $request->user()->id)
->firstOrFail();
$share = DocumentShare::create([
'document_id' => $docId,
'token' => Str::random(32),
'permission' => $request->input('permission', 'viewer'),
'password' => $request->filled('password')
? bcrypt($request->input('password'))
: null,
'expires_at' => $request->input('expires_at'),
]);
return response()->json([
'url' => route('doc.shared', $share->token),
]);
}
}
Сроки
Одиночный редактор с форматированием, экспортом в PDF/DOCX, комментариями — 6–8 недель. Добавление реального времени (Y.js + WebSocket), cursors присутствия, истории версий — ещё 4–6 недель. Полноценная система прав, audit log, интеграция с OAuth-провайдерами — ещё 3–4 недели.







