بناء التطبيقات اللحظية

ميزات التحرير التعاوني

5 دقيقة للقراءة

Real-time collaboration lets multiple users edit the same content simultaneously. We'll build a collaborative document editor with live cursors, presence awareness, and conflict-free synchronization.

Choosing a Collaboration Strategy

// lib/collab/strategies.ts
// Different approaches to real-time collaboration

// 1. Last-Write-Wins (simplest, lossy)
// Good for: Settings, simple forms
// Problem: Overwrites concurrent changes

// 2. Operational Transformation (OT)
// Good for: Text editing, documents
// Used by: Google Docs

// 3. Conflict-free Replicated Data Types (CRDTs)
// Good for: Offline-first, distributed systems
// Used by: Figma, Linear

// We'll use Yjs (CRDT) for robust collaboration

Setting Up Yjs with WebSocket Provider

npm install yjs y-websocket y-prosemirror @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
// lib/collab/yjs-server.ts
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';

const wss = new WebSocketServer({ port: 1234 });

wss.on('connection', (ws, req) => {
  // Extract document ID from URL
  const docName = req.url?.slice(1) || 'default';

  // Setup Yjs connection with persistence
  setupWSConnection(ws, req, {
    docName,
    gc: true, // Enable garbage collection
  });
});

console.log('Yjs WebSocket server running on ws://localhost:1234');
// lib/collab/document-provider.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

interface CollabConfig {
  documentId: string;
  userId: string;
  userName: string;
  userColor: string;
}

export function createCollabProvider(config: CollabConfig) {
  // Create Yjs document
  const ydoc = new Y.Doc();

  // Connect to WebSocket server
  const provider = new WebsocketProvider(
    process.env.NEXT_PUBLIC_YJS_URL || 'ws://localhost:1234',
    config.documentId,
    ydoc,
    { connect: true }
  );

  // Set user awareness (for cursors and presence)
  provider.awareness.setLocalStateField('user', {
    id: config.userId,
    name: config.userName,
    color: config.userColor,
  });

  return { ydoc, provider };
}

// Get shared content types from Yjs doc
export function getSharedTypes(ydoc: Y.Doc) {
  return {
    // For rich text editing
    content: ydoc.getXmlFragment('content'),
    // For simple text
    title: ydoc.getText('title'),
    // For structured data
    metadata: ydoc.getMap('metadata'),
    // For lists/arrays
    comments: ydoc.getArray('comments'),
  };
}

Building the Collaborative Editor

// components/collab/collaborative-editor.tsx
'use client';

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 { useEffect, useState } from 'react';
import { createCollabProvider } from '@/lib/collab/document-provider';
import * as Y from 'yjs';

interface CollaborativeEditorProps {
  documentId: string;
  user: {
    id: string;
    name: string;
    color: string;
  };
}

export function CollaborativeEditor({ documentId, user }: CollaborativeEditorProps) {
  const [provider, setProvider] = useState<any>(null);
  const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
  const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');

  useEffect(() => {
    const { ydoc: doc, provider: prov } = createCollabProvider({
      documentId,
      userId: user.id,
      userName: user.name,
      userColor: user.color,
    });

    setYdoc(doc);
    setProvider(prov);

    // Track connection status
    prov.on('status', ({ status }: { status: string }) => {
      setStatus(status as any);
    });

    return () => {
      prov.destroy();
      doc.destroy();
    };
  }, [documentId, user]);

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        history: false, // Yjs handles history
      }),
      Collaboration.configure({
        document: ydoc,
        field: 'content',
      }),
      CollaborationCursor.configure({
        provider,
        user: {
          name: user.name,
          color: user.color,
        },
      }),
    ],
    editorProps: {
      attributes: {
        class: 'prose prose-sm sm:prose lg:prose-lg focus:outline-none min-h-[300px] p-4',
      },
    },
  }, [ydoc, provider]);

  if (!ydoc || !provider) {
    return <EditorSkeleton />;
  }

  return (
    <div className="border rounded-lg overflow-hidden">
      <EditorToolbar editor={editor} />
      <ConnectionStatus status={status} />
      <EditorContent editor={editor} />
      <ActiveUsers provider={provider} />
    </div>
  );
}

function ConnectionStatus({ status }: { status: string }) {
  const colors = {
    connecting: 'bg-yellow-500',
    connected: 'bg-green-500',
    disconnected: 'bg-red-500',
  };

  return (
    <div className="flex items-center gap-2 px-4 py-2 border-b bg-gray-50">
      <div className={`w-2 h-2 rounded-full ${colors[status as keyof typeof colors]}`} />
      <span className="text-sm text-gray-600 capitalize">{status}</span>
    </div>
  );
}

function ActiveUsers({ provider }: { provider: any }) {
  const [users, setUsers] = useState<any[]>([]);

  useEffect(() => {
    const updateUsers = () => {
      const states = Array.from(provider.awareness.getStates().values());
      setUsers(states.filter((s: any) => s.user).map((s: any) => s.user));
    };

    provider.awareness.on('change', updateUsers);
    updateUsers();

    return () => provider.awareness.off('change', updateUsers);
  }, [provider]);

  return (
    <div className="flex items-center gap-2 px-4 py-2 border-t bg-gray-50">
      <span className="text-sm text-gray-500">Active:</span>
      <div className="flex -space-x-2">
        {users.map((user) => (
          <div
            key={user.id}
            className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium ring-2 ring-white"
            style={{ backgroundColor: user.color }}
            title={user.name}
          >
            {user.name[0].toUpperCase()}
          </div>
        ))}
      </div>
    </div>
  );
}

Live Cursor Tracking

// components/collab/cursor-overlay.tsx
'use client';

import { useEffect, useState, useRef } from 'react';
import type { Awareness } from 'y-protocols/awareness';

interface CursorPosition {
  x: number;
  y: number;
  userId: string;
  userName: string;
  userColor: string;
}

interface CursorOverlayProps {
  awareness: Awareness;
  containerRef: React.RefObject<HTMLElement>;
}

export function CursorOverlay({ awareness, containerRef }: CursorOverlayProps) {
  const [cursors, setCursors] = useState<Map<number, CursorPosition>>(new Map());

  useEffect(() => {
    const updateCursors = () => {
      const newCursors = new Map<number, CursorPosition>();

      awareness.getStates().forEach((state, clientId) => {
        if (clientId === awareness.clientID) return; // Skip self
        if (state.cursor && state.user) {
          newCursors.set(clientId, {
            x: state.cursor.x,
            y: state.cursor.y,
            userId: state.user.id,
            userName: state.user.name,
            userColor: state.user.color,
          });
        }
      });

      setCursors(newCursors);
    };

    awareness.on('change', updateCursors);
    return () => awareness.off('change', updateCursors);
  }, [awareness]);

  // Track local cursor position
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleMouseMove = (e: MouseEvent) => {
      const rect = container.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      awareness.setLocalStateField('cursor', { x, y });
    };

    const handleMouseLeave = () => {
      awareness.setLocalStateField('cursor', null);
    };

    container.addEventListener('mousemove', handleMouseMove);
    container.addEventListener('mouseleave', handleMouseLeave);

    return () => {
      container.removeEventListener('mousemove', handleMouseMove);
      container.removeEventListener('mouseleave', handleMouseLeave);
    };
  }, [awareness, containerRef]);

  return (
    <div className="absolute inset-0 pointer-events-none overflow-hidden">
      {Array.from(cursors.entries()).map(([clientId, cursor]) => (
        <RemoteCursor key={clientId} cursor={cursor} />
      ))}
    </div>
  );
}

function RemoteCursor({ cursor }: { cursor: CursorPosition }) {
  return (
    <div
      className="absolute transition-all duration-75 ease-out"
      style={{
        left: cursor.x,
        top: cursor.y,
        transform: 'translate(-2px, -2px)',
      }}
    >
      {/* Cursor pointer */}
      <svg
        width="24"
        height="24"
        viewBox="0 0 24 24"
        fill={cursor.userColor}
        className="drop-shadow-md"
      >
        <path d="M5.65 3.15l13.5 8.5c.7.44.55 1.5-.25 1.7l-5.3 1.3-2.5 5.1c-.35.7-1.3.6-1.5-.15L4.35 4.65c-.25-.9.6-1.65 1.3-1.5z" />
      </svg>
      {/* User name label */}
      <div
        className="absolute left-5 top-4 px-2 py-1 rounded text-xs text-white whitespace-nowrap"
        style={{ backgroundColor: cursor.userColor }}
      >
        {cursor.userName}
      </div>
    </div>
  );
}

Collaborative Canvas (Whiteboard)

// components/collab/collaborative-canvas.tsx
'use client';

import { useEffect, useRef, useState, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

interface Shape {
  id: string;
  type: 'rect' | 'circle' | 'line' | 'path';
  x: number;
  y: number;
  width?: number;
  height?: number;
  radius?: number;
  points?: { x: number; y: number }[];
  color: string;
  createdBy: string;
}

export function CollaborativeCanvas({ documentId, user }: { documentId: string; user: any }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const ydocRef = useRef<Y.Doc | null>(null);
  const shapesRef = useRef<Y.Array<Shape> | null>(null);
  const [tool, setTool] = useState<'select' | 'rect' | 'circle' | 'draw'>('draw');
  const [isDrawing, setIsDrawing] = useState(false);
  const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);

  useEffect(() => {
    const ydoc = new Y.Doc();
    const provider = new WebsocketProvider(
      process.env.NEXT_PUBLIC_YJS_URL || 'ws://localhost:1234',
      `canvas-${documentId}`,
      ydoc
    );

    provider.awareness.setLocalStateField('user', user);

    const shapes = ydoc.getArray<Shape>('shapes');
    ydocRef.current = ydoc;
    shapesRef.current = shapes;

    // Observe changes and redraw
    shapes.observe(() => {
      redrawCanvas();
    });

    // Initial draw
    redrawCanvas();

    return () => {
      provider.destroy();
      ydoc.destroy();
    };
  }, [documentId, user]);

  const redrawCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    const shapes = shapesRef.current;
    if (!canvas || !shapes) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // Clear canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // Draw all shapes
    shapes.forEach((shape) => {
      ctx.fillStyle = shape.color;
      ctx.strokeStyle = shape.color;
      ctx.lineWidth = 2;

      switch (shape.type) {
        case 'rect':
          ctx.fillRect(shape.x, shape.y, shape.width!, shape.height!);
          break;
        case 'circle':
          ctx.beginPath();
          ctx.arc(shape.x, shape.y, shape.radius!, 0, Math.PI * 2);
          ctx.fill();
          break;
        case 'path':
          if (shape.points && shape.points.length > 1) {
            ctx.beginPath();
            ctx.moveTo(shape.points[0].x, shape.points[0].y);
            shape.points.slice(1).forEach((point) => {
              ctx.lineTo(point.x, point.y);
            });
            ctx.stroke();
          }
          break;
      }
    });

    // Draw current path being created
    if (currentPath.length > 1) {
      ctx.strokeStyle = user.color;
      ctx.beginPath();
      ctx.moveTo(currentPath[0].x, currentPath[0].y);
      currentPath.slice(1).forEach((point) => {
        ctx.lineTo(point.x, point.y);
      });
      ctx.stroke();
    }
  }, [currentPath, user.color]);

  const handlePointerDown = (e: React.PointerEvent) => {
    const rect = canvasRef.current!.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    if (tool === 'draw') {
      setIsDrawing(true);
      setCurrentPath([{ x, y }]);
    } else if (tool === 'rect') {
      addShape({
        id: crypto.randomUUID(),
        type: 'rect',
        x,
        y,
        width: 100,
        height: 80,
        color: user.color,
        createdBy: user.id,
      });
    } else if (tool === 'circle') {
      addShape({
        id: crypto.randomUUID(),
        type: 'circle',
        x,
        y,
        radius: 50,
        color: user.color,
        createdBy: user.id,
      });
    }
  };

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!isDrawing || tool !== 'draw') return;

    const rect = canvasRef.current!.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    setCurrentPath((prev) => [...prev, { x, y }]);
    redrawCanvas();
  };

  const handlePointerUp = () => {
    if (isDrawing && currentPath.length > 1) {
      addShape({
        id: crypto.randomUUID(),
        type: 'path',
        x: currentPath[0].x,
        y: currentPath[0].y,
        points: currentPath,
        color: user.color,
        createdBy: user.id,
      });
    }
    setIsDrawing(false);
    setCurrentPath([]);
  };

  const addShape = (shape: Shape) => {
    if (shapesRef.current && ydocRef.current) {
      ydocRef.current.transact(() => {
        shapesRef.current!.push([shape]);
      });
    }
  };

  return (
    <div className="border rounded-lg overflow-hidden">
      <div className="flex gap-2 p-2 bg-gray-100 border-b">
        {(['select', 'draw', 'rect', 'circle'] as const).map((t) => (
          <button
            key={t}
            onClick={() => setTool(t)}
            className={`px-3 py-1 rounded ${tool === t ? 'bg-blue-500 text-white' : 'bg-white'}`}
          >
            {t}
          </button>
        ))}
      </div>
      <canvas
        ref={canvasRef}
        width={800}
        height={600}
        className="bg-white cursor-crosshair"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerLeave={handlePointerUp}
      />
    </div>
  );
}

Conflict Resolution Strategies

// lib/collab/conflict-resolution.ts
import * as Y from 'yjs';

// Yjs CRDTs automatically resolve conflicts, but you can add business logic

export function setupConflictHandling(ydoc: Y.Doc) {
  const content = ydoc.getText('content');

  // Track local changes vs remote changes
  ydoc.on('update', (update: Uint8Array, origin: any) => {
    if (origin === 'local') {
      // Local change - save to undo stack
      console.log('Local change applied');
    } else {
      // Remote change - maybe show notification
      console.log('Remote change received');
    }
  });
}

// Undo/Redo with Yjs
export function createUndoManager(ydoc: Y.Doc) {
  const content = ydoc.getXmlFragment('content');
  const undoManager = new Y.UndoManager(content, {
    trackedOrigins: new Set(['local']),
    captureTimeout: 500, // Group changes within 500ms
  });

  return {
    undo: () => undoManager.undo(),
    redo: () => undoManager.redo(),
    canUndo: () => undoManager.undoStack.length > 0,
    canRedo: () => undoManager.redoStack.length > 0,
  };
}

Presence and Selection Awareness

// hooks/use-awareness.ts
import { useState, useEffect, useCallback } from 'react';
import type { Awareness } from 'y-protocols/awareness';

interface UserState {
  id: string;
  name: string;
  color: string;
  cursor?: { x: number; y: number };
  selection?: { start: number; end: number };
  isTyping?: boolean;
  lastActive: number;
}

export function useAwareness(awareness: Awareness | null, localUser: Omit<UserState, 'lastActive'>) {
  const [users, setUsers] = useState<Map<number, UserState>>(new Map());

  // Set local user state
  useEffect(() => {
    if (!awareness) return;

    awareness.setLocalStateField('user', {
      ...localUser,
      lastActive: Date.now(),
    });

    // Update lastActive periodically
    const interval = setInterval(() => {
      awareness.setLocalStateField('user', {
        ...awareness.getLocalState()?.user,
        lastActive: Date.now(),
      });
    }, 30000);

    return () => clearInterval(interval);
  }, [awareness, localUser]);

  // Track all users
  useEffect(() => {
    if (!awareness) return;

    const updateUsers = () => {
      const newUsers = new Map<number, UserState>();
      awareness.getStates().forEach((state, clientId) => {
        if (state.user) {
          newUsers.set(clientId, state.user);
        }
      });
      setUsers(newUsers);
    };

    awareness.on('change', updateUsers);
    updateUsers();

    return () => awareness.off('change', updateUsers);
  }, [awareness]);

  const setSelection = useCallback((selection: { start: number; end: number } | null) => {
    if (!awareness) return;
    awareness.setLocalStateField('user', {
      ...awareness.getLocalState()?.user,
      selection,
    });
  }, [awareness]);

  const setTyping = useCallback((isTyping: boolean) => {
    if (!awareness) return;
    awareness.setLocalStateField('user', {
      ...awareness.getLocalState()?.user,
      isTyping,
    });
  }, [awareness]);

  // Get active users (active in last 2 minutes)
  const activeUsers = Array.from(users.values()).filter(
    (user) => Date.now() - user.lastActive < 120000
  );

  // Get users with selections (for highlighting)
  const usersWithSelections = activeUsers.filter((user) => user.selection);

  return {
    users: activeUsers,
    usersWithSelections,
    setSelection,
    setTyping,
    localClientId: awareness?.clientID,
  };
}

Selection Highlight Component

// components/collab/selection-highlights.tsx
'use client';

import { useMemo } from 'react';

interface SelectionHighlight {
  userId: string;
  userName: string;
  userColor: string;
  start: number;
  end: number;
}

interface SelectionHighlightsProps {
  selections: SelectionHighlight[];
  getCharacterPosition: (index: number) => { top: number; left: number; height: number };
}

export function SelectionHighlights({ selections, getCharacterPosition }: SelectionHighlightsProps) {
  const highlights = useMemo(() => {
    return selections.map((selection) => {
      const startPos = getCharacterPosition(selection.start);
      const endPos = getCharacterPosition(selection.end);

      // Simple single-line highlight
      return {
        ...selection,
        style: {
          position: 'absolute' as const,
          top: startPos.top,
          left: startPos.left,
          width: endPos.left - startPos.left,
          height: startPos.height,
          backgroundColor: selection.userColor + '40', // 25% opacity
          borderLeft: `2px solid ${selection.userColor}`,
        },
      };
    });
  }, [selections, getCharacterPosition]);

  return (
    <div className="absolute inset-0 pointer-events-none">
      {highlights.map((highlight) => (
        <div key={`${highlight.userId}-selection`} style={highlight.style}>
          <span
            className="absolute -top-5 left-0 px-1 text-xs text-white rounded"
            style={{ backgroundColor: highlight.userColor }}
          >
            {highlight.userName}
          </span>
        </div>
      ))}
    </div>
  );
}

Persistent Storage with IndexedDB

// lib/collab/persistence.ts
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

export function setupPersistence(documentId: string, ydoc: Y.Doc) {
  // Persist to IndexedDB for offline support
  const persistence = new IndexeddbPersistence(documentId, ydoc);

  persistence.on('synced', () => {
    console.log('Content loaded from IndexedDB');
  });

  return persistence;
}

// Sync with server database
export async function saveToServer(documentId: string, ydoc: Y.Doc) {
  const state = Y.encodeStateAsUpdate(ydoc);
  const base64State = Buffer.from(state).toString('base64');

  await fetch(`/api/documents/${documentId}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ state: base64State }),
  });
}

export async function loadFromServer(documentId: string, ydoc: Y.Doc) {
  const response = await fetch(`/api/documents/${documentId}`);
  if (!response.ok) return;

  const { state } = await response.json();
  if (state) {
    const update = Buffer.from(state, 'base64');
    Y.applyUpdate(ydoc, new Uint8Array(update));
  }
}

What You've Learned

In this lesson, you've built:

  • Yjs integration - CRDT-based collaboration for conflict-free editing
  • Live cursors - Real-time cursor tracking across users
  • Collaborative canvas - Whiteboard with synchronized drawing
  • Presence awareness - Selection highlighting and typing indicators
  • Offline persistence - IndexedDB for offline-first collaboration

These patterns power tools like Figma, Notion, and Google Docs. The combination of CRDTs and WebSockets gives you both reliability and real-time performance.


ميزات التحرير التعاوني

التعاون في الوقت الحقيقي يتيح لعدة مستخدمين تحرير نفس المحتوى في وقت واحد. سنبني محرر مستندات تعاوني مع مؤشرات حية ووعي بالحضور ومزامنة خالية من التعارضات.

اختيار استراتيجية التعاون

// lib/collab/strategies.ts
// مقاربات مختلفة للتعاون في الوقت الحقيقي

// 1. آخر كتابة تفوز (الأبسط، يفقد بيانات)
// جيد لـ: الإعدادات، النماذج البسيطة
// المشكلة: يكتب فوق التغييرات المتزامنة

// 2. التحويل العملياتي (OT)
// جيد لـ: تحرير النصوص، المستندات
// يستخدمه: Google Docs

// 3. أنواع البيانات المكررة الخالية من التعارض (CRDTs)
// جيد لـ: أولوية العمل دون اتصال، الأنظمة الموزعة
// يستخدمه: Figma، Linear

// سنستخدم Yjs (CRDT) للتعاون القوي

إعداد Yjs مع مزود WebSocket

npm install yjs y-websocket y-prosemirror @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
// lib/collab/yjs-server.ts
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';

const wss = new WebSocketServer({ port: 1234 });

wss.on('connection', (ws, req) => {
  // استخراج معرف المستند من URL
  const docName = req.url?.slice(1) || 'default';

  // إعداد اتصال Yjs مع الحفظ
  setupWSConnection(ws, req, {
    docName,
    gc: true, // تفعيل تجميع القمامة
  });
});

console.log('خادم Yjs WebSocket يعمل على ws://localhost:1234');
// lib/collab/document-provider.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

interface CollabConfig {
  documentId: string;
  userId: string;
  userName: string;
  userColor: string;
}

export function createCollabProvider(config: CollabConfig) {
  // إنشاء مستند Yjs
  const ydoc = new Y.Doc();

  // الاتصال بخادم WebSocket
  const provider = new WebsocketProvider(
    process.env.NEXT_PUBLIC_YJS_URL || 'ws://localhost:1234',
    config.documentId,
    ydoc,
    { connect: true }
  );

  // تعيين وعي المستخدم (للمؤشرات والحضور)
  provider.awareness.setLocalStateField('user', {
    id: config.userId,
    name: config.userName,
    color: config.userColor,
  });

  return { ydoc, provider };
}

// الحصول على أنواع المحتوى المشترك من مستند Yjs
export function getSharedTypes(ydoc: Y.Doc) {
  return {
    // لتحرير النص الغني
    content: ydoc.getXmlFragment('content'),
    // للنص البسيط
    title: ydoc.getText('title'),
    // للبيانات المهيكلة
    metadata: ydoc.getMap('metadata'),
    // للقوائم/المصفوفات
    comments: ydoc.getArray('comments'),
  };
}

بناء المحرر التعاوني

// components/collab/collaborative-editor.tsx
'use client';

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 { useEffect, useState } from 'react';
import { createCollabProvider } from '@/lib/collab/document-provider';
import * as Y from 'yjs';

interface CollaborativeEditorProps {
  documentId: string;
  user: {
    id: string;
    name: string;
    color: string;
  };
}

export function CollaborativeEditor({ documentId, user }: CollaborativeEditorProps) {
  const [provider, setProvider] = useState<any>(null);
  const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
  const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');

  useEffect(() => {
    const { ydoc: doc, provider: prov } = createCollabProvider({
      documentId,
      userId: user.id,
      userName: user.name,
      userColor: user.color,
    });

    setYdoc(doc);
    setProvider(prov);

    // تتبع حالة الاتصال
    prov.on('status', ({ status }: { status: string }) => {
      setStatus(status as any);
    });

    return () => {
      prov.destroy();
      doc.destroy();
    };
  }, [documentId, user]);

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        history: false, // Yjs يدير السجل
      }),
      Collaboration.configure({
        document: ydoc,
        field: 'content',
      }),
      CollaborationCursor.configure({
        provider,
        user: {
          name: user.name,
          color: user.color,
        },
      }),
    ],
    editorProps: {
      attributes: {
        class: 'prose prose-sm sm:prose lg:prose-lg focus:outline-none min-h-[300px] p-4',
      },
    },
  }, [ydoc, provider]);

  if (!ydoc || !provider) {
    return <EditorSkeleton />;
  }

  return (
    <div className="border rounded-lg overflow-hidden">
      <EditorToolbar editor={editor} />
      <ConnectionStatus status={status} />
      <EditorContent editor={editor} />
      <ActiveUsers provider={provider} />
    </div>
  );
}

function ConnectionStatus({ status }: { status: string }) {
  const colors = {
    connecting: 'bg-yellow-500',
    connected: 'bg-green-500',
    disconnected: 'bg-red-500',
  };

  return (
    <div className="flex items-center gap-2 px-4 py-2 border-b bg-gray-50">
      <div className={`w-2 h-2 rounded-full ${colors[status as keyof typeof colors]}`} />
      <span className="text-sm text-gray-600 capitalize">{status}</span>
    </div>
  );
}

function ActiveUsers({ provider }: { provider: any }) {
  const [users, setUsers] = useState<any[]>([]);

  useEffect(() => {
    const updateUsers = () => {
      const states = Array.from(provider.awareness.getStates().values());
      setUsers(states.filter((s: any) => s.user).map((s: any) => s.user));
    };

    provider.awareness.on('change', updateUsers);
    updateUsers();

    return () => provider.awareness.off('change', updateUsers);
  }, [provider]);

  return (
    <div className="flex items-center gap-2 px-4 py-2 border-t bg-gray-50">
      <span className="text-sm text-gray-500">نشط:</span>
      <div className="flex -space-x-2">
        {users.map((user) => (
          <div
            key={user.id}
            className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium ring-2 ring-white"
            style={{ backgroundColor: user.color }}
            title={user.name}
          >
            {user.name[0].toUpperCase()}
          </div>
        ))}
      </div>
    </div>
  );
}

تتبع المؤشرات الحية

// components/collab/cursor-overlay.tsx
'use client';

import { useEffect, useState, useRef } from 'react';
import type { Awareness } from 'y-protocols/awareness';

interface CursorPosition {
  x: number;
  y: number;
  userId: string;
  userName: string;
  userColor: string;
}

interface CursorOverlayProps {
  awareness: Awareness;
  containerRef: React.RefObject<HTMLElement>;
}

export function CursorOverlay({ awareness, containerRef }: CursorOverlayProps) {
  const [cursors, setCursors] = useState<Map<number, CursorPosition>>(new Map());

  useEffect(() => {
    const updateCursors = () => {
      const newCursors = new Map<number, CursorPosition>();

      awareness.getStates().forEach((state, clientId) => {
        if (clientId === awareness.clientID) return; // تخطي النفس
        if (state.cursor && state.user) {
          newCursors.set(clientId, {
            x: state.cursor.x,
            y: state.cursor.y,
            userId: state.user.id,
            userName: state.user.name,
            userColor: state.user.color,
          });
        }
      });

      setCursors(newCursors);
    };

    awareness.on('change', updateCursors);
    return () => awareness.off('change', updateCursors);
  }, [awareness]);

  // تتبع موقع المؤشر المحلي
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleMouseMove = (e: MouseEvent) => {
      const rect = container.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      awareness.setLocalStateField('cursor', { x, y });
    };

    const handleMouseLeave = () => {
      awareness.setLocalStateField('cursor', null);
    };

    container.addEventListener('mousemove', handleMouseMove);
    container.addEventListener('mouseleave', handleMouseLeave);

    return () => {
      container.removeEventListener('mousemove', handleMouseMove);
      container.removeEventListener('mouseleave', handleMouseLeave);
    };
  }, [awareness, containerRef]);

  return (
    <div className="absolute inset-0 pointer-events-none overflow-hidden">
      {Array.from(cursors.entries()).map(([clientId, cursor]) => (
        <RemoteCursor key={clientId} cursor={cursor} />
      ))}
    </div>
  );
}

function RemoteCursor({ cursor }: { cursor: CursorPosition }) {
  return (
    <div
      className="absolute transition-all duration-75 ease-out"
      style={{
        left: cursor.x,
        top: cursor.y,
        transform: 'translate(-2px, -2px)',
      }}
    >
      {/* مؤشر الفأرة */}
      <svg
        width="24"
        height="24"
        viewBox="0 0 24 24"
        fill={cursor.userColor}
        className="drop-shadow-md"
      >
        <path d="M5.65 3.15l13.5 8.5c.7.44.55 1.5-.25 1.7l-5.3 1.3-2.5 5.1c-.35.7-1.3.6-1.5-.15L4.35 4.65c-.25-.9.6-1.65 1.3-1.5z" />
      </svg>
      {/* تسمية اسم المستخدم */}
      <div
        className="absolute left-5 top-4 px-2 py-1 rounded text-xs text-white whitespace-nowrap"
        style={{ backgroundColor: cursor.userColor }}
      >
        {cursor.userName}
      </div>
    </div>
  );
}

لوحة رسم تعاونية (سبورة بيضاء)

// components/collab/collaborative-canvas.tsx
'use client';

import { useEffect, useRef, useState, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

interface Shape {
  id: string;
  type: 'rect' | 'circle' | 'line' | 'path';
  x: number;
  y: number;
  width?: number;
  height?: number;
  radius?: number;
  points?: { x: number; y: number }[];
  color: string;
  createdBy: string;
}

export function CollaborativeCanvas({ documentId, user }: { documentId: string; user: any }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const ydocRef = useRef<Y.Doc | null>(null);
  const shapesRef = useRef<Y.Array<Shape> | null>(null);
  const [tool, setTool] = useState<'select' | 'rect' | 'circle' | 'draw'>('draw');
  const [isDrawing, setIsDrawing] = useState(false);
  const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);

  useEffect(() => {
    const ydoc = new Y.Doc();
    const provider = new WebsocketProvider(
      process.env.NEXT_PUBLIC_YJS_URL || 'ws://localhost:1234',
      `canvas-${documentId}`,
      ydoc
    );

    provider.awareness.setLocalStateField('user', user);

    const shapes = ydoc.getArray<Shape>('shapes');
    ydocRef.current = ydoc;
    shapesRef.current = shapes;

    // مراقبة التغييرات وإعادة الرسم
    shapes.observe(() => {
      redrawCanvas();
    });

    // الرسم الأولي
    redrawCanvas();

    return () => {
      provider.destroy();
      ydoc.destroy();
    };
  }, [documentId, user]);

  const redrawCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    const shapes = shapesRef.current;
    if (!canvas || !shapes) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // مسح اللوحة
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // رسم جميع الأشكال
    shapes.forEach((shape) => {
      ctx.fillStyle = shape.color;
      ctx.strokeStyle = shape.color;
      ctx.lineWidth = 2;

      switch (shape.type) {
        case 'rect':
          ctx.fillRect(shape.x, shape.y, shape.width!, shape.height!);
          break;
        case 'circle':
          ctx.beginPath();
          ctx.arc(shape.x, shape.y, shape.radius!, 0, Math.PI * 2);
          ctx.fill();
          break;
        case 'path':
          if (shape.points && shape.points.length > 1) {
            ctx.beginPath();
            ctx.moveTo(shape.points[0].x, shape.points[0].y);
            shape.points.slice(1).forEach((point) => {
              ctx.lineTo(point.x, point.y);
            });
            ctx.stroke();
          }
          break;
      }
    });

    // رسم المسار الحالي الجاري إنشاؤه
    if (currentPath.length > 1) {
      ctx.strokeStyle = user.color;
      ctx.beginPath();
      ctx.moveTo(currentPath[0].x, currentPath[0].y);
      currentPath.slice(1).forEach((point) => {
        ctx.lineTo(point.x, point.y);
      });
      ctx.stroke();
    }
  }, [currentPath, user.color]);

  const handlePointerDown = (e: React.PointerEvent) => {
    const rect = canvasRef.current!.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    if (tool === 'draw') {
      setIsDrawing(true);
      setCurrentPath([{ x, y }]);
    } else if (tool === 'rect') {
      addShape({
        id: crypto.randomUUID(),
        type: 'rect',
        x,
        y,
        width: 100,
        height: 80,
        color: user.color,
        createdBy: user.id,
      });
    } else if (tool === 'circle') {
      addShape({
        id: crypto.randomUUID(),
        type: 'circle',
        x,
        y,
        radius: 50,
        color: user.color,
        createdBy: user.id,
      });
    }
  };

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!isDrawing || tool !== 'draw') return;

    const rect = canvasRef.current!.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    setCurrentPath((prev) => [...prev, { x, y }]);
    redrawCanvas();
  };

  const handlePointerUp = () => {
    if (isDrawing && currentPath.length > 1) {
      addShape({
        id: crypto.randomUUID(),
        type: 'path',
        x: currentPath[0].x,
        y: currentPath[0].y,
        points: currentPath,
        color: user.color,
        createdBy: user.id,
      });
    }
    setIsDrawing(false);
    setCurrentPath([]);
  };

  const addShape = (shape: Shape) => {
    if (shapesRef.current && ydocRef.current) {
      ydocRef.current.transact(() => {
        shapesRef.current!.push([shape]);
      });
    }
  };

  return (
    <div className="border rounded-lg overflow-hidden">
      <div className="flex gap-2 p-2 bg-gray-100 border-b">
        {(['select', 'draw', 'rect', 'circle'] as const).map((t) => (
          <button
            key={t}
            onClick={() => setTool(t)}
            className={`px-3 py-1 rounded ${tool === t ? 'bg-blue-500 text-white' : 'bg-white'}`}
          >
            {t}
          </button>
        ))}
      </div>
      <canvas
        ref={canvasRef}
        width={800}
        height={600}
        className="bg-white cursor-crosshair"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerLeave={handlePointerUp}
      />
    </div>
  );
}

استراتيجيات حل التعارضات

// lib/collab/conflict-resolution.ts
import * as Y from 'yjs';

// Yjs CRDTs تحل التعارضات تلقائياً، لكن يمكنك إضافة منطق الأعمال

export function setupConflictHandling(ydoc: Y.Doc) {
  const content = ydoc.getText('content');

  // تتبع التغييرات المحلية مقابل التغييرات البعيدة
  ydoc.on('update', (update: Uint8Array, origin: any) => {
    if (origin === 'local') {
      // تغيير محلي - حفظ في كومة التراجع
      console.log('تم تطبيق تغيير محلي');
    } else {
      // تغيير بعيد - ربما إظهار إشعار
      console.log('تم استلام تغيير بعيد');
    }
  });
}

// التراجع/الإعادة مع Yjs
export function createUndoManager(ydoc: Y.Doc) {
  const content = ydoc.getXmlFragment('content');
  const undoManager = new Y.UndoManager(content, {
    trackedOrigins: new Set(['local']),
    captureTimeout: 500, // تجميع التغييرات خلال 500 مللي ثانية
  });

  return {
    undo: () => undoManager.undo(),
    redo: () => undoManager.redo(),
    canUndo: () => undoManager.undoStack.length > 0,
    canRedo: () => undoManager.redoStack.length > 0,
  };
}

الحضور ووعي التحديد

// hooks/use-awareness.ts
import { useState, useEffect, useCallback } from 'react';
import type { Awareness } from 'y-protocols/awareness';

interface UserState {
  id: string;
  name: string;
  color: string;
  cursor?: { x: number; y: number };
  selection?: { start: number; end: number };
  isTyping?: boolean;
  lastActive: number;
}

export function useAwareness(awareness: Awareness | null, localUser: Omit<UserState, 'lastActive'>) {
  const [users, setUsers] = useState<Map<number, UserState>>(new Map());

  // تعيين حالة المستخدم المحلي
  useEffect(() => {
    if (!awareness) return;

    awareness.setLocalStateField('user', {
      ...localUser,
      lastActive: Date.now(),
    });

    // تحديث آخر نشاط دورياً
    const interval = setInterval(() => {
      awareness.setLocalStateField('user', {
        ...awareness.getLocalState()?.user,
        lastActive: Date.now(),
      });
    }, 30000);

    return () => clearInterval(interval);
  }, [awareness, localUser]);

  // تتبع جميع المستخدمين
  useEffect(() => {
    if (!awareness) return;

    const updateUsers = () => {
      const newUsers = new Map<number, UserState>();
      awareness.getStates().forEach((state, clientId) => {
        if (state.user) {
          newUsers.set(clientId, state.user);
        }
      });
      setUsers(newUsers);
    };

    awareness.on('change', updateUsers);
    updateUsers();

    return () => awareness.off('change', updateUsers);
  }, [awareness]);

  const setSelection = useCallback((selection: { start: number; end: number } | null) => {
    if (!awareness) return;
    awareness.setLocalStateField('user', {
      ...awareness.getLocalState()?.user,
      selection,
    });
  }, [awareness]);

  const setTyping = useCallback((isTyping: boolean) => {
    if (!awareness) return;
    awareness.setLocalStateField('user', {
      ...awareness.getLocalState()?.user,
      isTyping,
    });
  }, [awareness]);

  // الحصول على المستخدمين النشطين (نشطين في آخر دقيقتين)
  const activeUsers = Array.from(users.values()).filter(
    (user) => Date.now() - user.lastActive < 120000
  );

  // الحصول على المستخدمين مع التحديدات (للتمييز)
  const usersWithSelections = activeUsers.filter((user) => user.selection);

  return {
    users: activeUsers,
    usersWithSelections,
    setSelection,
    setTyping,
    localClientId: awareness?.clientID,
  };
}

مكون تمييز التحديد

// components/collab/selection-highlights.tsx
'use client';

import { useMemo } from 'react';

interface SelectionHighlight {
  userId: string;
  userName: string;
  userColor: string;
  start: number;
  end: number;
}

interface SelectionHighlightsProps {
  selections: SelectionHighlight[];
  getCharacterPosition: (index: number) => { top: number; left: number; height: number };
}

export function SelectionHighlights({ selections, getCharacterPosition }: SelectionHighlightsProps) {
  const highlights = useMemo(() => {
    return selections.map((selection) => {
      const startPos = getCharacterPosition(selection.start);
      const endPos = getCharacterPosition(selection.end);

      // تمييز سطر واحد بسيط
      return {
        ...selection,
        style: {
          position: 'absolute' as const,
          top: startPos.top,
          left: startPos.left,
          width: endPos.left - startPos.left,
          height: startPos.height,
          backgroundColor: selection.userColor + '40', // شفافية 25%
          borderLeft: `2px solid ${selection.userColor}`,
        },
      };
    });
  }, [selections, getCharacterPosition]);

  return (
    <div className="absolute inset-0 pointer-events-none">
      {highlights.map((highlight) => (
        <div key={`${highlight.userId}-selection`} style={highlight.style}>
          <span
            className="absolute -top-5 left-0 px-1 text-xs text-white rounded"
            style={{ backgroundColor: highlight.userColor }}
          >
            {highlight.userName}
          </span>
        </div>
      ))}
    </div>
  );
}

التخزين المستمر مع IndexedDB

// lib/collab/persistence.ts
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

export function setupPersistence(documentId: string, ydoc: Y.Doc) {
  // الحفظ في IndexedDB لدعم العمل دون اتصال
  const persistence = new IndexeddbPersistence(documentId, ydoc);

  persistence.on('synced', () => {
    console.log('تم تحميل المحتوى من IndexedDB');
  });

  return persistence;
}

// المزامنة مع قاعدة بيانات الخادم
export async function saveToServer(documentId: string, ydoc: Y.Doc) {
  const state = Y.encodeStateAsUpdate(ydoc);
  const base64State = Buffer.from(state).toString('base64');

  await fetch(`/api/documents/${documentId}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ state: base64State }),
  });
}

export async function loadFromServer(documentId: string, ydoc: Y.Doc) {
  const response = await fetch(`/api/documents/${documentId}`);
  if (!response.ok) return;

  const { state } = await response.json();
  if (state) {
    const update = Buffer.from(state, 'base64');
    Y.applyUpdate(ydoc, new Uint8Array(update));
  }
}

ما تعلمته

في هذا الدرس، بنيت:

  • تكامل Yjs - تعاون قائم على CRDT لتحرير خالٍ من التعارضات
  • مؤشرات حية - تتبع المؤشرات في الوقت الحقيقي عبر المستخدمين
  • لوحة تعاونية - سبورة بيضاء مع رسم متزامن
  • وعي الحضور - تمييز التحديد ومؤشرات الكتابة
  • حفظ دون اتصال - IndexedDB للتعاون بأولوية العمل دون اتصال

هذه الأنماط تشغل أدوات مثل Figma وNotion وGoogle Docs. الجمع بين CRDTs وWebSockets يعطيك الموثوقية والأداء في الوقت الحقيقي.

اختبار

الوحدة 5: بناء التطبيقات اللحظية

خذ الاختبار