sourabhrealtime
Version:
ROBUST RICH TEXT EDITOR: Single-pane contentEditable with direct text selection formatting, speech features, undo/redo, professional UI - Perfect TipTap alternative
264 lines (229 loc) • 7.56 kB
JSX
import React, { useRef, useEffect, useState, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { useRealtimeCursors } from './useRealtimeCursors';
export const CollaborativeEditor = ({
roomId,
userId,
userName,
userColor = '#3b82f6',
initialContent = '',
onContentChange,
wsUrl = 'ws://localhost:1234',
height = 300,
width = '100%',
showCollaborators = true,
showStatus = true,
debug = false
}) => {
// State
const [content, setContent] = useState(initialContent);
const [isConnected, setIsConnected] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isLocalChange, setIsLocalChange] = useState(false);
// Refs
const editorRef = useRef(null);
const textareaRef = useRef(null);
const ydocRef = useRef(null);
const providerRef = useRef(null);
const yTextRef = useRef(null);
// Get cursor tracking functionality
const {
cursors,
collaborators,
typingStatus,
updateCursor,
updateTypingStatus,
registerUser,
unregisterUser
} = useRealtimeCursors({
roomId,
userId,
userName,
userColor,
wsUrl,
debug
});
// Initialize Yjs document and provider
useEffect(() => {
if (debug) console.log('Initializing Yjs document and provider');
// Create Yjs document
const ydoc = new Y.Doc();
ydocRef.current = ydoc;
// Get shared text
const yText = ydoc.getText('content');
yTextRef.current = yText;
// If there's initial content and the yText is empty, set it
if (initialContent && yText.toString() === '') {
yText.insert(0, initialContent);
}
// Create WebSocket provider
const provider = new WebsocketProvider(
wsUrl,
roomId,
ydoc,
{ connect: true }
);
providerRef.current = provider;
// Set user info
provider.awareness.setLocalStateField('user', {
id: userId,
name: userName,
color: userColor
});
// Register user with cursor tracking
registerUser({
id: userId,
name: userName,
color: userColor
});
// Handle connection status
provider.on('status', ({ status }) => {
setIsConnected(status === 'connected');
if (status === 'connected') {
if (debug) console.log('Connected to collaboration server');
} else {
if (debug) console.log('Disconnected from collaboration server');
}
});
// Handle document updates
yText.observe(event => {
const newContent = yText.toString();
if (!isLocalChange) {
if (debug) console.log('Remote update received');
setContent(newContent);
if (onContentChange) {
onContentChange(newContent);
}
}
});
// Set ready state
setIsReady(true);
// Cleanup
return () => {
if (debug) console.log('Cleaning up Yjs document and provider');
unregisterUser(userId);
provider.disconnect();
ydoc.destroy();
};
}, [roomId, userId, userName, userColor, wsUrl, debug]);
// Update cursor position on mouse move
const handleMouseMove = useCallback((e) => {
if (!editorRef.current) return;
const rect = editorRef.current.getBoundingClientRect();
updateCursor({
x: e.clientX,
y: e.clientY,
relativeX: e.clientX - rect.left,
relativeY: e.clientY - rect.top,
textPosition: textareaRef.current ? textareaRef.current.selectionStart : null
});
}, [updateCursor]);
// Handle content changes
const handleContentChange = useCallback((e) => {
if (!isReady || !yTextRef.current) return;
const newContent = e.target.value;
setContent(newContent);
// Mark as local change to prevent loops
setIsLocalChange(true);
// Update Yjs document
yTextRef.current.delete(0, yTextRef.current.length);
yTextRef.current.insert(0, newContent);
// Reset local change flag after a short delay
setTimeout(() => {
setIsLocalChange(false);
}, 10);
// Update typing status
updateTypingStatus(true);
// Call onContentChange callback
if (onContentChange) {
onContentChange(newContent);
}
// Reset typing status after 2 seconds
setTimeout(() => updateTypingStatus(false), 2000);
}, [isReady, onContentChange, updateTypingStatus]);
// Handle cursor position changes
const handleSelect = useCallback((e) => {
if (!textareaRef.current) return;
const selectionStart = textareaRef.current.selectionStart;
const selectionEnd = textareaRef.current.selectionEnd;
if (!editorRef.current) return;
updateCursor({
textPosition: selectionStart,
selectionEnd: selectionEnd
});
}, [updateCursor]);
return (
<div className="sourabhrealtime-editor">
{showStatus && (
<div className="sourabhrealtime-status">
<div className={`sourabhrealtime-status-indicator ${isConnected ? 'connected' : 'disconnected'}`}></div>
<span>{isConnected ? 'Connected' : 'Disconnected'}</span>
{isConnected && showCollaborators && (
<span className="sourabhrealtime-collaborators">
({collaborators.length} {collaborators.length === 1 ? 'collaborator' : 'collaborators'})
</span>
)}
</div>
)}
{showCollaborators && collaborators.length > 0 && (
<div className="sourabhrealtime-collaborators-list">
{collaborators.map(user => (
<div
key={user.id}
className="sourabhrealtime-collaborator"
style={{ borderLeftColor: user.color || '#3b82f6' }}
>
{user.name}
{typingStatus[user.id]?.isTyping && (
<span className="sourabhrealtime-typing"> (typing...)</span>
)}
</div>
))}
</div>
)}
<div
className="sourabhrealtime-editor-container"
ref={editorRef}
onMouseMove={handleMouseMove}
style={{ height: typeof height === 'number' ? `${height}px` : height, width }}
>
<textarea
ref={textareaRef}
className="sourabhrealtime-textarea"
value={content}
onChange={handleContentChange}
onClick={handleSelect}
onKeyUp={handleSelect}
onSelect={handleSelect}
placeholder="Start typing..."
disabled={!isReady}
/>
{/* Render cursors */}
{Object.values(cursors).map(cursor => (
<div
key={cursor.id}
className="sourabhrealtime-cursor"
style={{
left: cursor.position.x || cursor.position.relativeX || 0,
top: cursor.position.y || cursor.position.relativeY || 0
}}
>
<div
className="sourabhrealtime-cursor-pointer"
style={{ borderBottomColor: cursor.user.color || '#3b82f6' }}
></div>
<div
className="sourabhrealtime-cursor-label"
style={{ backgroundColor: cursor.user.color || '#3b82f6' }}
>
{cursor.user.name}
{typingStatus[cursor.id]?.isTyping && <span className="sourabhrealtime-typing"> (typing...)</span>}
</div>
</div>
))}
</div>
</div>
);
};
export default CollaborativeEditor;