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