Розробка онлайн-редактора документів
Онлайн-редактор зі спільним редактуванням — одна з технічно складних задач у веб-розробці. 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" — детерміністично
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 });
// Персистентність через 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 (офіційний backend-сервер для 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 на backend:
// Конвертація 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 на backend
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 тижні.







