WebSockets والتطبيقات الفورية
التحرير التعاوني في الوقت الفعلي
التحرير التعاوني في الوقت الفعلي
يسمح التحرير التعاوني لمستخدمين متعددين بتحرير نفس المستند في نفس الوقت، مع رؤية تغييرات بعضهم البعض في الوقت الفعلي. تعمل هذه التقنية على تشغيل تطبيقات مثل Google Docs و Figma و Notion.
مفاهيم التحرير التعاوني
التحديات الرئيسية في التعاون في الوقت الفعلي:
- حل التعارضات: ماذا يحدث عندما يقوم مستخدمان بتحرير نفس النص في نفس الوقت؟
- الاتساق: ضمان رؤية جميع المستخدمين لنفس الحالة النهائية
- الكمون: التعامل مع تأخيرات الشبكة بشكل سلس
- الدعم غير المتصل: السماح بالتعديلات عند انقطاع الاتصال
- الحضور: إظهار من يقوم بالتحرير
محرر نصوص تعاوني أساسي
لنبني محررًا تعاونيًا بسيطًا:
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<title>محرر تعاوني</title>
<style>
.editor-container {
max-width: 800px;
margin: 20px auto;
}
#editor {
width: 100%;
min-height: 400px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
}
.user-cursors {
position: relative;
}
.cursor {
position: absolute;
width: 2px;
height: 20px;
background: #2563eb;
pointer-events: none;
}
.cursor-label {
position: absolute;
top: -20px;
left: 0;
background: #2563eb;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
white-space: nowrap;
}
.online-users {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.user-badge {
padding: 5px 12px;
background: #f3f4f6;
border-radius: 20px;
font-size: 13px;
}
</style>
</head>
<body>
<div class="editor-container">
<div class="online-users" id="onlineUsers"></div>
<div class="user-cursors">
<textarea id="editor" placeholder="ابدأ الكتابة..."></textarea>
<div id="cursors"></div>
</div>
</div>
<script>
const ws = new WebSocket('ws://localhost:8080');
const editor = document.getElementById('editor');
const userId = generateUserId();
const userName = prompt('أدخل اسمك:') || 'مجهول';
let isRemoteChange = false;
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'join',
userId,
userName
}));
};
editor.addEventListener('input', (e) => {
if (isRemoteChange) return;
const content = editor.value;
const cursorPosition = editor.selectionStart;
ws.send(JSON.stringify({
type: 'edit',
userId,
content,
cursorPosition
}));
});
editor.addEventListener('selectionchange', () => {
ws.send(JSON.stringify({
type: 'cursor',
userId,
position: editor.selectionStart
}));
});
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'edit':
if (data.userId !== userId) {
isRemoteChange = true;
const cursorPos = editor.selectionStart;
editor.value = data.content;
editor.setSelectionRange(cursorPos, cursorPos);
isRemoteChange = false;
}
break;
case 'cursor':
if (data.userId !== userId) {
updateRemoteCursor(data.userId, data.userName, data.position);
}
break;
case 'users':
updateOnlineUsers(data.users);
break;
}
};
function generateUserId() {
return 'user_' + Math.random().toString(36).substr(2, 9);
}
function updateRemoteCursor(userId, userName, position) {
// التنفيذ لإظهار المؤشرات البعيدة
}
function updateOnlineUsers(users) {
const container = document.getElementById('onlineUsers');
container.innerHTML = users.map(user =>
`<div class="user-badge">${user.name}</div>`
).join('');
}
</script>
</body>
</html>
ملاحظة: هذا التنفيذ الأساسي لا يتعامل مع التعديلات المتزامنة بشكل صحيح. للاستخدام الإنتاجي، تحتاج إلى Operational Transformation أو CRDTs.
أساسيات Operational Transformation (OT)
OT هي خوارزمية تحافظ على الاتساق عبر التعديلات المتزامنة:
class OperationalTransform {
// أنواع العمليات
static INSERT = 'insert';
static DELETE = 'delete';
static RETAIN = 'retain';
// تحويل عمليتين ضد بعضهما البعض
static transform(op1, op2) {
if (op1.type === this.INSERT && op2.type === this.INSERT) {
if (op1.position < op2.position) {
return [op1, { ...op2, position: op2.position + op1.text.length }];
} else {
return [{ ...op1, position: op1.position + op2.text.length }, op2];
}
}
if (op1.type === this.INSERT && op2.type === this.DELETE) {
if (op1.position <= op2.position) {
return [op1, { ...op2, position: op2.position + op1.text.length }];
} else if (op1.position > op2.position + op2.length) {
return [{ ...op1, position: op1.position - op2.length }, op2];
} else {
return [{ ...op1, position: op2.position }, op2];
}
}
if (op1.type === this.DELETE && op2.type === this.INSERT) {
if (op2.position <= op1.position) {
return [{ ...op1, position: op1.position + op2.text.length }, op2];
} else if (op2.position > op1.position + op1.length) {
return [op1, { ...op2, position: op2.position - op1.length }];
} else {
return [op1, { ...op2, position: op1.position }];
}
}
if (op1.type === this.DELETE && op2.type === this.DELETE) {
// التعامل مع الحذف المتداخل
return this.transformDeletes(op1, op2);
}
return [op1, op2];
}
static transformDeletes(op1, op2) {
const op1End = op1.position + op1.length;
const op2End = op2.position + op2.length;
if (op1End <= op2.position) {
return [op1, { ...op2, position: op2.position - op1.length }];
} else if (op2End <= op1.position) {
return [{ ...op1, position: op1.position - op2.length }, op2];
} else {
// حذف متداخل - يحتاج إلى حل معقد
const overlapStart = Math.max(op1.position, op2.position);
const overlapEnd = Math.min(op1End, op2End);
const overlapLength = overlapEnd - overlapStart;
return [
{ ...op1, length: op1.length - overlapLength },
{ ...op2, length: op2.length - overlapLength }
];
}
}
// تطبيق العملية على النص
static apply(text, operation) {
switch (operation.type) {
case this.INSERT:
return text.slice(0, operation.position) +
operation.text +
text.slice(operation.position);
case this.DELETE:
return text.slice(0, operation.position) +
text.slice(operation.position + operation.length);
default:
return text;
}
}
}
// مثال على الاستخدام
const text = 'مرحبا بالعالم';
const op1 = { type: 'insert', position: 5, text: ' الجميل' };
const op2 = { type: 'delete', position: 6, length: 5 };
const [transformedOp1, transformedOp2] = OperationalTransform.transform(op1, op2);
console.log(transformedOp1, transformedOp2);
أنواع البيانات المنسوخة الخالية من التعارض (CRDTs)
CRDTs هي هياكل بيانات تحل التعارضات تلقائيًا:
class CRDT_Text {
constructor(siteId) {
this.siteId = siteId;
this.characters = [];
this.clock = 0;
}
// إدراج حرف في الموضع
insert(position, char) {
this.clock++;
const id = {
position: this.generatePositionBetween(
this.characters[position - 1],
this.characters[position]
),
siteId: this.siteId,
clock: this.clock
};
const character = { id, value: char };
this.characters.splice(position, 0, character);
return { type: 'insert', character };
}
// حذف حرف في الموضع
delete(position) {
const character = this.characters[position];
this.characters.splice(position, 1);
return { type: 'delete', id: character.id };
}
// تطبيق إدراج عن بعد
remoteInsert(character) {
const index = this.findInsertIndex(character.id);
this.characters.splice(index, 0, character);
}
// تطبيق حذف عن بعد
remoteDelete(id) {
const index = this.characters.findIndex(c => this.compareIds(c.id, id) === 0);
if (index !== -1) {
this.characters.splice(index, 1);
}
}
// توليد موضع بين حرفين
generatePositionBetween(before, after) {
const beforePos = before ? before.id.position : [0];
const afterPos = after ? after.id.position : [this.clock + 1];
// تنفيذ بسيط - الإنتاج سيكون أكثر تطوراً
return [(beforePos[0] + afterPos[0]) / 2];
}
// إيجاد مكان الإدراج بناءً على الموضع
findInsertIndex(id) {
let left = 0;
let right = this.characters.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
const comparison = this.compareIds(this.characters[mid].id, id);
if (comparison < 0) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
// مقارنة معرفين للترتيب
compareIds(id1, id2) {
for (let i = 0; i < Math.min(id1.position.length, id2.position.length); i++) {
if (id1.position[i] !== id2.position[i]) {
return id1.position[i] - id2.position[i];
}
}
if (id1.position.length !== id2.position.length) {
return id1.position.length - id2.position.length;
}
return id1.siteId.localeCompare(id2.siteId);
}
// الحصول على محتوى النص
toString() {
return this.characters.map(c => c.value).join('');
}
}
// الاستخدام
const doc1 = new CRDT_Text('site1');
const doc2 = new CRDT_Text('site2');
const op1 = doc1.insert(0, 'م');
const op2 = doc1.insert(1, 'ر');
// تطبيق العمليات على النسخة المكررة
doc2.remoteInsert(op1.character);
doc2.remoteInsert(op2.character);
console.log(doc1.toString()); // "مر"
console.log(doc2.toString()); // "مر"
أفضل ممارسة: CRDTs أبسط في التنفيذ من OT وتحل التعارضات تلقائيًا. استخدم CRDTs للمشاريع الجديدة ما لم يكن لديك متطلبات OT محددة.
مواضع المؤشر والتحديد
اعرض أين يقوم المستخدمون الآخرون بالتحرير:
class CursorManager {
constructor(editorElement) {
this.editor = editorElement;
this.cursors = new Map();
this.container = document.getElementById('cursors');
}
updateCursor(userId, userName, position, color = '#2563eb') {
let cursor = this.cursors.get(userId);
if (!cursor) {
cursor = this.createCursor(userId, userName, color);
this.cursors.set(userId, cursor);
}
this.positionCursor(cursor, position);
}
createCursor(userId, userName, color) {
const cursor = document.createElement('div');
cursor.className = 'cursor';
cursor.style.background = color;
cursor.dataset.userId = userId;
const label = document.createElement('div');
label.className = 'cursor-label';
label.style.background = color;
label.textContent = userName;
cursor.appendChild(label);
this.container.appendChild(cursor);
return cursor;
}
positionCursor(cursor, position) {
// حساب موضع البكسل من موضع الحرف
const coords = this.getCoordinatesForPosition(position);
cursor.style.left = coords.x + 'px';
cursor.style.top = coords.y + 'px';
}
getCoordinatesForPosition(position) {
// إنشاء نطاق مؤقت في الموضع للقياس
const text = this.editor.value;
const before = text.substring(0, position);
// حساب الأسطر والأعمدة
const lines = before.split('\n');
const line = lines.length - 1;
const column = lines[lines.length - 1].length;
// حساب الإحداثيات بالبكسل
const lineHeight = 20; // ارتفاع السطر التقريبي
const charWidth = 8.5; // عرض الحرف التقريبي (أحادي المسافات)
return {
x: column * charWidth + 15, // حشوة 15 بكسل
y: line * lineHeight + 15
};
}
removeCursor(userId) {
const cursor = this.cursors.get(userId);
if (cursor) {
cursor.remove();
this.cursors.delete(userId);
}
}
hideCursor(userId) {
const cursor = this.cursors.get(userId);
if (cursor) {
cursor.style.display = 'none';
}
}
showCursor(userId) {
const cursor = this.cursors.get(userId);
if (cursor) {
cursor.style.display = 'block';
}
}
}
// الاستخدام
const cursorManager = new CursorManager(document.getElementById('editor'));
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'cursor') {
cursorManager.updateCursor(
data.userId,
data.userName,
data.position,
data.color
);
}
};
حالة المستند المشترك
إدارة حالة المستند من جانب الخادم:
class CollaborativeDocument {
constructor(docId) {
this.docId = docId;
this.content = '';
this.version = 0;
this.operations = [];
this.users = new Map();
}
addUser(userId, userName, ws) {
this.users.set(userId, {
id: userId,
name: userName,
ws: ws,
cursor: 0,
color: this.generateColor()
});
// إرسال حالة المستند الحالية إلى المستخدم الجديد
ws.send(JSON.stringify({
type: 'init',
content: this.content,
version: this.version,
users: Array.from(this.users.values()).map(u => ({
id: u.id,
name: u.name,
color: u.color
}))
}));
// إخطار الآخرين بالمستخدم الجديد
this.broadcast({
type: 'user-join',
userId,
userName,
color: this.users.get(userId).color
}, userId);
}
removeUser(userId) {
this.users.delete(userId);
this.broadcast({
type: 'user-leave',
userId
});
}
applyOperation(userId, operation) {
// تطبيق العملية على المستند
this.content = this.executeOperation(this.content, operation);
this.version++;
this.operations.push({
userId,
operation,
version: this.version,
timestamp: Date.now()
});
// بث إلى جميع المستخدمين باستثناء المرسل
this.broadcast({
type: 'operation',
userId,
operation,
version: this.version
}, userId);
}
executeOperation(content, operation) {
switch (operation.type) {
case 'insert':
return content.slice(0, operation.position) +
operation.text +
content.slice(operation.position);
case 'delete':
return content.slice(0, operation.position) +
content.slice(operation.position + operation.length);
default:
return content;
}
}
updateCursor(userId, position) {
const user = this.users.get(userId);
if (user) {
user.cursor = position;
this.broadcast({
type: 'cursor',
userId,
userName: user.name,
position,
color: user.color
}, userId);
}
}
broadcast(message, excludeUserId = null) {
this.users.forEach((user, userId) => {
if (userId !== excludeUserId && user.ws.readyState === WebSocket.OPEN) {
user.ws.send(JSON.stringify(message));
}
});
}
generateColor() {
const colors = [
'#2563eb', '#dc2626', '#16a34a', '#ca8a04',
'#9333ea', '#ea580c', '#0891b2', '#be123c'
];
return colors[this.users.size % colors.length];
}
}
// تنفيذ الخادم
const documents = new Map();
wss.on('connection', (ws) => {
let currentDoc = null;
let userId = null;
ws.on('message', (message) => {
const data = JSON.parse(message);
switch (data.type) {
case 'join':
userId = data.userId;
const docId = data.docId || 'default';
if (!documents.has(docId)) {
documents.set(docId, new CollaborativeDocument(docId));
}
currentDoc = documents.get(docId);
currentDoc.addUser(userId, data.userName, ws);
break;
case 'operation':
if (currentDoc) {
currentDoc.applyOperation(userId, data.operation);
}
break;
case 'cursor':
if (currentDoc) {
currentDoc.updateCursor(userId, data.position);
}
break;
}
});
ws.on('close', () => {
if (currentDoc && userId) {
currentDoc.removeUser(userId);
}
});
});
تحذير: لا يحتفظ هذا التنفيذ بالمستندات على القرص أو قاعدة البيانات. أضف الاستمرارية للاستخدام الإنتاجي.
التمرين: قم ببناء تطبيق ملاحظات تعاوني
قم بإنشاء تطبيق ملاحظات متعدد المستخدمين:
- قم بتنفيذ محرر نصوص منسقة تعاوني باستخدام ContentEditable
- اعرض مؤشرات ملونة لكل مستخدم مع أسمائهم
- اعرض قائمة المستخدمين المتصلين مع مؤشرات الحضور
- قم بتنفيذ OT أساسي أو CRDT لحل التعارض
- أضف تمييز التحديد (اعرض ما حدده المستخدمون الآخرون)
- احفظ سجل المستند في قاعدة البيانات
- إضافي: أضف وظيفة التعليق على النص المحدد
الملخص
- يتطلب التحرير التعاوني خوارزميات حل التعارض (OT أو CRDTs)
- يحول Operational Transformation العمليات المتزامنة للحفاظ على الاتساق
- CRDTs تحل التعارضات تلقائيًا وأسهل في التنفيذ
- اعرض مواضع المؤشر والتحديدات لتحسين وعي التعاون
- حافظ على حالة المستند المشترك على الخادم
- تعامل مع حضور المستخدم (انضمام، مغادرة، حالة متصل)
- ضع في اعتبارك الدعم غير المتصل وطابور العمليات