import React, { useCallback, useEffect, useState } from 'react' import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import Underline from '@tiptap/extension-underline' import Mention from '@tiptap/extension-mention' import mentionSuggestion from './mentionSuggestion' import EmojiPicker from './EmojiPicker' /* ─── Toolbar button ─── */ function ToolbarBtn({ onClick, active, disabled, title, children }) { return ( ) } function Divider() { return
} /* ─── Toolbar ─── */ function Toolbar({ editor }) { if (!editor) return null const addLink = useCallback(() => { const prev = editor.getAttributes('link').href const url = window.prompt('URL', prev ?? 'https://') if (url === null) return if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run() } else { editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() } }, [editor]) const addImage = useCallback(() => { const url = window.prompt('Image URL', 'https://') if (url) { editor.chain().focus().setImage({ src: url }).run() } }, [editor]) return (
{/* Text formatting */} editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold (Ctrl+B)" > editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic (Ctrl+I)" > editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline (Ctrl+U)" > editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough" > {/* Headings */} editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2" > H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3" > H3 {/* Lists */} editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list" > editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list" > 123 {/* Block elements */} editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote" > editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code block" > editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code" > {'{}'} {/* Link & Image */} {/* Horizontal rule */} editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule" > {/* Emoji picker */} {/* Mention hint */} editor.chain().focus().insertContent('@').run()} title="Mention a user (type @username)" > @ {/* 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)" >
) } /* ─── Main editor component ─── */ /** * Rich text editor for forum posts & replies. * * @prop {string} content – initial HTML content * @prop {function} onChange – called with HTML string on every change * @prop {string} placeholder – placeholder text * @prop {string} error – validation error message * @prop {number} minHeight – min height in rem (default 12) * @prop {boolean} autofocus – focus on mount */ export default function RichTextEditor({ content = '', onChange, placeholder = 'Write something…', error, minHeight = 12, autofocus = false, }) { const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [2, 3] }, codeBlock: { HTMLAttributes: { class: 'forum-code-block' }, }, }), Underline, Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-sky-300 underline hover:text-sky-200', rel: 'noopener noreferrer nofollow', }, }), Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full' }, }), Placeholder.configure({ placeholder }), Mention.configure({ HTMLAttributes: { class: 'mention', }, suggestion: mentionSuggestion, }), ], content, autofocus, editorProps: { attributes: { class: [ 'prose prose-invert prose-sm max-w-none', 'focus:outline-none', 'px-4 py-3', 'prose-headings:text-white prose-headings:font-bold', 'prose-p:text-zinc-200 prose-p:leading-relaxed', 'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200', 'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400', 'prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-xs', 'prose-pre:bg-white/[0.04] prose-pre:border prose-pre:border-white/[0.06] prose-pre:rounded-xl', 'prose-img:rounded-xl', 'prose-hr:border-white/10', ].join(' '), style: `min-height: ${minHeight}rem`, }, }, onUpdate: ({ editor: e }) => { onChange?.(e.getHTML()) }, }) // Sync content from outside (e.g. prefill / quote) useEffect(() => { if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) { editor.commands.setContent(content, false) } }, [content]) // eslint-disable-line react-hooks/exhaustive-deps return (
{error && (

{error}

)}
) }