Files
SkinbaseNova/resources/js/components/forum/RichTextEditor.jsx

324 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={[
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
active
? 'bg-sky-600/25 text-sky-300'
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
disabled && 'opacity-30 pointer-events-none',
].filter(Boolean).join(' ')}
>
{children}
</button>
)
}
function Divider() {
return <div className="mx-1 h-5 w-px bg-white/10" />
}
/* ─── 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 (
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
{/* Text formatting */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive('underline')}
title="Underline (Ctrl+U)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
title="Strikethrough"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><path d="M17.5 7.5c0-2-1.5-3.5-5.5-3.5S6.5 5.5 6.5 7.5c0 4 11 4 11 8 0 2-1.5 3.5-5.5 3.5s-5.5-1.5-5.5-3.5"/></svg>
</ToolbarBtn>
<Divider />
{/* Headings */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<span className="text-xs font-bold">H2</span>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<span className="text-xs font-bold">H3</span>
</ToolbarBtn>
<Divider />
{/* Lists */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet list"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Numbered list"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
</ToolbarBtn>
<Divider />
{/* Block elements */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title="Quote"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')}
title="Code block"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
title="Inline code"
>
<span className="font-mono text-[11px] font-bold">{'{}'}</span>
</ToolbarBtn>
<Divider />
{/* Link & Image */}
<ToolbarBtn
onClick={addLink}
active={editor.isActive('link')}
title="Link"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</ToolbarBtn>
<ToolbarBtn onClick={addImage} title="Insert image">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</ToolbarBtn>
<Divider />
{/* Horizontal rule */}
<ToolbarBtn
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal rule"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>
</ToolbarBtn>
{/* Emoji picker */}
<EmojiPicker editor={editor} />
{/* Mention hint */}
<ToolbarBtn
onClick={() => editor.chain().focus().insertContent('@').run()}
title="Mention a user (type @username)"
>
<span className="text-xs font-bold">@</span>
</ToolbarBtn>
{/* Undo / Redo */}
<div className="ml-auto flex items-center gap-0.5">
<ToolbarBtn
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Shift+Z)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
</ToolbarBtn>
</div>
</div>
)
}
/* ─── 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({
link: false,
underline: false,
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,
}),
],
immediatelyRender: false,
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)
// Keep the parent form state in sync with what we just rendered.
// setContent with emitUpdate=false silently resets TipTap without
// calling onUpdate, so form.data.content would lag behind the editor.
onChange?.(content)
}
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex flex-col gap-1.5">
<div
className={[
'overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
error
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
].join(' ')}
>
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
{error && (
<p role="alert" className="text-xs text-red-400">{error}</p>
)}
</div>
)
}