UNPKG

sourabhrealtime

Version:

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

377 lines (343 loc) 12.1 kB
import React, { useState, useEffect, useRef, useCallback } from 'react'; // Fixed Editor Component that prevents cursor jumping const FixedEditor = ({ content, onChange, onTypingStart, onTypingStop, currentUser, projectId, socket, collaborators = [], typingUsers = [] }) => { const editorRef = useRef(null); const [isTyping, setIsTyping] = useState(false); const [cursorPosition, setCursorPosition] = useState(0); const typingTimeoutRef = useRef(null); const isUpdatingRef = useRef(false); // Save cursor position before content updates const saveCursorPosition = useCallback(() => { if (editorRef.current && document.activeElement === editorRef.current) { const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(editorRef.current); preCaretRange.setEnd(range.endContainer, range.endOffset); setCursorPosition(preCaretRange.toString().length); } } }, []); // Restore cursor position after content updates const restoreCursorPosition = useCallback(() => { if (editorRef.current && cursorPosition >= 0) { const selection = window.getSelection(); const range = document.createRange(); let charCount = 0; let nodeStack = [editorRef.current]; let node, foundStart = false; while (!foundStart && (node = nodeStack.pop())) { if (node.nodeType === Node.TEXT_NODE) { const nextCharCount = charCount + node.textContent.length; if (cursorPosition >= charCount && cursorPosition <= nextCharCount) { range.setStart(node, cursorPosition - charCount); range.setEnd(node, cursorPosition - charCount); foundStart = true; } charCount = nextCharCount; } else { for (let i = node.childNodes.length - 1; i >= 0; i--) { nodeStack.push(node.childNodes[i]); } } } if (foundStart) { selection.removeAllRanges(); selection.addRange(range); } } }, [cursorPosition]); // Handle content changes const handleContentChange = useCallback(() => { if (isUpdatingRef.current) return; const newContent = editorRef.current.innerHTML; saveCursorPosition(); if (onChange) { onChange(newContent); } // Handle typing indicators if (!isTyping) { setIsTyping(true); if (onTypingStart && socket && projectId && currentUser) { socket.emit('typing-start', { projectId, user: currentUser, cursorPosition }); } } // Clear existing timeout and set new one if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } typingTimeoutRef.current = setTimeout(() => { setIsTyping(false); if (onTypingStop && socket && projectId && currentUser) { socket.emit('typing-stop', { projectId, user: currentUser }); } }, 1000); }, [isTyping, onChange, onTypingStart, onTypingStop, socket, projectId, currentUser, cursorPosition, saveCursorPosition]); // Update content from external changes (other users) useEffect(() => { if (editorRef.current && content !== editorRef.current.innerHTML) { isUpdatingRef.current = true; saveCursorPosition(); editorRef.current.innerHTML = content; setTimeout(() => { restoreCursorPosition(); isUpdatingRef.current = false; }, 0); } }, [content, saveCursorPosition, restoreCursorPosition]); // Handle mouse movements for real-time tracking const handleMouseMove = useCallback((e) => { if (socket && projectId && currentUser) { const rect = editorRef.current.getBoundingClientRect(); const mousePosition = { x: e.clientX - rect.left, y: e.clientY - rect.top, relativeX: (e.clientX - rect.left) / rect.width, relativeY: (e.clientY - rect.top) / rect.height }; socket.emit('mouse-move', { projectId, mousePosition, user: currentUser }); } }, [socket, projectId, currentUser]); // Formatting functions const formatText = useCallback((command, value = null) => { document.execCommand(command, false, value); handleContentChange(); }, [handleContentChange]); // Cleanup on unmount useEffect(() => { return () => { if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } }; }, []); return React.createElement('div', { className: 'fixed-editor-container' }, // Enhanced Toolbar React.createElement('div', { className: 'editor-toolbar enhanced-toolbar' }, // History React.createElement('div', { className: 'toolbar-group' }, React.createElement('button', { onClick: () => formatText('undo'), className: 'toolbar-btn', title: 'Undo (Ctrl+Z)' }, '↶'), React.createElement('button', { onClick: () => formatText('redo'), className: 'toolbar-btn', title: 'Redo (Ctrl+Y)' }, '↷') ), React.createElement('div', { className: 'toolbar-separator' }), // Text formatting React.createElement('div', { className: 'toolbar-group' }, React.createElement('button', { onClick: () => formatText('bold'), className: 'toolbar-btn', title: 'Bold (Ctrl+B)' }, React.createElement('strong', null, 'B')), React.createElement('button', { onClick: () => formatText('italic'), className: 'toolbar-btn', title: 'Italic (Ctrl+I)' }, React.createElement('em', null, 'I')), React.createElement('button', { onClick: () => formatText('underline'), className: 'toolbar-btn', title: 'Underline (Ctrl+U)' }, React.createElement('u', null, 'U')), React.createElement('button', { onClick: () => formatText('strikeThrough'), className: 'toolbar-btn', title: 'Strikethrough' }, React.createElement('s', null, 'S')) ), React.createElement('div', { className: 'toolbar-separator' }), // Headings React.createElement('div', { className: 'toolbar-group' }, React.createElement('button', { onClick: () => formatText('formatBlock', 'h1'), className: 'toolbar-btn', title: 'Heading 1' }, 'H1'), React.createElement('button', { onClick: () => formatText('formatBlock', 'h2'), className: 'toolbar-btn', title: 'Heading 2' }, 'H2'), React.createElement('button', { onClick: () => formatText('formatBlock', 'h3'), className: 'toolbar-btn', title: 'Heading 3' }, 'H3') ), React.createElement('div', { className: 'toolbar-separator' }), // Lists React.createElement('div', { className: 'toolbar-group' }, React.createElement('button', { onClick: () => formatText('insertUnorderedList'), className: 'toolbar-btn', title: 'Bullet List' }, '• List'), React.createElement('button', { onClick: () => formatText('insertOrderedList'), className: 'toolbar-btn', title: 'Numbered List' }, '1. List') ), React.createElement('div', { className: 'toolbar-separator' }), // Alignment React.createElement('div', { className: 'toolbar-group' }, React.createElement('button', { onClick: () => formatText('justifyLeft'), className: 'toolbar-btn', title: 'Align Left' }, '⬅️'), React.createElement('button', { onClick: () => formatText('justifyCenter'), className: 'toolbar-btn', title: 'Align Center' }, '↔️'), React.createElement('button', { onClick: () => formatText('justifyRight'), className: 'toolbar-btn', title: 'Align Right' }, '➡️') ), React.createElement('div', { className: 'toolbar-separator' }), // Insert React.createElement('div', { className: 'toolbar-group' }, React.createElement('button', { onClick: () => { const url = window.prompt('Enter link URL:'); if (url) formatText('createLink', url); }, className: 'toolbar-btn', title: 'Insert Link' }, '🔗'), React.createElement('button', { onClick: () => { const url = window.prompt('Enter image URL:'); if (url) formatText('insertImage', url); }, className: 'toolbar-btn', title: 'Insert Image' }, '🖼️'), React.createElement('button', { onClick: () => formatText('insertHorizontalRule'), className: 'toolbar-btn', title: 'Horizontal Rule' }, '➖') ), React.createElement('div', { className: 'toolbar-separator' }), // Colors React.createElement('div', { className: 'toolbar-group' }, React.createElement('button', { onClick: () => formatText('hiliteColor', 'yellow'), className: 'toolbar-btn', title: 'Highlight' }, '🖍️'), React.createElement('button', { onClick: () => { const color = window.prompt('Enter color (e.g., red, #ff0000):'); if (color) formatText('foreColor', color); }, className: 'toolbar-btn', title: 'Text Color' }, '🎨') ) ), // Editor Content React.createElement('div', { className: 'editor-content-wrapper', style: { position: 'relative' } }, React.createElement('div', { ref: editorRef, className: 'editor-content fixed-editor-content', contentEditable: true, onInput: handleContentChange, onMouseMove: handleMouseMove, onKeyUp: saveCursorPosition, onClick: saveCursorPosition, style: { minHeight: '400px', padding: '20px', border: '1px solid #e2e8f0', borderRadius: '8px', outline: 'none', fontSize: '16px', lineHeight: '1.6', fontFamily: 'Inter, sans-serif' }, suppressContentEditableWarning: true }), // Real-time mouse cursors collaborators.map(collaborator => { return React.createElement('div', { key: `mouse-${collaborator.id}`, className: 'mouse-cursor', style: { position: 'absolute', pointerEvents: 'none', zIndex: 1000, width: '20px', height: '20px', background: collaborator.color || '#3b82f6', borderRadius: '50%', transform: 'translate(-50%, -50%)', opacity: 0.7 } }, React.createElement('div', { style: { position: 'absolute', top: '25px', left: '0', background: collaborator.color || '#3b82f6', color: 'white', padding: '2px 6px', borderRadius: '4px', fontSize: '12px', whiteSpace: 'nowrap' } }, collaborator.name) ); }) ), // Typing indicators typingUsers.length > 0 && React.createElement('div', { className: 'typing-indicators', style: { padding: '8px 16px', fontSize: '14px', color: '#6b7280', fontStyle: 'italic', borderTop: '1px solid #e5e7eb', background: '#f9fafb' } }, `${typingUsers.map(t => t.user.name).join(', ')} ${typingUsers.length === 1 ? 'is' : 'are'} typing...`) ); }; export default FixedEditor;