UNPKG

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
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;