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