UNPKG

sourabhrealtime

Version:

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

72 lines (62 loc) 15.3 kB
import React, { useRef, useEffect, useState } from 'react'; import { io } from 'socket.io-client'; export const UserRole = { SUPER_ADMIN: 'super_admin', ADMIN: 'admin', EDITOR: 'editor', VIEWER: 'viewer' }; export const EnhancedRealtimeEditor = ({ apiUrl = 'http://localhost:3002', projectId, user, onContentChange, onCollaboratorsChange, onConnectionChange, height = 400, width = '100%', showToolbar = true, showCollaborators = true, showMouseCursors = true, className = '', placeholder = 'Start typing to collaborate...' }) => { const [content, setContent] = useState(''); const [connected, setConnected] = useState(false); const [collaborators, setCollaborators] = useState([]); const [mouseCursors, setMouseCursors] = useState({}); const [typingUsers, setTypingUsers] = useState({}); const [isTyping, setIsTyping] = useState(false); const socketRef = useRef(null); const editorRef = useRef(null); const textareaRef = useRef(null); const isLocalChange = useRef(false); const typingTimeoutRef = useRef(null); // Initialize socket connection useEffect(() => { if (!user || !projectId) return; socketRef.current = io(apiUrl, { transports: ['websocket', 'polling'], reconnection: true }); const socket = socketRef.current; // Connection events socket.on('connect', () => { setConnected(true); onConnectionChange?.(true); // Join project socket.emit('join-project', { projectId, user: { ...user, color: user.color || `#${Math.floor(Math.random()*16777215).toString(16)}` } }); }); socket.on('disconnect', () => { setConnected(false); onConnectionChange?.(false); }); // Collaboration events socket.on('room-users', (data) => { const otherUsers = data.users.filter(u => u.id !== user.id);\n setCollaborators(otherUsers);\n onCollaboratorsChange?.(otherUsers);\n });\n \n socket.on('user-joined', (data) => {\n if (data.user.id !== user.id) {\n setCollaborators(prev => {\n const filtered = prev.filter(u => u.id !== data.user.id);\n const updated = [...filtered, data.user];\n onCollaboratorsChange?.(updated);\n return updated;\n });\n }\n });\n \n socket.on('user-left', (data) => {\n setCollaborators(prev => {\n const updated = prev.filter(u => u.id !== data.userId);\n onCollaboratorsChange?.(updated);\n return updated;\n });\n });\n \n socket.on('content-update', (data) => {\n if (!isLocalChange.current && data.content !== undefined) {\n setContent(data.content);\n onContentChange?.(data.content);\n }\n });\n \n socket.on('content-state', (data) => {\n if (data.content) {\n setContent(data.content);\n onContentChange?.(data.content);\n }\n });\n \n socket.on('mouse-cursor', (data) => {\n if (showMouseCursors) {\n setMouseCursors(prev => ({\n ...prev,\n [data.socketId]: data\n }));\n \n // Remove cursor after 3 seconds\n setTimeout(() => {\n setMouseCursors(prev => {\n const newCursors = { ...prev };\n delete newCursors[data.socketId];\n return newCursors;\n });\n }, 3000);\n }\n });\n \n socket.on('user-typing', (data) => {\n setTypingUsers(prev => ({\n ...prev,\n [data.user.id]: data.isTyping\n }));\n \n if (data.isTyping) {\n setTimeout(() => {\n setTypingUsers(prev => ({\n ...prev,\n [data.user.id]: false\n }));\n }, 3000);\n }\n });\n \n return () => {\n socket.disconnect();\n };\n }, [user, projectId, apiUrl]);\n \n // Mouse tracking\n useEffect(() => {\n if (!socketRef.current || !connected || !showMouseCursors) return;\n \n const handleMouseMove = (e) => {\n if (editorRef.current?.contains(e.target)) {\n const rect = editorRef.current.getBoundingClientRect();\n socketRef.current.emit('mouse-move', {\n x: e.clientX - rect.left,\n y: e.clientY - rect.top,\n elementId: 'editor'\n });\n }\n };\n \n document.addEventListener('mousemove', handleMouseMove);\n return () => document.removeEventListener('mousemove', handleMouseMove);\n }, [connected, showMouseCursors]);\n \n const handleContentChange = (e) => {\n const newContent = e.target.value;\n \n isLocalChange.current = true;\n setContent(newContent);\n onContentChange?.(newContent);\n \n if (socketRef.current && connected) {\n const cursorPos = {\n start: e.target.selectionStart,\n end: e.target.selectionEnd\n };\n \n socketRef.current.emit('content-update', {\n projectId,\n content: newContent,\n version: Date.now(),\n cursorPosition: cursorPos\n });\n \n // Handle typing indicator\n if (!isTyping) {\n setIsTyping(true);\n socketRef.current.emit('user-typing', { isTyping: true });\n }\n \n // Clear existing timeout\n if (typingTimeoutRef.current) {\n clearTimeout(typingTimeoutRef.current);\n }\n \n // Set new timeout\n typingTimeoutRef.current = setTimeout(() => {\n setIsTyping(false);\n socketRef.current.emit('user-typing', { isTyping: false });\n }, 2000);\n }\n \n setTimeout(() => {\n isLocalChange.current = false;\n }, 100);\n };\n \n const handleToolbarAction = (action) => {\n if (!textareaRef.current) return;\n \n const textarea = textareaRef.current;\n const start = textarea.selectionStart;\n const end = textarea.selectionEnd;\n const selectedText = content.substring(start, end);\n \n let newContent = content;\n let newCursorPos = end;\n \n switch (action) {\n case 'bold':\n newContent = content.substring(0, start) + `**${selectedText}**` + content.substring(end);\n newCursorPos = end + 4;\n break;\n case 'italic':\n newContent = content.substring(0, start) + `*${selectedText}*` + content.substring(end);\n newCursorPos = end + 2;\n break;\n case 'list':\n const lines = selectedText.split('\\n');\n const listText = lines.map(line => `- ${line}`).join('\\n');\n newContent = content.substring(0, start) + listText + content.substring(end);\n newCursorPos = start + listText.length;\n break;\n case 'link':\n const linkText = selectedText || 'Link Text';\n const linkMarkdown = `[${linkText}](https://example.com)`;\n newContent = content.substring(0, start) + linkMarkdown + content.substring(end);\n newCursorPos = start + linkMarkdown.length;\n break;\n }\n \n setContent(newContent);\n onContentChange?.(newContent);\n \n if (socketRef.current && connected) {\n socketRef.current.emit('content-update', {\n projectId,\n content: newContent,\n version: Date.now()\n });\n }\n \n // Restore cursor position\n setTimeout(() => {\n textarea.focus();\n textarea.setSelectionRange(newCursorPos, newCursorPos);\n }, 0);\n };\n \n const canEdit = user?.role !== UserRole.VIEWER;\n \n return (\n <div className={`enhanced-realtime-editor ${className}`} style={{ width }}>\n {/* Connection Status */}\n <div className=\"editor-status\">\n <div className={`status-indicator ${connected ? 'connected' : 'disconnected'}`}>\n <div className=\"status-dot\"></div>\n <span>{connected ? 'Connected' : 'Disconnected'}</span>\n </div>\n \n {showCollaborators && collaborators.length > 0 && (\n <div className=\"collaborators-info\">\n <span>{collaborators.length} collaborator{collaborators.length !== 1 ? 's' : ''}</span>\n </div>\n )}\n </div>\n \n {/* Toolbar */}\n {showToolbar && canEdit && (\n <div className=\"editor-toolbar\">\n <button \n className=\"toolbar-btn\" \n onClick={() => handleToolbarAction('bold')}\n title=\"Bold\"\n >\n <strong>B</strong>\n </button>\n <button \n className=\"toolbar-btn\" \n onClick={() => handleToolbarAction('italic')}\n title=\"Italic\"\n >\n <em>I</em>\n </button>\n <button \n className=\"toolbar-btn\" \n onClick={() => handleToolbarAction('list')}\n title=\"List\"\n >\n\n </button>\n <button \n className=\"toolbar-btn\" \n onClick={() => handleToolbarAction('link')}\n title=\"Link\"\n >\n 🔗\n </button>\n </div>\n )}\n \n {/* Editor Container */}\n <div \n className=\"editor-container\" \n ref={editorRef}\n style={{ height: typeof height === 'number' ? `${height}px` : height }}\n >\n <textarea\n ref={textareaRef}\n className=\"editor-textarea\"\n value={content}\n onChange={handleContentChange}\n disabled={!canEdit}\n placeholder={placeholder}\n />\n \n {/* Mouse Cursors */}\n {showMouseCursors && Object.values(mouseCursors).map(cursor => (\n <div\n key={cursor.socketId}\n className=\"mouse-cursor\"\n style={{\n left: cursor.x,\n top: cursor.y,\n borderColor: cursor.userColor\n }}\n >\n <div \n className=\"cursor-label\" \n style={{ backgroundColor: cursor.userColor }}\n >\n {cursor.userName}\n </div>\n </div>\n ))}\n \n {/* Read-only overlay */}\n {!canEdit && (\n <div className=\"readonly-overlay\">\n <span>👁️ View Only</span>\n </div>\n )}\n </div>\n \n {/* Collaborators */}\n {showCollaborators && (\n <div className=\"collaborators-bar\">\n {collaborators.map(collaborator => (\n <div \n key={collaborator.id}\n className=\"collaborator-avatar\"\n style={{ backgroundColor: collaborator.color }}\n title={`${collaborator.name} (${collaborator.role})${typingUsers[collaborator.id] ? ' - typing...' : ''}`}\n >\n {collaborator.name.charAt(0).toUpperCase()}\n {typingUsers[collaborator.id] && (\n <div className=\"typing-indicator\">✏️</div>\n )}\n </div>\n ))}\n </div>\n )}\n \n <style jsx>{`\n .enhanced-realtime-editor {\n border: 1px solid #e1e5e9;\n border-radius: 8px;\n background: white;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n }\n \n .editor-status {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 12px;\n background: #f8f9fa;\n border-bottom: 1px solid #e1e5e9;\n font-size: 0.85rem;\n }\n \n .status-indicator {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n \n .status-dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: #dc3545;\n }\n \n .status-indicator.connected .status-dot {\n background: #28a745;\n }\n \n .collaborators-info {\n color: #6c757d;\n }\n \n .editor-toolbar {\n display: flex;\n gap: 4px;\n padding: 8px 12px;\n background: #f8f9fa;\n border-bottom: 1px solid #e1e5e9;\n }\n \n .toolbar-btn {\n padding: 6px 10px;\n border: 1px solid #dee2e6;\n background: white;\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.9rem;\n transition: all 0.2s;\n }\n \n .toolbar-btn:hover {\n background: #e9ecef;\n border-color: #007bff;\n }\n \n .editor-container {\n position: relative;\n overflow: hidden;\n }\n \n .editor-textarea {\n width: 100%;\n height: 100%;\n border: none;\n padding: 16px;\n font-size: 14px;\n line-height: 1.5;\n resize: none;\n outline: none;\n font-family: 'Monaco', 'Menlo', monospace;\n }\n \n .editor-textarea:disabled {\n background: #f8f9fa;\n color: #6c757d;\n }\n \n .mouse-cursor {\n position: absolute;\n pointer-events: none;\n z-index: 10;\n }\n \n .mouse-cursor::before {\n content: '';\n position: absolute;\n width: 0;\n height: 0;\n border-left: 6px solid transparent;\n border-right: 6px solid transparent;\n border-bottom: 10px solid;\n border-bottom-color: inherit;\n }\n \n .cursor-label {\n position: absolute;\n top: 12px;\n left: -8px;\n padding: 2px 6px;\n border-radius: 3px;\n font-size: 0.7rem;\n color: white;\n white-space: nowrap;\n }\n \n .readonly-overlay {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: rgba(255,255,255,0.9);\n padding: 12px 20px;\n border-radius: 6px;\n box-shadow: 0 2px 10px rgba(0,0,0,0.1);\n font-weight: 500;\n color: #6c757d;\n }\n \n .collaborators-bar {\n display: flex;\n gap: 8px;\n padding: 8px 12px;\n background: #f8f9fa;\n border-top: 1px solid #e1e5e9;\n align-items: center;\n }\n \n .collaborator-avatar {\n position: relative;\n width: 28px;\n height: 28px;\n border-radius: 50%;\n display: flex;\n align-items: center;\n justify-content: center;\n color: white;\n font-weight: 600;\n font-size: 0.8rem;\n }\n \n .typing-indicator {\n position: absolute;\n top: -4px;\n right: -4px;\n font-size: 0.6rem;\n animation: pulse 1s infinite;\n }\n \n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.5; }\n }\n `}</style>\n </div>\n );\n};\n\nexport default EnhancedRealtimeEditor;