Розробка онлайн-редактора документів

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка онлайн-редактора документів
Складна
від 2 тижнів до 3 місяців
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка онлайн-редактора документів

Онлайн-редактор зі спільним редактуванням — одна з технічно складних задач у веб-розробці. 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 тижні.