UNPKG

sourabhrealtime

Version:

ROBUST RICH TEXT EDITOR: Single-pane contentEditable with direct text selection formatting, speech features, undo/redo, professional UI - Perfect TipTap alternative

370 lines (327 loc) 10.7 kB
import React, { useRef, useEffect, useState } from 'react'; import { useRealtimeCursor, UserRole } from './index'; export const RealtimeEditor = ({ apiUrl, projectId, userId, userName, userColor = '#3b82f6', userRole = UserRole.EDITOR, initialContent = '', onContentChange, onConnectionChange, onCollaboratorsChange, onInvitationsChange, onRoleChange, debug = false, height = 300, width = '100%', showCollaborators = true, showStatus = true, showAdminPanel = false, showInvitations = true }) => { const [content, setContent] = useState(initialContent); const [lastCursorPosition, setLastCursorPosition] = useState({ start: 0, end: 0 }); const editorRef = useRef(null); const textareaRef = useRef(null); const isLocalChange = useRef(false); const { cursors, collaborators, connected, typingStatus, invitations, userRole: currentRole, projectSettings, connect, disconnect, updateCursor, updateContent, updateTypingStatus, inviteUser, removeUser, changeUserRole, updateProjectSettings, acceptInvitation, rejectInvitation, cursorClient } = useRealtimeCursor({ apiUrl, projectId, user: { id: userId, name: userName, color: userColor, role: userRole }, debug }); // Connect immediately when component mounts useEffect(() => { if (debug) console.log('RealtimeEditor: Connecting to server...'); connect(); // Send heartbeat every 3 seconds to keep connection alive const heartbeatInterval = setInterval(() => { if (editorRef.current) { updateCursor({ heartbeat: true, timestamp: Date.now() }); } }, 3000); return () => { clearInterval(heartbeatInterval); disconnect(); }; }, []); // Call callbacks when state changes useEffect(() => { if (onConnectionChange) { onConnectionChange(connected); } }, [connected, onConnectionChange]); useEffect(() => { if (onCollaboratorsChange) { onCollaboratorsChange(collaborators); } }, [collaborators, onCollaboratorsChange]); useEffect(() => { if (onInvitationsChange) { onInvitationsChange(invitations); } }, [invitations, onInvitationsChange]); useEffect(() => { if (onRoleChange) { onRoleChange(currentRole); } }, [currentRole, onRoleChange]); // Listen for content updates from other users useEffect(() => { const handleContentUpdate = (data) => { if (debug) console.log('Content update received:', data); // Only update if this is not a local change and content is provided if (data.content !== undefined && !isLocalChange.current) { // Store current cursor position before updating content const currentPos = { start: textareaRef.current ? textareaRef.current.selectionStart : 0, end: textareaRef.current ? textareaRef.current.selectionEnd : 0 }; setContent(data.content); // If we have cursor position data and a textarea reference if (textareaRef.current) { try { // Restore cursor position after content update setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.setSelectionRange( currentPos.start, currentPos.end ); } }, 0); } catch (err) { if (debug) console.error('Error setting cursor position:', err); } } if (onContentChange) { onContentChange(data.content); } } }; if (cursorClient) { cursorClient.on('content-updated', handleContentUpdate); } return () => { if (cursorClient) { cursorClient.off('content-updated', handleContentUpdate); } }; }, [cursorClient, onContentChange, debug]); // Update cursor position on mouse move const handleMouseMove = (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, timestamp: Date.now() }); }; // Update content when changed const handleContentChange = (e) => { const newContent = e.target.value; // Mark this as a local change isLocalChange.current = true; setContent(newContent); // Save cursor position let cursorPos = null; if (textareaRef.current) { cursorPos = { start: textareaRef.current.selectionStart, end: textareaRef.current.selectionEnd }; setLastCursorPosition(cursorPos); } // Send content update with cursor position updateContent(newContent, cursorPos); updateTypingStatus(true); if (onContentChange) { onContentChange(newContent); } // Reset local change flag after a short delay setTimeout(() => { isLocalChange.current = false; }, 100); // Reset typing status after 2 seconds setTimeout(() => updateTypingStatus(false), 2000); // Force update cursor position to ensure it's synced if (editorRef.current) { const rect = editorRef.current.getBoundingClientRect(); updateCursor({ textPosition: cursorPos?.start, selectionEnd: cursorPos?.end, timestamp: Date.now() }); } }; // Handle cursor position changes const handleSelect = (e) => { if (!textareaRef.current) return; const selectionStart = textareaRef.current.selectionStart; const selectionEnd = textareaRef.current.selectionEnd; setLastCursorPosition({ start: selectionStart, end: selectionEnd }); if (!editorRef.current) return; updateCursor({ textPosition: selectionStart, selectionEnd: selectionEnd, timestamp: Date.now() }); }; // Handle click to update cursor position const handleClick = (e) => { handleSelect(e); }; // Handle keyup to update cursor position const handleKeyUp = (e) => { // Don't update on modifier keys if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') { return; } handleSelect(e); }; // Check if user has edit permission const canEdit = currentRole !== UserRole.VIEWER; return ( <div className="sourabhrealtime-editor"> {showStatus && ( <div className="sourabhrealtime-status"> <div className={`sourabhrealtime-status-indicator ${connected ? 'connected' : 'disconnected'}`}></div> <span>{connected ? 'Connected' : 'Disconnected'}</span> {connected && showCollaborators && ( <span className="sourabhrealtime-collaborators"> ({collaborators.length} {collaborators.length === 1 ? 'collaborator' : 'collaborators'}) </span> )} {currentRole && ( <span className="sourabhrealtime-role">Role: {currentRole}</span> )} </div> )} {showInvitations && invitations && invitations.length > 0 && ( <div className="sourabhrealtime-invitations"> <h3>Invitations</h3> <ul> {invitations.map(invitation => ( <li key={invitation.id}> <div>Project: {invitation.projectId}</div> <div>Role: {invitation.role}</div> <div>Invited by: {invitation.invitedBy}</div> <div className="sourabhrealtime-invitation-actions"> <button onClick={() => acceptInvitation(invitation.id)}> Accept </button> <button onClick={() => rejectInvitation(invitation.id)}> Reject </button> </div> </li> ))} </ul> </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} {user.role && <span className="sourabhrealtime-user-role"> ({user.role})</span>} {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={handleClick} onKeyUp={handleKeyUp} onSelect={handleSelect} placeholder="Start typing..." disabled={!canEdit} /> {!canEdit && ( <div className="sourabhrealtime-readonly-notice"> You are in view-only mode </div> )} {/* 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} {cursor.user.role && <span className="sourabhrealtime-cursor-role"> ({cursor.user.role})</span>} {typingStatus[cursor.id]?.isTyping && <span className="sourabhrealtime-typing"> (typing...)</span>} </div> </div> ))} </div> </div> ); }; export default RealtimeEditor;