UNPKG

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