tahamajs/IE / Projects /front /src /components /CollaborativeEditor.tsx
tahamajs's picture
download
raw
9.73 kB
// src/components/CollaborativeEditor.tsx
import React, { useState, useEffect, useRef } from 'react';
import { useStompClient } from '../hooks/useStompClient';
import { useAuth } from '../context/AuthContext';
interface CollaborativeEditorProps {
documentId: string;
}
interface TextOperation {
type: 'insert' | 'delete';
position: number;
characters?: string;
length?: number;
userId?: string;
version?: number;
}
export const CollaborativeEditor: React.FC<CollaborativeEditorProps> = ({ documentId }) => {
const { user, accessToken } = useAuth();
const { isConnected, sendMessage, subscribeToTopic } = useStompClient();
const [content, setContent] = useState<string>('');
const [title, setTitle] = useState<string>('');
const [editors, setEditors] = useState<string[]>([]);
const [version, setVersion] = useState<number>(0);
const [pendingOps, setPendingOps] = useState<TextOperation[]>([]);
const [typingUsers, setTypingUsers] = useState<Map<string, number>>(new Map());
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const selectionRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 });
const applyOperation = (text: string, op: TextOperation): string => {
if (op.type === 'insert' && op.characters) {
return text.slice(0, op.position) + op.characters + text.slice(op.position);
} else if (op.type === 'delete' && op.length) {
return text.slice(0, op.position) + text.slice(op.position + op.length);
}
return text;
};
const adjustCursorPosition = (cursorPos: number, op: TextOperation): number => {
if (op.type === 'insert' && op.characters) {
if (cursorPos >= op.position) {
return cursorPos + op.characters.length;
}
} else if (op.type === 'delete' && op.length) {
if (cursorPos >= op.position + op.length) {
return cursorPos - op.length;
} else if (cursorPos > op.position) {
return op.position;
}
}
return cursorPos;
};
const setCursorPosition = (position: number): void => {
const editor = editorRef.current;
if (editor) {
editor.focus();
editor.setSelectionRange(position, position);
selectionRef.current = { start: position, end: position };
}
};
const applyRemoteOperation = (operation: TextOperation): void => {
setContent(prev => applyOperation(prev, operation));
setVersion(prev => prev + 1);
if (editorRef.current) {
const newCursorPos = adjustCursorPosition(selectionRef.current.start, operation);
if (newCursorPos !== selectionRef.current.start) {
setCursorPosition(newCursorPos);
}
}
};
useEffect(() => {
if (!isConnected || !documentId) return;
fetch('/api/documents/' + documentId, {
headers: { Authorization: 'Bearer ' + accessToken },
})
.then(res => res.json())
.then(data => {
setContent(data.content || '');
setTitle(data.title || 'Untitled');
setVersion(data.version || 0);
setEditors(data.editors || []);
})
.catch(err => console.error(err));
sendMessage('/app/document.' + documentId + '.join', {});
const opSubscription = subscribeToTopic('/topic/document/' + documentId + '/operations', (message: any) => {
const data = JSON.parse(message.body);
if (data.userId !== user?.username) {
applyRemoteOperation(data.operation);
}
});
const editorSubscription = subscribeToTopic('/topic/document/' + documentId + '/editors', (message: any) => {
const data = JSON.parse(message.body);
setEditors(data.editors);
});
const titleSubscription = subscribeToTopic('/topic/document/' + documentId + '/title', (message: any) => {
const data = JSON.parse(message.body);
setTitle(data.title);
});
const typingSubscription = subscribeToTopic('/topic/document/' + documentId + '/typing', (message: any) => {
const data = JSON.parse(message.body);
setTypingUsers(prev => {
const newMap = new Map(prev);
if (data.isTyping) {
newMap.set(data.userId, Date.now());
} else {
newMap.delete(data.userId);
}
return newMap;
});
});
return () => {
sendMessage('/app/document.' + documentId + '.leave', {});
opSubscription?.unsubscribe();
editorSubscription?.unsubscribe();
titleSubscription?.unsubscribe();
typingSubscription?.unsubscribe();
};
}, [isConnected, documentId, user?.username, accessToken]);
const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement> & { data?: string }) => {
e.preventDefault();
const target = e.currentTarget;
const start = target.selectionStart;
const data = e.data;
if (data) {
const operation: TextOperation = {
type: 'insert',
position: start,
characters: data,
userId: user?.username,
version: version + 1,
};
setContent(prev => applyOperation(prev, operation));
setVersion(prev => prev + 1);
setPendingOps(prev => [...prev, operation]);
sendMessage('/app/document.' + documentId + '.operation', operation);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
const target = e.currentTarget;
const start = target.selectionStart;
const end = target.selectionEnd;
if (start !== end) {
e.preventDefault();
const operation: TextOperation = {
type: 'delete',
position: start,
length: end - start,
userId: user?.username,
version: version + 1,
};
setContent(prev => applyOperation(prev, operation));
setVersion(prev => prev + 1);
setPendingOps(prev => [...prev, operation]);
sendMessage('/app/document.' + documentId + '.operation', operation);
setCursorPosition(start);
} else if (e.key === 'Backspace' && start > 0) {
e.preventDefault();
const operation: TextOperation = {
type: 'delete',
position: start - 1,
length: 1,
userId: user?.username,
version: version + 1,
};
setContent(prev => applyOperation(prev, operation));
setVersion(prev => prev + 1);
setPendingOps(prev => [...prev, operation]);
sendMessage('/app/document.' + documentId + '.operation', operation);
setCursorPosition(start - 1);
}
}
};
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
selectionRef.current = {
start: target.selectionStart,
end: target.selectionEnd,
};
};
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle);
sendMessage('/app/document.' + documentId + '.title', { title: newTitle });
};
const handleTyping = () => {
sendMessage('/app/document.' + documentId + '.typing', { isTyping: true });
setTimeout(() => {
sendMessage('/app/document.' + documentId + '.typing', { isTyping: false });
}, 2000);
};
const handleSave = async () => {
try {
const response = await fetch('/api/documents/' + documentId, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessToken,
},
body: JSON.stringify({ content, title, version }),
});
if (response.ok) {
setPendingOps([]);
}
} catch (error) {
console.error('Failed to save document:', error);
}
};
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setTypingUsers(prev => {
const newMap = new Map(prev);
for (const [userId, timestamp] of newMap.entries()) {
if (now - timestamp > 3000) {
newMap.delete(userId);
}
}
return newMap;
});
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="collaborative-editor">
<div className="editor-header">
<input
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
className="editor-title"
/>
<div className="editor-toolbar">
<button onClick={handleSave}>Save</button>
</div>
<div className="editor-status">
<span>Connected: {isConnected ? '✓' : '✗'}</span>
<span>Version: {version}</span>
<div className="active-editors">
{editors.map(ed => (
<span key={ed} className="editor-badge">{ed}</span>
))}
</div>
<div className="typing-indicators">
{Array.from(typingUsers.keys()).map(uId => (
<span key={uId} className="typing-badge">{uId} is typing...</span>
))}
</div>
</div>
</div>
<textarea
ref={editorRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onBeforeInput={handleBeforeInput}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
onKeyUp={handleTyping}
className="editor-content"
placeholder="Start writing..."
/>
<div className="editor-footer">
<span>Characters: {content.length}</span>
<span>Words: {content.split(/\s+/).filter(w => w.length > 0).length}</span>
{pendingOps.length > 0 && (
<span className="sync-status">Syncing... ({pendingOps.length} pending)</span>
)}
</div>
</div>
);
};
export default CollaborativeEditor;

Xet Storage Details

Size:
9.73 kB
·
Xet hash:
c7ebf9dc5e134cb09784f862c532865a39602d5943125af083dbd7c4e67b27c0

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.