'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 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(`$1
${paragraph.join('
')}
${paragraph.join('
')}
${paragraph.join('
')}
]*>]*>(.*?)<\/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 */}); } 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 (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" > {/* 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)" > {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 (} ); }