'use client'; import { useEditor, EditorContent, Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; import { useEffect, useRef } from 'react'; import clsx from 'clsx'; interface RichTextEditorProps { content: string; // Markdown content onChange: (content: string) => void; // Returns markdown placeholder?: string; className?: string; editable?: boolean; } // Convert markdown to HTML for TipTap function markdownToHtml(markdown: string): string { if (!markdown) return '

'; let html = markdown; // Convert horizontal rules first (before other processing) html = html.replace(/^---+$/gm, '
'); // Convert headings (must be done before other inline formatting) html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); // Convert bold and italic html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/\*(.+?)\*/g, '$1'); html = html.replace(/__(.+?)__/g, '$1'); html = html.replace(/_(.+?)_/g, '$1'); // Convert unordered lists const lines = html.split('\n'); let inList = false; let listType = ''; const processedLines: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const bulletMatch = line.match(/^[\*\-\+]\s+(.+)$/); const numberedMatch = line.match(/^\d+\.\s+(.+)$/); if (bulletMatch) { if (!inList || listType !== 'ul') { if (inList) processedLines.push(listType === 'ul' ? '' : ''); processedLines.push('' : ''); processedLines.push('
    '); inList = true; listType = 'ol'; } processedLines.push(`
  1. ${numberedMatch[1]}
  2. `); } else { if (inList) { processedLines.push(listType === 'ul' ? '' : '
'); inList = false; listType = ''; } processedLines.push(line); } } if (inList) { processedLines.push(listType === 'ul' ? '' : ''); } html = processedLines.join('\n'); // Convert blockquotes html = html.replace(/^>\s*(.+)$/gm, '

$1

'); // Convert paragraphs (lines that aren't already HTML tags) const finalLines = html.split('\n'); const result: string[] = []; let paragraph: string[] = []; for (const line of finalLines) { const trimmed = line.trim(); if (!trimmed) { // Empty line - close paragraph if open if (paragraph.length > 0) { result.push(`

${paragraph.join('
')}

`); paragraph = []; } } else if (trimmed.startsWith(' 0) { result.push(`

${paragraph.join('
')}

`); paragraph = []; } result.push(trimmed); } else { // Regular text - add to paragraph paragraph.push(trimmed); } } if (paragraph.length > 0) { result.push(`

${paragraph.join('
')}

`); } return result.join('') || '

'; } // Convert HTML from TipTap back to markdown function htmlToMarkdown(html: string): string { if (!html) return ''; let md = html; // Convert headings md = md.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); md = md.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); md = md.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); // Convert bold and italic md = md.replace(/]*>(.*?)<\/strong>/gi, '**$1**'); md = md.replace(/]*>(.*?)<\/b>/gi, '**$1**'); md = md.replace(/]*>(.*?)<\/em>/gi, '*$1*'); md = md.replace(/]*>(.*?)<\/i>/gi, '*$1*'); // Convert lists md = md.replace(/]*>/gi, '\n'); md = md.replace(/<\/ul>/gi, '\n'); md = md.replace(/]*>/gi, '\n'); md = md.replace(/<\/ol>/gi, '\n'); md = md.replace(/]*>(.*?)<\/li>/gi, '* $1\n'); // Convert blockquotes md = md.replace(/]*>]*>(.*?)<\/p><\/blockquote>/gi, '> $1\n\n'); md = md.replace(/]*>(.*?)<\/blockquote>/gi, '> $1\n\n'); // Convert horizontal rules md = md.replace(/]*\/?>/gi, '\n---\n\n'); // Convert paragraphs md = md.replace(/]*>(.*?)<\/p>/gi, '$1\n\n'); // Convert line breaks md = md.replace(/]*\/?>/gi, '\n'); // Remove any remaining HTML tags md = md.replace(/<[^>]+>/g, ''); // Decode HTML entities md = md.replace(/&/g, '&'); md = md.replace(/</g, '<'); md = md.replace(/>/g, '>'); md = md.replace(/"/g, '"'); md = md.replace(/'/g, "'"); md = md.replace(/ /g, ' '); // Clean up extra newlines md = md.replace(/\n{3,}/g, '\n\n'); return md.trim(); } // Toolbar button component function ToolbarButton({ onClick, isActive = false, disabled = false, children, title, }: { onClick: () => void; isActive?: boolean; disabled?: boolean; children: React.ReactNode; title?: string; }) { return ( ); } // Toolbar component function Toolbar({ editor }: { editor: Editor | null }) { if (!editor) return null; return (
{/* Text formatting */} editor.chain().focus().toggleBold().run()} isActive={editor.isActive('bold')} title="Bold (Ctrl+B)" > editor.chain().focus().toggleItalic().run()} isActive={editor.isActive('italic')} title="Italic (Ctrl+I)" >
{/* Headings */} editor.chain().focus().toggleHeading({ level: 1 }).run()} isActive={editor.isActive('heading', { level: 1 })} title="Heading 1" > H1 editor.chain().focus().toggleHeading({ level: 2 }).run()} isActive={editor.isActive('heading', { level: 2 })} title="Heading 2" > H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} isActive={editor.isActive('heading', { level: 3 })} title="Heading 3" > H3
{/* Lists */} editor.chain().focus().toggleBulletList().run()} isActive={editor.isActive('bulletList')} title="Bullet List" > editor.chain().focus().toggleOrderedList().run()} isActive={editor.isActive('orderedList')} title="Numbered List" > 1 2 3
{/* Block elements */} editor.chain().focus().toggleBlockquote().run()} isActive={editor.isActive('blockquote')} title="Quote" > editor.chain().focus().setHorizontalRule().run()} title="Horizontal Rule" >
{/* Undo/Redo */} editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)" > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo (Ctrl+Shift+Z)" >
); } export default function RichTextEditor({ content, onChange, placeholder = 'Start writing...', className = '', editable = true, }: RichTextEditorProps) { const lastContentRef = useRef(content); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3], }, }), Placeholder.configure({ placeholder, }), ], content: markdownToHtml(content), editable, onUpdate: ({ editor }) => { // Convert HTML back to markdown const markdown = htmlToMarkdown(editor.getHTML()); lastContentRef.current = markdown; onChange(markdown); }, editorProps: { attributes: { class: 'prose prose-sm max-w-none focus:outline-none min-h-[400px] p-4', }, }, }); // Update content when prop changes (e.g., switching languages) useEffect(() => { if (editor && content !== lastContentRef.current) { lastContentRef.current = content; const html = markdownToHtml(content); editor.commands.setContent(html); } }, [content, editor]); return (
{editable && }
); } // Read-only preview component export function RichTextPreview({ content, className = '', }: { content: string; // Markdown content className?: string; }) { const editor = useEditor({ extensions: [StarterKit], content: markdownToHtml(content), editable: false, editorProps: { attributes: { class: 'prose prose-sm max-w-none p-4', }, }, }); useEffect(() => { if (editor) { editor.commands.setContent(markdownToHtml(content)); } }, [content, editor]); return (
); }