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"
>
{/* 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 (
)
}