sourabhrealtime
Version:
ROBUST RICH TEXT EDITOR: Single-pane contentEditable with direct text selection formatting, speech features, undo/redo, professional UI - Perfect TipTap alternative
446 lines (413 loc) • 16.4 kB
JavaScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
// Enhanced TipTap Editor with Real-time Features
const EnhancedTipTapEditor = ({
content,
onChange,
onCursorChange,
onSelectionChange,
onTypingStart,
onTypingStop,
collaborators = [],
typingUsers = [],
currentUser,
projectId,
socket
}) => {
const editorRef = useRef(null);
const [editor, setEditor] = useState(null);
const [isTyping, setIsTyping] = useState(false);
const typingTimeoutRef = useRef(null);
const lastContentRef = useRef(content);
// Initialize TipTap editor
useEffect(() => {
if (typeof window === 'undefined') return;
const initEditor = async () => {
try {
// Dynamic import for TipTap
const { Editor } = await import('@tiptap/core');
const { StarterKit } = await import('@tiptap/starter-kit');
const { Image } = await import('@tiptap/extension-image');
const { Link } = await import('@tiptap/extension-link');
const { TextAlign } = await import('@tiptap/extension-text-align');
const { Underline } = await import('@tiptap/extension-underline');
const { Highlight } = await import('@tiptap/extension-highlight');
const { TaskList } = await import('@tiptap/extension-task-list');
const { TaskItem } = await import('@tiptap/extension-task-item');
const { Table } = await import('@tiptap/extension-table');
const { TableRow } = await import('@tiptap/extension-table-row');
const { TableCell } = await import('@tiptap/extension-table-cell');
const { TableHeader } = await import('@tiptap/extension-table-header');
const { Collaboration } = await import('@tiptap/extension-collaboration');
const { CollaborationCursor } = await import('@tiptap/extension-collaboration-cursor');
const editorInstance = new Editor({
element: editorRef.current,
extensions: [
StarterKit.configure({
history: false, // We'll handle history with collaboration
}),
Image.configure({
inline: true,
allowBase64: true,
}),
Link.configure({
openOnClick: false,
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Highlight.configure({
multicolor: true,
}),
TaskList,
TaskItem.configure({
nested: true,
}),
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
// Real-time collaboration extensions would go here
// For now, we'll handle collaboration manually
],
content: content || '<p>Start typing...</p>',
editorProps: {
attributes: {
class: 'tiptap-editor',
spellcheck: 'false',
},
handleDOMEvents: {
// Handle mouse movements for real-time cursor tracking
mousemove: (view, event) => {
if (socket && projectId && currentUser) {
const rect = view.dom.getBoundingClientRect();
const mousePosition = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
};
socket.emit('mouse-move', {
projectId,
mousePosition,
user: currentUser
});
}
return false;
},
// Handle selection changes
selectionchange: (view) => {
const { from, to } = view.state.selection;
if (onSelectionChange) {
onSelectionChange({ from, to });
}
if (socket && projectId && currentUser) {
socket.emit('selection-change', {
projectId,
selection: { from, to },
user: currentUser
});
}
return false;
}
}
},
onUpdate: ({ editor }) => {
const html = editor.getHTML();
// Prevent cursor jumping by checking if content actually changed
if (html !== lastContentRef.current) {
lastContentRef.current = html;
if (onChange) {
onChange(html);
}
// Handle typing indicators
if (!isTyping) {
setIsTyping(true);
if (onTypingStart) {
const { from, to } = editor.state.selection;
onTypingStart({ from, to });
}
}
// Clear existing timeout and set new one
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
setIsTyping(false);
if (onTypingStop) {
onTypingStop();
}
}, 1000);
}
},
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection;
if (onCursorChange) {
onCursorChange({ from, to });
}
}
});
setEditor(editorInstance);
} catch (error) {
console.error('Failed to initialize TipTap editor:', error);
// Fallback to simple contentEditable
if (editorRef.current) {
editorRef.current.contentEditable = true;
editorRef.current.innerHTML = content || '<p>Start typing...</p>';
}
}
};
initEditor();
return () => {
if (editor) {
editor.destroy();
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
// Update content when it changes externally (from other users)
useEffect(() => {
if (editor && content !== lastContentRef.current) {
const { from, to } = editor.state.selection;
editor.commands.setContent(content, false);
// Restore cursor position
editor.commands.setTextSelection({ from, to });
lastContentRef.current = content;
}
}, [content, editor]);
// Toolbar commands
const commands = {
bold: () => editor?.chain().focus().toggleBold().run(),
italic: () => editor?.chain().focus().toggleItalic().run(),
underline: () => editor?.chain().focus().toggleUnderline().run(),
strike: () => editor?.chain().focus().toggleStrike().run(),
code: () => editor?.chain().focus().toggleCode().run(),
heading1: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
heading2: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
heading3: () => editor?.chain().focus().toggleHeading({ level: 3 }).run(),
paragraph: () => editor?.chain().focus().setParagraph().run(),
bulletList: () => editor?.chain().focus().toggleBulletList().run(),
orderedList: () => editor?.chain().focus().toggleOrderedList().run(),
taskList: () => editor?.chain().focus().toggleTaskList().run(),
blockquote: () => editor?.chain().focus().toggleBlockquote().run(),
codeBlock: () => editor?.chain().focus().toggleCodeBlock().run(),
horizontalRule: () => editor?.chain().focus().setHorizontalRule().run(),
undo: () => editor?.chain().focus().undo().run(),
redo: () => editor?.chain().focus().redo().run(),
alignLeft: () => editor?.chain().focus().setTextAlign('left').run(),
alignCenter: () => editor?.chain().focus().setTextAlign('center').run(),
alignRight: () => editor?.chain().focus().setTextAlign('right').run(),
alignJustify: () => editor?.chain().focus().setTextAlign('justify').run(),
highlight: () => editor?.chain().focus().toggleHighlight().run(),
link: () => {
const url = window.prompt('Enter URL:');
if (url) {
editor?.chain().focus().setLink({ href: url }).run();
}
},
image: () => {
const url = window.prompt('Enter image URL:');
if (url) {
editor?.chain().focus().setImage({ src: url }).run();
}
},
table: () => {
editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}
};
return React.createElement('div', { className: 'enhanced-editor-container' },
// Toolbar
React.createElement('div', { className: 'editor-toolbar enhanced-toolbar' },
// History
React.createElement('div', { className: 'toolbar-group' },
React.createElement('button', {
onClick: commands.undo,
className: 'toolbar-btn',
title: 'Undo (Ctrl+Z)'
}, '↶'),
React.createElement('button', {
onClick: commands.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: commands.bold,
className: `toolbar-btn ${editor?.isActive('bold') ? 'active' : ''}`,
title: 'Bold (Ctrl+B)'
}, React.createElement('strong', null, 'B')),
React.createElement('button', {
onClick: commands.italic,
className: `toolbar-btn ${editor?.isActive('italic') ? 'active' : ''}`,
title: 'Italic (Ctrl+I)'
}, React.createElement('em', null, 'I')),
React.createElement('button', {
onClick: commands.underline,
className: `toolbar-btn ${editor?.isActive('underline') ? 'active' : ''}`,
title: 'Underline (Ctrl+U)'
}, React.createElement('u', null, 'U')),
React.createElement('button', {
onClick: commands.strike,
className: `toolbar-btn ${editor?.isActive('strike') ? 'active' : ''}`,
title: 'Strikethrough'
}, React.createElement('s', null, 'S')),
React.createElement('button', {
onClick: commands.highlight,
className: `toolbar-btn ${editor?.isActive('highlight') ? 'active' : ''}`,
title: 'Highlight'
}, '🖍️')
),
React.createElement('div', { className: 'toolbar-separator' }),
// Headings
React.createElement('div', { className: 'toolbar-group' },
React.createElement('button', {
onClick: commands.heading1,
className: `toolbar-btn ${editor?.isActive('heading', { level: 1 }) ? 'active' : ''}`,
title: 'Heading 1'
}, 'H1'),
React.createElement('button', {
onClick: commands.heading2,
className: `toolbar-btn ${editor?.isActive('heading', { level: 2 }) ? 'active' : ''}`,
title: 'Heading 2'
}, 'H2'),
React.createElement('button', {
onClick: commands.heading3,
className: `toolbar-btn ${editor?.isActive('heading', { level: 3 }) ? 'active' : ''}`,
title: 'Heading 3'
}, 'H3')
),
React.createElement('div', { className: 'toolbar-separator' }),
// Lists
React.createElement('div', { className: 'toolbar-group' },
React.createElement('button', {
onClick: commands.bulletList,
className: `toolbar-btn ${editor?.isActive('bulletList') ? 'active' : ''}`,
title: 'Bullet List'
}, '• List'),
React.createElement('button', {
onClick: commands.orderedList,
className: `toolbar-btn ${editor?.isActive('orderedList') ? 'active' : ''}`,
title: 'Numbered List'
}, '1. List'),
React.createElement('button', {
onClick: commands.taskList,
className: `toolbar-btn ${editor?.isActive('taskList') ? 'active' : ''}`,
title: 'Task List'
}, '☑️ Tasks')
),
React.createElement('div', { className: 'toolbar-separator' }),
// Alignment
React.createElement('div', { className: 'toolbar-group' },
React.createElement('button', {
onClick: commands.alignLeft,
className: `toolbar-btn ${editor?.isActive({ textAlign: 'left' }) ? 'active' : ''}`,
title: 'Align Left'
}, '⬅️'),
React.createElement('button', {
onClick: commands.alignCenter,
className: `toolbar-btn ${editor?.isActive({ textAlign: 'center' }) ? 'active' : ''}`,
title: 'Align Center'
}, '↔️'),
React.createElement('button', {
onClick: commands.alignRight,
className: `toolbar-btn ${editor?.isActive({ textAlign: 'right' }) ? 'active' : ''}`,
title: 'Align Right'
}, '➡️')
),
React.createElement('div', { className: 'toolbar-separator' }),
// Insert
React.createElement('div', { className: 'toolbar-group' },
React.createElement('button', {
onClick: commands.link,
className: `toolbar-btn ${editor?.isActive('link') ? 'active' : ''}`,
title: 'Insert Link'
}, '🔗'),
React.createElement('button', {
onClick: commands.image,
className: 'toolbar-btn',
title: 'Insert Image'
}, '🖼️'),
React.createElement('button', {
onClick: commands.table,
className: 'toolbar-btn',
title: 'Insert Table'
}, '📊'),
React.createElement('button', {
onClick: commands.horizontalRule,
className: 'toolbar-btn',
title: 'Horizontal Rule'
}, '➖')
),
React.createElement('div', { className: 'toolbar-separator' }),
// Block elements
React.createElement('div', { className: 'toolbar-group' },
React.createElement('button', {
onClick: commands.blockquote,
className: `toolbar-btn ${editor?.isActive('blockquote') ? 'active' : ''}`,
title: 'Quote'
}, '💬'),
React.createElement('button', {
onClick: commands.codeBlock,
className: `toolbar-btn ${editor?.isActive('codeBlock') ? 'active' : ''}`,
title: 'Code Block'
}, '</>')
)
),
// Editor
React.createElement('div', {
className: 'editor-content-wrapper',
style: { position: 'relative' }
},
React.createElement('div', {
ref: editorRef,
className: 'tiptap-editor enhanced-editor-content'
}),
// Real-time cursors overlay
React.createElement('div', { className: 'cursors-overlay' },
collaborators.map(collaborator => {
const typingUser = typingUsers.find(t => t.user.id === collaborator.id);
return React.createElement('div', {
key: collaborator.id,
className: 'collaborator-cursor',
style: {
position: 'absolute',
pointerEvents: 'none',
zIndex: 1000
}
},
typingUser && React.createElement('div', {
className: 'typing-indicator',
style: {
background: collaborator.color || '#3b82f6',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap'
}
}, `${collaborator.name} is typing...`)
);
})
)
),
// 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'
}
}, `${typingUsers.map(t => t.user.name).join(', ')} ${typingUsers.length === 1 ? 'is' : 'are'} typing...`)
);
};
export default EnhancedTipTapEditor;